Implement people management features by adding API endpoints for retrieving school rosters and individual person cards. Enhance the UI to support a people browser with filtering and pagination capabilities. Update localization strings for improved user experience and ensure robust handling of person data. Revise documentation to reflect new API functionalities and update tests to validate the new features.
ci / server (push) Failing after 11s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 19:44:42 +03:00
parent 52c5082418
commit 533bd80f5e
23 changed files with 2134 additions and 47 deletions
@@ -21,6 +21,7 @@ describe('t', () => {
setLocale('en');
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16');
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
});
});
+80
View File
@@ -83,6 +83,46 @@ const ru = {
charactersEmpty: 'Никого нет.',
activitiesEmpty: 'Ничего не происходит.',
positionsEmpty: 'Нет должностей.',
peopleTitle: 'Люди',
peopleRole: 'Роль',
peopleRoleAll: 'Все',
peopleRoleStudent: 'Ученик',
peopleRoleStaff: 'Работник',
peopleRoleParent: 'Родитель',
peopleYear: 'Параллель',
peopleYearAll: 'Все',
peopleLetter: 'Литера',
peopleLetterAll: 'Все',
peoplePosition: 'Должность',
peoplePositionAll: 'Все',
peopleSex: 'Пол',
peopleSexAll: 'Все',
peopleMale: 'муж.',
peopleFemale: 'жен.',
peopleAgeFrom: 'Возраст от',
peopleAgeTo: 'до',
peopleColName: 'ФИО',
peopleColRole: 'Роль',
peopleColPlace: 'Класс / должность',
peopleColAge: 'Возраст',
peopleColSex: 'Пол',
peopleEmpty: 'Никого нет.',
peopleLoadFailed: 'Не удалось загрузить список людей.',
peopleCardFailed: 'Не удалось открыть карточку.',
peoplePickHint: 'Выберите человека в списке.',
peoplePager: 'Стр. {page} из {pages} · {total}',
peoplePrev: 'Назад',
peopleNext: 'Вперёд',
peopleBody: 'Тело',
peopleSkills: 'Навыки',
peopleTraits: 'Черты',
peopleNeeds: 'Нужды',
peopleFamily: 'Семья',
peopleParents: 'Родители',
peopleChildren: 'Дети',
peopleSiblings: 'Братья и сёстры',
peoplePartners: 'Супруг(а)',
} as const;
type Messages = { [K in keyof typeof ru]: string };
@@ -170,6 +210,46 @@ const en: Messages = {
charactersEmpty: 'Nobody here.',
activitiesEmpty: 'Nothing is happening.',
positionsEmpty: 'No positions.',
peopleTitle: 'People',
peopleRole: 'Role',
peopleRoleAll: 'All',
peopleRoleStudent: 'Student',
peopleRoleStaff: 'Staff',
peopleRoleParent: 'Parent',
peopleYear: 'Year',
peopleYearAll: 'All',
peopleLetter: 'Letter',
peopleLetterAll: 'All',
peoplePosition: 'Position',
peoplePositionAll: 'All',
peopleSex: 'Sex',
peopleSexAll: 'All',
peopleMale: 'male',
peopleFemale: 'female',
peopleAgeFrom: 'Age from',
peopleAgeTo: 'to',
peopleColName: 'Name',
peopleColRole: 'Role',
peopleColPlace: 'Class / position',
peopleColAge: 'Age',
peopleColSex: 'Sex',
peopleEmpty: 'Nobody here.',
peopleLoadFailed: 'Could not load the people list.',
peopleCardFailed: 'Could not open the card.',
peoplePickHint: 'Select a person in the list.',
peoplePager: 'Page {page} of {pages} · {total}',
peoplePrev: 'Previous',
peopleNext: 'Next',
peopleBody: 'Body',
peopleSkills: 'Skills',
peopleTraits: 'Traits',
peopleNeeds: 'Needs',
peopleFamily: 'Family',
peopleParents: 'Parents',
peopleChildren: 'Children',
peopleSiblings: 'Siblings',
peoplePartners: 'Spouse',
};
const catalogs: Record<Locale, Messages> = { ru, en };
+116 -2
View File
@@ -1,6 +1,6 @@
/**
* HTTP side of the server: everything the main menu needs. The realtime clock arrives over the
* WebSocket instead — see `connection.ts`.
* HTTP side of the server: the main menu and the in-school people list/card. The realtime clock
* arrives over the WebSocket instead — see `connection.ts`.
*/
export interface School {
@@ -132,6 +132,120 @@ export async function deleteSchool(id: number): Promise<void> {
await request<void>(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false });
}
export type PersonRole = 'student' | 'staff' | 'parent';
export type PersonSort = 'surname' | 'age' | 'year' | 'position';
export interface PeopleQuery {
readonly role?: PersonRole | '';
readonly year?: number | '';
readonly letter?: string;
readonly position?: string;
readonly sex?: 'male' | 'female' | '';
readonly ageMin?: number | '';
readonly ageMax?: number | '';
readonly sort?: PersonSort;
readonly dir?: 'asc' | 'desc';
readonly page?: number;
readonly pageSize?: number;
}
export interface PersonListItem {
readonly id: string;
readonly fullName: string;
readonly surname: string;
readonly given: string;
readonly patronymic: string;
readonly female: boolean;
readonly age: number;
readonly roles: readonly string[];
readonly classYear: number | null;
readonly classLetter: string | null;
readonly position: string | null;
readonly positionLabel: string | null;
}
export interface DefLabel {
readonly defName: string;
readonly label: string;
}
export interface PeoplePage {
readonly total: number;
readonly page: number;
readonly pageSize: number;
readonly people: readonly PersonListItem[];
readonly filters: {
readonly years: readonly number[];
readonly letters: readonly string[];
readonly positions: readonly DefLabel[];
};
}
export interface LabeledStat {
readonly id: string;
readonly label: string;
readonly value: string;
}
export interface NeedStat {
readonly id: string;
readonly label: string;
readonly value: number;
}
export interface PersonRel {
readonly id: string;
readonly fullName: string;
readonly female: boolean;
}
export interface PersonCard {
readonly id: string;
readonly fullName: string;
readonly surname: string;
readonly given: string;
readonly patronymic: string;
readonly female: boolean;
readonly age: number;
readonly birthDate: string;
readonly roles: readonly string[];
readonly classYear: number | null;
readonly classLetter: string | null;
readonly position: string | null;
readonly positionLabel: string | null;
readonly body: readonly LabeledStat[];
readonly skills: readonly LabeledStat[];
readonly traits: readonly DefLabel[];
readonly needs: readonly NeedStat[];
readonly family: {
readonly parents: readonly PersonRel[];
readonly children: readonly PersonRel[];
readonly siblings: readonly PersonRel[];
readonly partners: readonly PersonRel[];
};
}
export async function fetchPeople(schoolId: number, query: PeopleQuery, lang: string): Promise<PeoplePage> {
const params = new URLSearchParams({ lang });
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === '') {
continue;
}
params.set(key, String(value));
}
return request<PeoplePage>(`/api/schools/${schoolId}/people?${params.toString()}`);
}
export async function fetchPerson(schoolId: number, personId: string, lang: string): Promise<PersonCard> {
const params = new URLSearchParams({ lang });
return request<PersonCard>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}?${params.toString()}`,
);
}
async function request<T>(
url: string,
init?: RequestInit,
+180 -1
View File
@@ -244,13 +244,21 @@ body {
/* Manager */
.shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
margin-top: 16px;
gap: 16px;
}
.manager {
display: grid;
grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr);
gap: 16px;
flex: 1;
min-height: 0;
margin-top: 16px;
}
.panel {
@@ -357,6 +365,168 @@ body {
color: var(--accent);
}
.panel--people {
flex: 0 1 38vh;
min-height: 220px;
max-height: 42vh;
}
.people {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
}
.people__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: flex-end;
}
.people__field {
display: flex;
flex-direction: column;
gap: 2px;
}
.people__label {
font-size: 11px;
color: var(--text-muted);
}
.people__input {
min-width: 7.5rem;
padding: 6px 8px;
font-size: 13px;
}
.people__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 0.42fr);
gap: 12px;
flex: 1;
min-height: 0;
}
.people__list {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.people__table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.people__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.people__table th,
.people__table td {
padding: 5px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
white-space: nowrap;
}
.people__table th {
position: sticky;
top: 0;
background: var(--surface-raised);
z-index: 1;
}
.people__sort {
padding: 0;
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
}
.people__sort:hover,
.people__sort[aria-sort='ascending'],
.people__sort[aria-sort='descending'] {
color: var(--accent);
}
.people__row {
cursor: pointer;
}
.people__row:hover {
background: rgba(76, 201, 240, 0.08);
}
.people__row--active {
background: rgba(76, 201, 240, 0.16);
}
.people__pager {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
color: var(--text-muted);
font-size: 13px;
}
.people__pager-label {
flex: 1;
}
.people__card {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-sunken);
font-size: 13px;
}
.people__card-name {
margin: 0 0 4px;
font-size: 16px;
}
.people__card-meta {
margin: 0 0 12px;
color: var(--text-muted);
}
.people__section {
margin-top: 12px;
}
.people__stats {
margin: 0;
padding-left: 18px;
}
.people__link {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
/* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) {
#app {
@@ -372,6 +542,15 @@ body {
.panel__body {
overflow: visible;
}
.panel--people {
flex: 0 0 auto;
max-height: none;
}
.people__main {
grid-template-columns: 1fr;
}
}
/* Controls */
+34 -24
View File
@@ -3,6 +3,7 @@ import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../forma
import { t } from '../i18n/strings.ts';
import type { School } from '../net/api.ts';
import { clear, el } from './dom.ts';
import { PeoplePanel } from './peoplePanel.ts';
interface GameScreenOptions {
readonly onLeave: () => void;
@@ -15,7 +16,7 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
/**
* The inside of a school: calendar controls plus the manager shell. The tree and location
* lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot
* on the client. People, in-place activities and events stay empty in this slice.
* on the client. The people panel loads its page over HTTP.
*/
export class GameScreen {
private readonly root = el('section', { class: 'screen game' });
@@ -45,6 +46,8 @@ export class GameScreen {
private readonly positionsEmpty = el('p', { class: 'panel__empty' });
private readonly positionsList = el('ul', { class: 'panel__list' });
private readonly people = new PeoplePanel();
private readonly treeButtons = new Map<string, HTMLButtonElement>();
private nodes: readonly MapSnapshotNode[] = [];
private selectedId: string | null = null;
@@ -81,33 +84,38 @@ export class GameScreen {
),
el(
'div',
{ class: 'manager' },
{ class: 'shell' },
el(
'section',
{ class: 'panel' },
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
),
el(
'section',
{ class: 'panel' },
this.locationTitle,
'div',
{ class: 'manager' },
el(
'div',
{ class: 'panel__body' },
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
'section',
{ class: 'panel' },
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
),
el(
'section',
{ class: 'panel' },
this.locationTitle,
el(
'div',
{ class: 'panel__body' },
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
),
),
),
this.people.element,
),
);
@@ -133,6 +141,7 @@ export class GameScreen {
this.positionsHeading.textContent = t('locationPositions');
this.positionsEmpty.textContent = t('positionsEmpty');
this.people.localize();
this.paintSelection();
if (this.lastGameTime !== null) {
@@ -150,6 +159,7 @@ export class GameScreen {
this.selectedId = null;
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
this.people.show(school.id);
}
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
+483
View File
@@ -0,0 +1,483 @@
import {
fetchPeople,
fetchPerson,
type PeoplePage,
type PersonCard,
type PersonListItem,
type PersonRel,
type PersonRole,
type PersonSort,
} from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
student: 'peopleRoleStudent',
staff: 'peopleRoleStaff',
parent: 'peopleRoleParent',
};
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
{ sort: 'surname', label: 'peopleColName' },
{ sort: 'year', label: 'peopleColPlace' },
{ sort: 'age', label: 'peopleColAge' },
];
/**
* Bottom row of the manager shell: filters, a page of people, and one open card.
* Fetches over HTTP; never ticks locally.
*/
export class PeoplePanel {
readonly element: HTMLElement;
private readonly title = el('h2', { class: 'panel__title' });
private readonly roleSelect = el('select', { class: 'input people__input' });
private readonly yearSelect = el('select', { class: 'input people__input' });
private readonly letterSelect = el('select', { class: 'input people__input' });
private readonly positionSelect = el('select', { class: 'input people__input' });
private readonly sexSelect = el('select', { class: 'input people__input' });
private readonly ageMinInput = el('input', { class: 'input input--count', type: 'number' });
private readonly ageMaxInput = el('input', { class: 'input input--count', type: 'number' });
private readonly roleLabel = el('span', { class: 'people__label' });
private readonly yearLabel = el('span', { class: 'people__label' });
private readonly letterLabel = el('span', { class: 'people__label' });
private readonly positionLabel = el('span', { class: 'people__label' });
private readonly sexLabel = el('span', { class: 'people__label' });
private readonly ageFromLabel = el('span', { class: 'people__label' });
private readonly ageToLabel = el('span', { class: 'people__label' });
private readonly table = el('table', { class: 'people__table' });
private readonly thead = el('thead');
private readonly tbody = el('tbody');
private readonly empty = el('p', { class: 'panel__empty' });
private readonly pagerLabel = el('span', { class: 'people__pager-label' });
private readonly prevButton = el('button', { class: 'button button--small', type: 'button' });
private readonly nextButton = el('button', { class: 'button button--small', type: 'button' });
private readonly card = el('aside', { class: 'people__card' });
private schoolId: number | null = null;
private sort: PersonSort = 'surname';
private dir: 'asc' | 'desc' = 'asc';
private page = 1;
private selectedId: string | null = null;
private token = 0;
private cardToken = 0;
constructor() {
this.ageMinInput.min = '0';
this.ageMaxInput.min = '0';
this.table.append(this.thead, this.tbody);
this.roleSelect.addEventListener('change', () => this.onFilterChange());
this.yearSelect.addEventListener('change', () => this.onFilterChange());
this.letterSelect.addEventListener('change', () => this.onFilterChange());
this.positionSelect.addEventListener('change', () => this.onFilterChange());
this.sexSelect.addEventListener('change', () => this.onFilterChange());
this.ageMinInput.addEventListener('change', () => this.onFilterChange());
this.ageMaxInput.addEventListener('change', () => this.onFilterChange());
this.prevButton.addEventListener('click', () => {
if (this.page > 1) {
this.page -= 1;
void this.reload();
}
});
this.nextButton.addEventListener('click', () => {
this.page += 1;
void this.reload();
});
this.element = el(
'section',
{ class: 'panel panel--people' },
this.title,
el(
'div',
{ class: 'panel__body people' },
el(
'div',
{ class: 'people__toolbar' },
field(this.roleLabel, this.roleSelect),
field(this.yearLabel, this.yearSelect),
field(this.letterLabel, this.letterSelect),
field(this.positionLabel, this.positionSelect),
field(this.sexLabel, this.sexSelect),
field(this.ageFromLabel, this.ageMinInput),
field(this.ageToLabel, this.ageMaxInput),
),
el(
'div',
{ class: 'people__main' },
el(
'div',
{ class: 'people__list' },
el('div', { class: 'people__table-wrap' }, this.table, this.empty),
el('div', { class: 'people__pager' }, this.prevButton, this.pagerLabel, this.nextButton),
),
this.card,
),
),
);
this.localize();
}
localize(): void {
this.title.textContent = t('peopleTitle');
this.roleLabel.textContent = t('peopleRole');
this.yearLabel.textContent = t('peopleYear');
this.letterLabel.textContent = t('peopleLetter');
this.positionLabel.textContent = t('peoplePosition');
this.sexLabel.textContent = t('peopleSex');
this.ageFromLabel.textContent = t('peopleAgeFrom');
this.ageToLabel.textContent = t('peopleAgeTo');
this.prevButton.textContent = t('peoplePrev');
this.nextButton.textContent = t('peopleNext');
this.empty.textContent = t('peopleEmpty');
fillFixedSelect(this.roleSelect, [
{ value: '', label: t('peopleRoleAll') },
{ value: 'student', label: t('peopleRoleStudent') },
{ value: 'staff', label: t('peopleRoleStaff') },
{ value: 'parent', label: t('peopleRoleParent') },
]);
fillFixedSelect(this.sexSelect, [
{ value: '', label: t('peopleSexAll') },
{ value: 'female', label: t('peopleFemale') },
{ value: 'male', label: t('peopleMale') },
]);
this.paintHeader();
if (this.schoolId !== null) {
void this.reload();
if (this.selectedId !== null) {
void this.openCard(this.selectedId);
} else {
this.paintCard(null);
}
} else {
this.paintCard(null);
}
}
show(schoolId: number): void {
this.schoolId = schoolId;
this.sort = 'surname';
this.dir = 'asc';
this.page = 1;
this.selectedId = null;
this.roleSelect.value = '';
this.yearSelect.value = '';
this.letterSelect.value = '';
this.positionSelect.value = '';
this.sexSelect.value = '';
this.ageMinInput.value = '';
this.ageMaxInput.value = '';
this.paintCard(null);
void this.reload();
}
private onFilterChange(): void {
this.page = 1;
void this.reload();
}
private onSort(sort: PersonSort): void {
if (this.sort === sort) {
this.dir = this.dir === 'asc' ? 'desc' : 'asc';
} else {
this.sort = sort;
this.dir = 'asc';
}
this.page = 1;
void this.reload();
}
private async reload(): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null) {
return;
}
const token = ++this.token;
try {
const page = await fetchPeople(
schoolId,
{
role: (this.roleSelect.value || undefined) as PersonRole | undefined,
year: parseOptionalInt(this.yearSelect.value),
letter: this.letterSelect.value || undefined,
position: this.positionSelect.value || undefined,
sex: (this.sexSelect.value || undefined) as 'male' | 'female' | undefined,
ageMin: parseOptionalInt(this.ageMinInput.value),
ageMax: parseOptionalInt(this.ageMaxInput.value),
sort: this.sort,
dir: this.dir,
page: this.page,
pageSize: 50,
},
getLocale(),
);
if (token !== this.token) {
return;
}
this.fillDynamicFilters(page);
this.paintRows(page);
} catch {
if (token !== this.token) {
return;
}
clear(this.tbody);
this.empty.hidden = false;
this.empty.textContent = t('peopleLoadFailed');
this.pagerLabel.textContent = '';
this.prevButton.disabled = true;
this.nextButton.disabled = true;
}
}
private fillDynamicFilters(page: PeoplePage): void {
fillSelect(
this.yearSelect,
page.filters.years.map((year) => ({ value: String(year), label: String(year) })),
t('peopleYearAll'),
);
fillSelect(
this.letterSelect,
page.filters.letters.map((letter) => ({ value: letter, label: letter })),
t('peopleLetterAll'),
);
fillSelect(
this.positionSelect,
page.filters.positions.map((position) => ({ value: position.defName, label: position.label })),
t('peoplePositionAll'),
);
}
private paintHeader(): void {
clear(this.thead);
const row = el('tr');
for (const column of COLUMNS) {
const button = el('button', {
class: 'people__sort',
type: 'button',
text: t(column.label),
onClick: () => this.onSort(column.sort === 'year' && this.roleSelect.value === 'staff' ? 'position' : column.sort),
});
button.setAttribute(
'aria-sort',
this.sort === column.sort || (column.sort === 'year' && this.sort === 'position')
? this.dir === 'asc'
? 'ascending'
: 'descending'
: 'none',
);
row.append(el('th', {}, button));
}
row.append(
el('th', {}, el('span', { text: t('peopleColRole') })),
el('th', {}, el('span', { text: t('peopleColSex') })),
);
this.thead.append(row);
}
private paintRows(page: PeoplePage): void {
this.paintHeader();
clear(this.tbody);
this.empty.textContent = t('peopleEmpty');
this.empty.hidden = page.people.length > 0;
this.table.hidden = page.people.length === 0;
for (const person of page.people) {
const row = el('tr', { class: 'people__row' });
row.dataset.personId = person.id;
row.classList.toggle('people__row--active', person.id === this.selectedId);
row.addEventListener('click', () => {
this.selectedId = person.id;
this.highlightSelection();
void this.openCard(person.id);
});
row.append(
el('td', { text: person.fullName }),
el('td', { text: placement(person) }),
el('td', { text: String(person.age) }),
el('td', { text: roleLabels(person.roles) }),
el('td', { text: person.female ? t('peopleFemale') : t('peopleMale') }),
);
this.tbody.append(row);
}
const pages = Math.max(1, Math.ceil(page.total / page.pageSize));
this.page = page.page;
this.pagerLabel.textContent = t('peoplePager', { page: page.page, pages, total: page.total });
this.prevButton.disabled = page.page <= 1;
this.nextButton.disabled = page.page >= pages;
}
private highlightSelection(): void {
for (const row of this.tbody.querySelectorAll('tr')) {
row.classList.toggle('people__row--active', row instanceof HTMLTableRowElement && row.dataset.personId === this.selectedId);
}
}
private async openCard(personId: string): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null) {
return;
}
const token = ++this.cardToken;
try {
const card = await fetchPerson(schoolId, personId, getLocale());
if (token !== this.cardToken) {
return;
}
this.selectedId = card.id;
this.highlightSelection();
this.paintCard(card);
} catch {
if (token !== this.cardToken) {
return;
}
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('peopleCardFailed') }));
}
}
private paintCard(card: PersonCard | null): void {
clear(this.card);
if (card === null) {
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
return;
}
this.card.append(
el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
);
appendStats(this.card, t('peopleBody'), card.body.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleSkills'), card.skills.map((row) => `${row.label}: ${row.value}`));
appendStats(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
appendStats(
this.card,
t('peopleNeeds'),
card.needs.map((row) => `${row.label}: ${Math.round(row.value * 100)}%`),
);
const family = el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: t('peopleFamily') }));
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
appendRelatives(family, t('peoplePartners'), card.family.partners, (id) => void this.openCard(id));
if (family.childElementCount > 1) {
this.card.append(family);
}
}
}
function field(label: HTMLElement, control: HTMLElement): HTMLLabelElement {
return el('label', { class: 'people__field' }, label, control);
}
function fillFixedSelect(select: HTMLSelectElement, items: readonly { value: string; label: string }[]): void {
const current = select.value;
select.replaceChildren();
for (const item of items) {
const option = el('option', { text: item.label });
option.value = item.value;
select.append(option);
}
if ([...select.options].some((option) => option.value === current)) {
select.value = current;
}
}
function fillSelect(
select: HTMLSelectElement,
items: readonly { value: string; label: string }[],
allLabel: string,
): void {
fillFixedSelect(select, [{ value: '', label: allLabel }, ...items]);
}
function parseOptionalInt(value: string): number | undefined {
if (value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
}
function placement(person: PersonListItem): string {
const parts: string[] = [];
if (person.classYear !== null && person.classLetter !== null) {
parts.push(`${person.classYear}${person.classLetter}`);
}
if (person.positionLabel !== null && person.positionLabel.length > 0) {
parts.push(person.positionLabel);
}
return parts.length > 0 ? parts.join(' · ') : '—';
}
function roleLabels(roles: readonly string[]): string {
return roles
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
.join(', ');
}
function cardMeta(card: PersonCard): string {
const bits = [
roleLabels(card.roles),
card.female ? t('peopleFemale') : t('peopleMale'),
String(card.age),
placement(card),
].filter((bit) => bit.length > 0 && bit !== '—');
return bits.join(' · ');
}
function appendStats(parent: HTMLElement, title: string, values: readonly string[]): void {
if (values.length === 0) {
return;
}
const list = el('ul', { class: 'people__stats' });
for (const value of values) {
list.append(el('li', { text: value }));
}
parent.append(el('div', { class: 'people__section' }, el('h4', { class: 'panel__section-title', text: title }), list));
}
function appendRelatives(
parent: HTMLElement,
title: string,
relatives: readonly PersonRel[],
open: (id: string) => void,
): void {
if (relatives.length === 0) {
return;
}
const list = el('ul', { class: 'people__stats' });
for (const relative of relatives) {
const item = el('li');
item.append(
el('button', {
class: 'people__link',
type: 'button',
text: relative.fullName,
onClick: () => open(relative.id),
}),
);
list.append(item);
}
parent.append(el('h4', { class: 'panel__section-title', text: title }), list);
}