Merge branch 'phase/36-person-card-tabs'
ci / server (push) Failing after 4m1s
ci / client (push) Failing after 17s

This commit is contained in:
Leonid Pershin
2026-08-20 04:35:35 +03:00
29 changed files with 1458 additions and 280 deletions
+17 -16
View File
@@ -13,29 +13,30 @@
## Задачи ## Задачи
- [ ] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас** - [x] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас**
- [ ] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора - [x] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора
уходит на «Сейчас» уходит на «Сейчас»
- [ ] Одежда: слои, цвет, полоска износа и подпись, короткая уместность к ситуации - [x] Одежда: слои, цвет, полоска износа и подпись (если сервер прислал `condition` /
- [ ] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного `conditionLabel`), короткая уместность — прочерк до фазы 35
- [x] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного
домашнего списка домашнего списка
- [ ] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`, - [x] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`,
`переодевается`). Вне школы — «дома» `переодевается`). Вне школы — «дома»
- [ ] История за сегодня под текущим делом: поиск, сортировка, пейджинг - [x] История за сегодня под текущим делом: поиск, сортировка, пейджинг
- [ ] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента - [x] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента
школы. Граница суток — шесть утра; skip ночи обнуляет вчерашнее школы. Граница суток — шесть утра; skip ночи обнуляет вчерашнее
- [ ] Запись в лог: начало/конец действия, утренняя выдача одежды, переодевание. Хранит - [ ] Запись в лог: начало/конец действия есть; утренняя выдача одежды — тип
работник, не `people.json` `apparel-replaced` подхватывается, пишет фаза 34; переодевание — после фазы 35
- [ ] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away - [x] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away
- [ ] `docs/protocol.md` — HTTP лога в том же коммите - [x] `docs/protocol.md` — HTTP лога в том же коммите
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Лог не едет в `GET .../people/{id}` — только отдельным запросом - [x] Лог не едет в `GET .../people/{id}` — только отдельным запросом
- [ ] Страница лога не пересекается; неизвестный человек — `404` - [x] Страница лога не пересекается; неизвестный человек — `404`
- [ ] После 6:00 записей вчера нет - [x] После 6:00 записей вчера нет
- [ ] Вне школы вкладка «Сейчас» показывает «дома», не пусто - [x] Вне школы вкладка «Сейчас» показывает «дома», не пусто
- [ ] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога - [x] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога
## Критерий готовности ## Критерий готовности
+38 -2
View File
@@ -259,8 +259,9 @@ no Chemistry; a related tongue from the name set may sit beside the native at a
(01) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` / (01) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` /
`висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds. `висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds.
`carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms; `carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms;
overload does not slow walking. Home and locker stay in `people.json` and are not on this card. overload does not slow walking. `hasLocker` is true when anything sits in a locker; `homeCount`
The people list does not include any of these fields. is how many items remain at home, not the list. The people list does not include any of these
fields. Today's history is a separate GET.
`hasAvatar` and `hasFullBody` tell the client whether PNG files already exist on disk for this `hasAvatar` and `hasFullBody` tell the client whether PNG files already exist on disk for this
person. They are filled on the HTTP thread after the worker returns the card; generation does person. They are filled on the HTTP thread after the worker returns the card; generation does
@@ -313,11 +314,46 @@ not happen on this request.
], ],
"carryMass": 1.2, "carryMass": 1.2,
"carryCapacity": 14, "carryCapacity": 14,
"hasLocker": false,
"homeCount": 3,
"hasAvatar": false, "hasAvatar": false,
"hasFullBody": false "hasFullBody": false
} }
``` ```
### `GET /api/schools/{id}/people/{personId}/log`
Today's history for one person: search, sort by time, page. Goes through the school's mailbox
because the log lives on the worker, not in `people.json` and not on a published snapshot.
Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. This is
not a socket feed and not a school-wide event list.
The day boundary is six in the morning — the same hour skip lands on. Crossing that hour, or
skipping an empty night, drops yesterday's rows. `?lang=ru|en` labels the captions. `q` is a
substring of the caption or type. `sort` is `time`. `dir` is `asc` or `desc` (default `desc`).
`page` starts at 1; `pageSize` defaults to 20 and is at most 100. A thousand rows do not
arrive in one response.
Row `type` values: `action-started`, `action-ended`, `apparel-replaced` (morning issue, when
phase 34 appends it), `apparel-changed` (dressing, when phase 35 appends it). `thingDef` is
the action or apparel def the caption was built from.
```json
{
"total": 2,
"page": 1,
"pageSize": 20,
"entries": [
{
"time": "2012-04-03T12:00:00Z",
"type": "action-started",
"label": "начал: Обед",
"thingDef": "EatLunch"
}
]
}
```
### `GET /api/schools/{id}/people/{personId}/portrait` ### `GET /api/schools/{id}/people/{personId}/portrait`
Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or
+30
View File
@@ -136,6 +136,21 @@ const ru = {
peopleChildren: 'Дети', peopleChildren: 'Дети',
peopleSiblings: 'Братья и сёстры', peopleSiblings: 'Братья и сёстры',
peoplePartners: 'Супруг(а)', peoplePartners: 'Супруг(а)',
peopleTabOverview: 'Обзор',
peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас',
peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик',
peopleLockerNone: 'Шкафчика нет',
peopleHomeCount: 'ещё {n} дома',
peopleLogSearch: 'Поиск',
peopleLogNewest: 'Сначала новые',
peopleLogOldest: 'Сначала старые',
peopleLogTime: 'Время',
peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.',
peopleTabOverview: 'Обзор', peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет', peopleTabPortrait: 'Портрет',
@@ -376,6 +391,21 @@ const en: Messages = {
peopleChildren: 'Children', peopleChildren: 'Children',
peopleSiblings: 'Siblings', peopleSiblings: 'Siblings',
peoplePartners: 'Spouse', peoplePartners: 'Spouse',
peopleTabOverview: 'Overview',
peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried',
peopleTabNow: 'Now',
peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker',
peopleLockerNone: 'No locker',
peopleHomeCount: '{n} more at home',
peopleLogSearch: 'Search',
peopleLogNewest: 'Newest first',
peopleLogOldest: 'Oldest first',
peopleLogTime: 'Time',
peopleLogEvent: 'Event',
peopleLogEmpty: 'Nothing logged today.',
peopleTabOverview: 'Overview', peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait', peopleTabPortrait: 'Portrait',
+46
View File
@@ -295,6 +295,8 @@ export interface PersonCard {
readonly carried: readonly CarriedItem[]; readonly carried: readonly CarriedItem[];
readonly carryMass: number; readonly carryMass: number;
readonly carryCapacity: number; readonly carryCapacity: number;
readonly hasLocker: boolean;
readonly homeCount: number;
readonly hasAvatar: boolean; readonly hasAvatar: boolean;
readonly hasFullBody: boolean; readonly hasFullBody: boolean;
} }
@@ -339,6 +341,50 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
); );
} }
export type PersonLogDir = 'asc' | 'desc';
export interface PersonLogQuery {
readonly q?: string;
readonly sort?: 'time';
readonly dir?: PersonLogDir;
readonly page?: number;
readonly pageSize?: number;
}
export interface PersonLogEntry {
readonly time: string;
readonly type: string;
readonly label: string;
readonly thingDef: string | null;
}
export interface PersonLogPage {
readonly total: number;
readonly page: number;
readonly pageSize: number;
readonly entries: readonly PersonLogEntry[];
}
export async function fetchPersonLog(
schoolId: number,
personId: string,
lang: string,
query: PersonLogQuery,
): Promise<PersonLogPage> {
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<PersonLogPage>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/log?${params.toString()}`,
);
}
export interface GameStatus { export interface GameStatus {
readonly tick: number; readonly tick: number;
readonly tickRate: number; readonly tickRate: number;
+85 -3
View File
@@ -673,11 +673,93 @@ body {
} }
.people__card-place, .people__card-place,
.people__card-activity { .people__card-activity,
.people__now-activity {
margin: 4px 0 0; margin: 4px 0 0;
color: var(--text-muted); color: var(--text-muted);
} }
.people__tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 10px 0 8px;
}
.people__tab {
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font: inherit;
padding: 3px 8px;
}
.people__tab:hover {
color: var(--text);
}
.people__tab--active {
background: var(--surface);
color: var(--text);
border-color: var(--text-muted);
}
.people__tab-panel[hidden] {
display: none;
}
.people__fit,
.people__locker,
.people__home-count {
margin: 0 0 6px;
font-size: 12px;
}
.people__garments {
display: flex;
flex-direction: column;
gap: 8px;
}
.people__wear {
display: grid;
grid-template-columns: minmax(4em, auto) 1fr;
gap: 6px 8px;
align-items: center;
margin-top: 4px;
font-size: 12px;
}
.people__wear-label {
color: var(--text-muted);
}
.people__wear .people__need-track {
margin: 0;
}
.people__log-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.people__log {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.people__log th,
.people__log td {
padding: 4px 6px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.people__section { .people__section {
margin-top: 10px; margin-top: 10px;
} }
@@ -705,8 +787,8 @@ body {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
/* The first block sits right under the name, where a divider would only add noise. */ /* The first block sits right under the tabs, where a divider would only add noise. */
.people__card > .people__card-meta + .people__section > .people__section-title { .people__tab-panel > .people__section:first-child > .people__section-title {
margin-top: 0; margin-top: 0;
padding-top: 0; padding-top: 0;
border-top: 0; border-top: 0;
+14 -41
View File
@@ -1,5 +1,4 @@
import { import {
fetchGameStatus,
fetchPerson, fetchPerson,
hireStaff, hireStaff,
type PersonCard, type PersonCard,
@@ -10,7 +9,8 @@ import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
import { formatPersonPlace, renderPersonCard, type PersonCardTab } from './personCard.ts'; import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
import { import {
actionError, actionError,
fillSelect, fillSelect,
@@ -68,15 +68,13 @@ export class ApplicantsDialog {
private sort: ApplicantSort = 'name'; private sort: ApplicantSort = 'name';
private dir: 'asc' | 'desc' = 'asc'; private dir: 'asc' | 'desc' = 'asc';
private busy = false; private busy = false;
private cardTab: PersonCardTab = 'overview';
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private painted: PersonCard | null = null; private painted: PersonCard | null = null;
private readonly cardHost = new PersonCardHost();
constructor(private readonly options: ApplicantsDialogOptions) { constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing; this.staffing = options.staffing;
this.selectedId = options.selectedId ?? null; this.selectedId = options.selectedId ?? null;
void this.refreshSwarmStatus(); this.cardHost.attach(options.schoolId);
this.error.hidden = true; this.error.hidden = true;
this.ageMinInput.min = '0'; this.ageMinInput.min = '0';
this.ageMaxInput.min = '0'; this.ageMaxInput.min = '0';
@@ -295,6 +293,7 @@ export class ApplicantsDialog {
} }
private async openCard(personId: string): Promise<void> { private async openCard(personId: string): Promise<void> {
this.cardHost.resetTabs();
try { try {
const card = await fetchPerson(this.options.schoolId, personId, getLocale()); const card = await fetchPerson(this.options.schoolId, personId, getLocale());
this.paintCard(card); this.paintCard(card);
@@ -307,47 +306,21 @@ export class ApplicantsDialog {
private paintCard(card: PersonCard | null): void { private paintCard(card: PersonCard | null): void {
this.painted = card; this.painted = card;
if (card === null) { if (card === null) {
clear(this.card); this.cardHost.paint(this.card, null, () => {}, (id) => this.placeOf(id));
this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') })); this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') }));
return; return;
} }
clear(this.card); this.cardHost.paint(
renderPersonCard(this.card, card, { this.card,
onRelative: (id) => this.openRelative(id), card,
place: this.placeOf(card.id), (id) => this.openRelative(id),
schoolId: this.options.schoolId, (id) => this.placeOf(id),
tab: this.cardTab, (overview) => this.appendHireIfNeeded(overview),
onTabChange: (tab) => { );
this.cardTab = tab;
if (tab === 'portrait') {
void this.refreshSwarmStatus();
} else if (this.painted !== null) {
this.paintCard(this.painted);
}
},
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
});
this.appendHireIfNeeded(this.card);
} }
private async refreshSwarmStatus(): Promise<void> { private appendHireIfNeeded(parent: HTMLElement): void {
try {
const status = await fetchGameStatus();
this.swarmConfigured = status.swarmUiConfigured;
this.swarmConnected = status.swarmUiConnected;
} catch {
this.swarmConfigured = false;
this.swarmConnected = null;
}
if (this.painted !== null) {
this.paintCard(this.painted);
}
}
private appendHireIfNeeded(parent: HTMLElement = this.card): void {
const personId = this.selectedId ?? this.painted?.id; const personId = this.selectedId ?? this.painted?.id;
const applicant = const applicant =
personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId); personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId);
@@ -32,7 +32,9 @@ vi.mock('../net/api.ts', async (importOriginal) => {
maxSchools: 6, maxSchools: 6,
connections: 0, connections: 0,
swarmUiConfigured: false, swarmUiConfigured: false,
swarmUiConnected: null,
}), }),
fetchPersonLog: vi.fn(),
hireStaff: vi.fn(), hireStaff: vi.fn(),
assignSubject: vi.fn(), assignSubject: vi.fn(),
unassignSubject: vi.fn(), unassignSubject: vi.fn(),
@@ -68,6 +70,8 @@ function personCard(): PersonCard {
carried: [], carried: [],
carryMass: 0, carryMass: 0,
carryCapacity: 0, carryCapacity: 0,
hasLocker: false,
homeCount: 0,
hasAvatar: false, hasAvatar: false,
hasFullBody: false, hasFullBody: false,
}; };
@@ -128,6 +132,7 @@ describe('ManagementPanel payroll cap', () => {
maxSchools: 6, maxSchools: 6,
connections: 0, connections: 0,
swarmUiConfigured: false, swarmUiConfigured: false,
swarmUiConnected: null,
}); });
vi.mocked(hireStaff).mockRejectedValue( vi.mocked(hireStaff).mockRejectedValue(
new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000), new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000),
@@ -383,6 +383,7 @@ export class ManagementPanel {
} }
const token = ++this.cardToken; const token = ++this.cardToken;
this.cardHost.resetTabs();
try { try {
const card = await fetchPerson(schoolId, personId, getLocale()); const card = await fetchPerson(schoolId, personId, getLocale());
if (token !== this.cardToken) { if (token !== this.cardToken) {
@@ -20,6 +20,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual, ...actual,
fetchPeople: vi.fn(), fetchPeople: vi.fn(),
fetchPerson: vi.fn(), fetchPerson: vi.fn(),
fetchPersonLog: vi.fn(),
fetchTimetable: vi.fn(), fetchTimetable: vi.fn(),
}; };
}); });
+1
View File
@@ -342,6 +342,7 @@ export class PeoplePanel {
// Also covers the links inside a card: opening a relative is picking somebody too. // Also covers the links inside a card: opening a relative is picking somebody too.
this.options.onSelect(); this.options.onSelect();
this.cardHost.resetTabs();
const token = ++this.cardToken; const token = ++this.cardToken;
try { try {
const card = await fetchPerson(schoolId, personId, getLocale()); const card = await fetchPerson(schoolId, personId, getLocale());
+114 -28
View File
@@ -2,10 +2,10 @@
* @vitest-environment happy-dom * @vitest-environment happy-dom
*/ */
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PersonCard } from '../net/api.ts'; import type { PersonCard, PersonLogPage } from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts'; import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { renderPersonCard, type PersonCardHandlers } from './personCard.ts'; import { renderPersonCard, type PersonCardTab, type RenderPersonCardOptions } from './personCard.ts';
const initial = getLocale(); const initial = getLocale();
@@ -27,7 +27,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
classId: 'class-1', classId: 'class-1',
position: null, position: null,
positionLabel: null, positionLabel: null,
body: [], body: [{ id: 'Height', label: 'Рост', value: '164' }],
skills: [], skills: [],
traits: [], traits: [],
needs: [], needs: [],
@@ -58,37 +58,69 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
], ],
carryMass: 1.2, carryMass: 1.2,
carryCapacity: 14, carryCapacity: 14,
hasLocker: false,
homeCount: 2,
hasAvatar: false, hasAvatar: false,
hasFullBody: false, hasFullBody: false,
...overrides, ...overrides,
}; };
} }
function handlers(overrides: Partial<PersonCardHandlers> = {}): PersonCardHandlers { function options(overrides: RenderPersonCardOptions = {}): RenderPersonCardOptions {
return { return {
onRelative: () => {},
schoolId: 1, schoolId: 1,
tab: 'overview', tab: 'overview',
onTabChange: () => {},
portraitBusy: null,
portraitError: null,
...overrides, ...overrides,
}; };
} }
function logPage(overrides: Partial<PersonLogPage> = {}): PersonLogPage {
return {
total: 3,
page: 1,
pageSize: 2,
entries: [
{ time: '2012-04-03T12:00:00Z', type: 'action-started', label: 'начал: Обед', thingDef: 'EatLunch' },
{ time: '2012-04-03T12:15:00Z', type: 'action-ended', label: 'закончил: Обед', thingDef: 'EatLunch' },
],
...overrides,
};
}
function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement {
const button = [...root.querySelectorAll('button')].find((node) => node.dataset.tab === tab);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`missing tab ${tab}`);
}
return button;
}
describe('renderPersonCard', () => { describe('renderPersonCard', () => {
it('shows worn layers with colour and carried mass on the overview tab', () => { it('switches overview, clothes, carried and now', () => {
setLocale('ru'); setLocale('ru');
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card(), handlers()); renderPersonCard(root, card(), () => {}, options());
expect(root.textContent).toContain(t('peopleApparel'));
expect(root.textContent).toContain('Верх');
expect(root.textContent).toContain('Рубашка · Белый');
expect(root.textContent).toContain(t('peopleCarry'));
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain('Учебник · Математика');
expect(root.querySelector('.people__tabs')).not.toBeNull(); expect(root.querySelector('.people__tabs')).not.toBeNull();
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(false);
expect(root.textContent).toContain('Рост');
expect(root.querySelector('.people__log')).toBeNull();
tabButton(root, 'apparel').click();
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(true);
expect(root.textContent).toContain('Рубашка · Белый');
expect(root.querySelector('.people__log')).toBeNull();
tabButton(root, 'carry').click();
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain(t('peopleLockerNone'));
expect(root.textContent).toContain(t('peopleHomeCount', { n: 2 }));
expect(root.textContent).toContain('Учебник · Математика');
tabButton(root, 'now').click();
expect(root.querySelector('[data-card-tab="now"]')?.hasAttribute('hidden')).toBe(false);
}); });
it('shows the server condition caption, not a band inferred from the number', () => { it('shows the server condition caption, not a band inferred from the number', () => {
@@ -109,7 +141,8 @@ describe('renderPersonCard', () => {
}, },
], ],
}), }),
handlers(), () => {},
options({ tab: 'apparel' }),
); );
expect(root.textContent).toContain('целая'); expect(root.textContent).toContain('целая');
@@ -120,11 +153,11 @@ describe('renderPersonCard', () => {
it('shows the avatar on overview only when hasAvatar is true', () => { it('shows the avatar on overview only when hasAvatar is true', () => {
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card({ hasAvatar: false }), handlers({ schoolId: 1 })); renderPersonCard(root, card({ hasAvatar: false }), () => {}, options({ schoolId: 1 }));
expect(root.querySelector('.people__avatar')).toBeNull(); expect(root.querySelector('.people__avatar')).toBeNull();
root.replaceChildren(); root.replaceChildren();
renderPersonCard(root, card({ hasAvatar: true }), handlers({ schoolId: 1 })); renderPersonCard(root, card({ hasAvatar: true }), () => {}, options({ schoolId: 1 }));
const image = root.querySelector('.people__avatar') as HTMLImageElement | null; const image = root.querySelector('.people__avatar') as HTMLImageElement | null;
expect(image).not.toBeNull(); expect(image).not.toBeNull();
expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar'); expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar');
@@ -133,44 +166,97 @@ describe('renderPersonCard', () => {
it('does not mount apparel on the portrait tab', () => { it('does not mount apparel on the portrait tab', () => {
setLocale('ru'); setLocale('ru');
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait' })); renderPersonCard(root, card(), () => {}, options({ tab: 'portrait' }));
const panel = root.querySelector('.people__portrait-panel'); const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull(); expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar')); expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).not.toContain(t('peopleApparel')); expect(panel?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
}); });
it('disables generate while busy', () => { it('disables generate while busy', () => {
setLocale('en'); setLocale('en');
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait', portraitBusy: 'avatar' })); renderPersonCard(root, card(), () => {}, options({ tab: 'portrait', portraitBusy: 'avatar' }));
const buttons = [...root.querySelectorAll('button')].filter((button) => button.textContent?.includes('Generating')); const buttons = [...root.querySelectorAll('button')].filter((button) => button.textContent?.includes('Generating'));
expect(buttons.length).toBeGreaterThan(0); expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((button) => button.disabled)).toBe(true); expect(buttons.every((button) => button.disabled)).toBe(true);
}); });
it('calls onTabChange when a tab is clicked', () => { it('calls onTab when a tab is clicked', () => {
const onTabChange = vi.fn(); const onTab = vi.fn();
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ onTabChange })); renderPersonCard(root, card(), () => {}, options({ onTab }));
const portraitTab = [...root.querySelectorAll('button')].find((button) => button.textContent === t('peopleTabPortrait')); const portraitTab = [...root.querySelectorAll('button')].find((button) => button.textContent === t('peopleTabPortrait'));
portraitTab?.click(); portraitTab?.click();
expect(onTabChange).toHaveBeenCalledWith('portrait'); expect(onTab).toHaveBeenCalledWith('portrait');
}); });
it('shows Swarm connection status on the portrait tab', () => { it('shows Swarm connection status on the portrait tab', () => {
setLocale('en'); setLocale('en');
const root = document.createElement('div'); const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: true })); renderPersonCard(
root,
card(),
() => {},
options({ tab: 'portrait', swarmConfigured: true, swarmConnected: true }),
);
expect(root.querySelector('.people__swarm-status--ok')?.textContent).toContain('connected'); expect(root.querySelector('.people__swarm-status--ok')?.textContent).toContain('connected');
root.replaceChildren(); root.replaceChildren();
renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: false })); renderPersonCard(
root,
card(),
() => {},
options({ tab: 'portrait', swarmConfigured: true, swarmConnected: false }),
);
expect(root.querySelector('.people__swarm-status--bad')?.textContent).toContain('unreachable'); expect(root.querySelector('.people__swarm-status--bad')?.textContent).toContain('unreachable');
}); });
it('does not mount the log table on the clothes tab', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {}, { tab: 'apparel', log: logPage() });
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('.people__log')).toBeNull();
expect(root.querySelector('.people__log-tools')).toBeNull();
});
it('pages the log on the now tab', () => {
setLocale('ru');
const onLogPage = vi.fn();
const root = document.createElement('div');
renderPersonCard(root, card(), () => {}, {
tab: 'now',
log: logPage(),
onLogPage,
});
expect(root.querySelector('.people__log')).not.toBeNull();
expect(root.textContent).toContain('начал: Обед');
expect(root.textContent).toContain(t('peoplePager', { page: 1, pages: 2, total: 3 }));
const next = [...root.querySelectorAll('.people__pager button')].at(-1);
expect(next).toBeInstanceOf(HTMLButtonElement);
(next as HTMLButtonElement).click();
expect(onLogPage).toHaveBeenCalledWith(2);
});
it('shows home on now when activity is empty and the person is away', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card({ activity: null, activityLabel: null }), () => {}, {
tab: 'now',
away: true,
place: t('presenceAway'),
});
expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome'));
expect(root.querySelector('.people__now-activity')?.textContent).not.toBe('');
});
}); });
+295 -137
View File
@@ -1,5 +1,14 @@
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts'; import type {
PersonCard,
PersonListItem,
PersonLogDir,
PersonLogPage,
PersonLogQuery,
PersonRel,
PersonRole,
} from '../net/api.ts';
import { portraitUrl } from '../net/api.ts'; import { portraitUrl } from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
@@ -9,14 +18,19 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
parent: 'peopleRoleParent', parent: 'peopleRoleParent',
}; };
export type PersonCardTab = 'overview' | 'portrait'; export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait';
export interface PersonCardHandlers { export interface RenderPersonCardOptions {
readonly onRelative: (id: string) => void;
readonly place?: string; readonly place?: string;
readonly schoolId: number | null; readonly away?: boolean;
readonly tab: PersonCardTab; readonly tab?: PersonCardTab;
readonly onTabChange: (tab: PersonCardTab) => void; readonly log?: PersonLogPage | null;
readonly logQuery?: PersonLogQuery;
readonly onTab?: (tab: PersonCardTab) => void;
readonly onLogSearch?: (query: string) => void;
readonly onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void;
readonly schoolId?: number | null;
readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void; readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void;
readonly portraitBusy?: 'avatar' | 'full' | null; readonly portraitBusy?: 'avatar' | 'full' | null;
readonly portraitError?: string | null; readonly portraitError?: string | null;
@@ -25,10 +39,6 @@ export interface PersonCardHandlers {
readonly swarmConnected?: boolean | null; readonly swarmConnected?: boolean | null;
} }
export interface PersonCardMount {
readonly overviewPane: HTMLElement;
}
export function roleLabels(roles: readonly string[]): string { export function roleLabels(roles: readonly string[]): string {
return roles return roles
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role)) .map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
@@ -48,46 +58,28 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
return parts.length > 0 ? parts.join(' · ') : '—'; return parts.length > 0 ? parts.join(' · ') : '—';
} }
export function nowActivityText(card: PersonCard, away: boolean): string {
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
return t('peopleAtHome');
}
return card.activityLabel ?? '';
}
export function renderPersonCard( export function renderPersonCard(
parent: HTMLElement, parent: HTMLElement,
card: PersonCard, card: PersonCard,
handlers: PersonCardHandlers, onRelative: (id: string) => void,
): PersonCardMount { options: RenderPersonCardOptions = {},
const tabs = el('div', { class: 'people__tabs' }); ): void {
const overviewTab = tabButton(t('peopleTabOverview'), handlers.tab === 'overview', () => handlers.onTabChange('overview')); const tab = options.tab ?? 'overview';
const portraitTab = tabButton(t('peopleTabPortrait'), handlers.tab === 'portrait', () => handlers.onTabChange('portrait'));
tabs.append(overviewTab, portraitTab);
const overviewPane = el('div', { class: 'people__tab-panel' });
const portraitPane = el('div', { class: 'people__tab-panel people__portrait-panel', hidden: handlers.tab !== 'portrait' });
if (handlers.tab !== 'overview') {
overviewPane.hidden = true;
}
mountOverview(overviewPane, card, handlers);
mountPortrait(portraitPane, card, handlers);
parent.append(tabs, overviewPane, portraitPane);
return { overviewPane };
}
function tabButton(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
return el('button', {
class: `panel__tab${active ? ' panel__tab--active' : ''}`,
type: 'button',
text: label,
onClick,
});
}
function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
const header = el('div', { class: 'people__card-header' }); const header = el('div', { class: 'people__card-header' });
if (card.hasAvatar && handlers.schoolId !== null) { if (card.hasAvatar && options.schoolId !== null && options.schoolId !== undefined) {
header.append( header.append(
el('img', { el('img', {
class: 'people__avatar', class: 'people__avatar',
alt: card.fullName, alt: card.fullName,
src: portraitUrl(handlers.schoolId, card.id, 'avatar'), src: portraitUrl(options.schoolId, card.id, 'avatar'),
}), }),
); );
} }
@@ -100,98 +92,324 @@ function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCa
header.append(titles); header.append(titles);
parent.append(header); parent.append(header);
if (handlers.place !== undefined && handlers.place.length > 0) { if (options.place !== undefined && options.place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: handlers.place })); parent.append(el('p', { class: 'people__card-place', text: options.place }));
} }
if (card.activityLabel !== null && card.activityLabel.length > 0) {
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel })); const tabs = el('div', { class: 'people__tabs' });
const panels: Record<PersonCardTab, HTMLElement> = {
overview: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'overview' } }),
apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }),
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
};
const labels: Record<PersonCardTab, MessageKey> = {
overview: 'peopleTabOverview',
apparel: 'peopleTabApparel',
carry: 'peopleTabCarry',
now: 'peopleTabNow',
portrait: 'peopleTabPortrait',
};
const buttons: Partial<Record<PersonCardTab, HTMLButtonElement>> = {};
for (const id of Object.keys(labels) as PersonCardTab[]) {
const button = el('button', {
class: 'people__tab',
type: 'button',
text: t(labels[id]),
dataset: { tab: id },
onClick: () => {
showTab(id);
options.onTab?.(id);
},
});
button.classList.toggle('people__tab--active', id === tab);
buttons[id] = button;
tabs.append(button);
} }
fillOverview(panels.overview, card, onRelative);
fillApparel(panels.apparel, card);
fillCarry(panels.carry, card);
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
fillPortrait(panels.portrait, card, options);
const showTab = (next: PersonCardTab): void => {
for (const id of Object.keys(panels) as PersonCardTab[]) {
panels[id].hidden = id !== next;
buttons[id]?.classList.toggle('people__tab--active', id === next);
}
const log = panels.now.querySelector('.people__log, .people__log-tools');
if (next !== 'now' && log !== null) {
for (const node of [...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .panel__empty')]) {
node.remove();
}
}
};
showTab(tab);
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.portrait);
}
function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
appendPairs(parent, t('peopleBody'), card.body); appendPairs(parent, t('peopleBody'), card.body);
appendPairs(parent, t('peopleSkills'), card.skills); appendPairs(parent, t('peopleSkills'), card.skills);
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label)); appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(parent, t('peopleNeeds'), card.needs); appendNeeds(parent, t('peopleNeeds'), card.needs);
appendApparel(parent, card.worn);
appendCarry(parent, card);
const family = section(t('peopleFamily')); const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, handlers.onRelative); appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
appendRelatives(family, t('peopleChildren'), card.family.children, handlers.onRelative); appendRelatives(family, t('peopleChildren'), card.family.children, onRelative);
appendRelatives(family, t('peopleSiblings'), card.family.siblings, handlers.onRelative); appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative);
appendRelatives(family, t('peoplePartners'), card.family.partners, handlers.onRelative); appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative);
if (family.childElementCount > 1) { if (family.childElementCount > 1) {
parent.append(family); parent.append(family);
} }
} }
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { function fillApparel(parent: HTMLElement, card: PersonCard): void {
parent.append(swarmStatusLine(handlers)); parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) }));
if (card.worn.length === 0) {
return;
}
const list = el('div', { class: 'people__garments' });
for (const row of card.worn) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
const garment = el(
'div',
{ class: 'people__garment' },
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
);
const share = Math.max(0, Math.min(1, row.condition));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
const caption = row.conditionLabel.length > 0 ? row.conditionLabel : '—';
garment.append(
el(
'div',
{ class: 'people__wear' },
el('span', { class: 'people__wear-label', text: caption }),
el('span', { class: 'people__need-track' }, fill),
),
);
list.append(garment);
}
parent.append(list);
}
function fillCarry(parent: HTMLElement, card: PersonCard): void {
parent.append(
el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
}),
el('p', {
class: 'people__locker',
text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'),
}),
el('p', {
class: 'people__home-count',
text: t('peopleHomeCount', { n: card.homeCount }),
}),
);
if (card.carried.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(grid);
}
function fillNow(
parent: HTMLElement,
card: PersonCard,
away: boolean,
log: PersonLogPage | null | undefined,
options: RenderPersonCardOptions,
): void {
const activity = nowActivityText(card, away);
if (activity.length > 0) {
parent.append(el('p', { class: 'people__now-activity', text: activity }));
}
if (log === null || log === undefined) {
return;
}
const search = el('input', { class: 'input people__input', type: 'search' });
search.placeholder = t('peopleLogSearch');
search.value = options.logQuery?.q ?? '';
search.addEventListener('change', () => options.onLogSearch?.(search.value));
const dir = el('select', { class: 'input people__input' });
const desc = el('option', { text: t('peopleLogNewest') });
desc.value = 'desc';
const asc = el('option', { text: t('peopleLogOldest') });
asc.value = 'asc';
dir.append(desc, asc);
dir.value = options.logQuery?.dir ?? 'desc';
dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc'));
const table = el('table', { class: 'people__log' });
const body = el('tbody');
table.append(
el(
'thead',
{},
el(
'tr',
{},
el('th', { text: t('peopleLogTime') }),
el('th', { text: t('peopleLogEvent') }),
),
),
body,
);
if (log.entries.length === 0) {
parent.append(
el('div', { class: 'people__log-tools' }, search, dir),
el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }),
);
return;
}
for (const row of log.entries) {
body.append(
el(
'tr',
{},
el('td', { text: formatGameTimeOfDay(new Date(row.time)) }),
el('td', { text: row.label }),
),
);
}
const pages = Math.max(1, Math.ceil(log.total / log.pageSize));
const prev = el('button', {
class: 'button button--small',
type: 'button',
text: t('peoplePrev'),
disabled: log.page <= 1,
onClick: () => options.onLogPage?.(log.page - 1),
});
const next = el('button', {
class: 'button button--small',
type: 'button',
text: t('peopleNext'),
disabled: log.page >= pages,
onClick: () => options.onLogPage?.(log.page + 1),
});
parent.append(
el('div', { class: 'people__log-tools' }, search, dir),
table,
el(
'div',
{ class: 'people__pager' },
prev,
el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }),
next,
),
);
}
function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void {
parent.append(swarmStatusLine(options));
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') })); parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
parent.append(portraitPreview(card, handlers, 'avatar')); parent.append(portraitPreview(card, options, 'avatar'));
parent.append( parent.append(
el('button', { el('button', {
class: 'button button--small', class: 'button button--small',
type: 'button', type: 'button',
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
onClick: () => handlers.onGeneratePortrait?.('avatar'), onClick: () => options.onGeneratePortrait?.('avatar'),
}), }),
); );
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') })); parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') }));
parent.append(portraitPreview(card, handlers, 'full')); parent.append(portraitPreview(card, options, 'full'));
parent.append( parent.append(
el('button', { el('button', {
class: 'button button--small', class: 'button button--small',
type: 'button', type: 'button',
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), text: options.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
onClick: () => handlers.onGeneratePortrait?.('full'), onClick: () => options.onGeneratePortrait?.('full'),
}), }),
); );
if (handlers.swarmConfigured === false) { if (options.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
} else if (handlers.swarmConfigured === true && handlers.swarmConnected === false) { } else if (options.swarmConfigured === true && options.swarmConnected === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') })); parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') }));
} }
if ((handlers.portraitError?.length ?? 0) > 0) { const portraitError = options.portraitError;
parent.append(el('p', { class: 'panel__error', text: handlers.portraitError! })); if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
parent.append(el('p', { class: 'panel__error', text: portraitError }));
} }
} }
function portraitGenerateEnabled(handlers: PersonCardHandlers): boolean { function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
if (handlers.swarmConfigured !== true) { if (options.swarmConfigured !== true) {
return false; return false;
} }
return handlers.swarmConnected === true; return options.swarmConnected === true;
} }
function swarmStatusLine(handlers: PersonCardHandlers): HTMLElement { function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement {
if (handlers.swarmConfigured === false) { if (options.swarmConfigured === false) {
return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') }); return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') });
} }
if (handlers.swarmConnected === true) { if (options.swarmConnected === true) {
return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') }); return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') });
} }
if (handlers.swarmConnected === false) { if (options.swarmConnected === false) {
return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') }); return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') });
} }
return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') }); return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') });
} }
function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement { function portraitPreview(card: PersonCard, options: RenderPersonCardOptions, kind: 'avatar' | 'full'): HTMLElement {
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
if (hasImage && handlers.schoolId !== null) { if (hasImage && options.schoolId !== null && options.schoolId !== undefined) {
return el('img', { return el('img', {
class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full', class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full',
alt: card.fullName, alt: card.fullName,
src: portraitUrl(handlers.schoolId, card.id, kind), src: portraitUrl(options.schoolId, card.id, kind),
}); });
} }
@@ -252,66 +470,6 @@ function appendPairs(
parent.append(section(title), grid); parent.append(section(title), grid);
} }
function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
if (rows.length === 0) {
return;
}
const block = section(t('peopleApparel'));
const garments = el('div', { class: 'people__garments' });
for (const row of rows) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
const garment = el('div', { class: 'people__garment' });
garment.append(el('span', { text: layers.length > 0 ? layers : value }));
if (layers.length > 0) {
garment.append(el('span', { text: value }));
}
const share = Math.max(0, Math.min(1, row.condition));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
garment.append(
el(
'div',
{ class: 'people__wear' },
el('span', { class: 'people__wear-label', text: row.conditionLabel }),
el('span', { class: 'people__need-track' }, fill),
),
);
garments.append(garment);
}
block.append(garments);
parent.append(block);
}
function appendCarry(parent: HTMLElement, card: PersonCard): void {
const mass = el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
});
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(section(t('peopleCarry')), mass);
if (card.carried.length > 0) {
parent.append(grid);
}
}
function formatMass(value: number): string { function formatMass(value: number): string {
return String(Math.round(value * 100) / 100); return String(Math.round(value * 100) / 100);
} }
+83 -20
View File
@@ -1,15 +1,25 @@
import { ApiError, fetchGameStatus, fetchPerson, generatePortrait, type PersonCard } from '../net/api.ts'; import {
ApiError,
fetchGameStatus,
fetchPerson,
fetchPersonLog,
generatePortrait,
type PersonCard,
type PersonLogDir,
type PersonLogPage,
type PersonLogQuery,
} from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { clear } from './dom.ts'; import { clear } from './dom.ts';
import { import {
formatPersonPlace,
nowActivityText,
renderPersonCard, renderPersonCard,
type PersonCardHandlers,
type PersonCardMount,
type PersonCardTab, type PersonCardTab,
} from './personCard.ts'; } from './personCard.ts';
/** Shared card tab state, portrait generation and Swarm availability for people-like panels. */ /** Shared card tab state, portrait generation, Swarm availability and the day log. */
export class PersonCardHost { export class PersonCardHost {
private tab: PersonCardTab = 'overview'; private tab: PersonCardTab = 'overview';
private portraitBusy: 'avatar' | 'full' | null = null; private portraitBusy: 'avatar' | 'full' | null = null;
@@ -18,11 +28,13 @@ export class PersonCardHost {
private swarmConnected: boolean | null = null; private swarmConnected: boolean | null = null;
private schoolId: number | null = null; private schoolId: number | null = null;
private painted: PersonCard | null = null; private painted: PersonCard | null = null;
private mount: PersonCardMount | null = null;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private onRelative: (id: string) => void = () => {}; private onRelative: (id: string) => void = () => {};
private placeOf: (id: string) => string = () => ''; private placeOf: (id: string) => string = () => '';
private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void; private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void;
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
private logPage: PersonLogPage | null = null;
private logToken = 0;
attach(schoolId: number): void { attach(schoolId: number): void {
this.schoolId = schoolId; this.schoolId = schoolId;
@@ -45,15 +57,20 @@ export class PersonCardHost {
detach(): void { detach(): void {
this.schoolId = null; this.schoolId = null;
this.painted = null; this.painted = null;
this.mount = null;
this.container = null; this.container = null;
this.onOverviewMounted = undefined; this.onOverviewMounted = undefined;
this.tab = 'overview'; this.resetTabs();
this.portraitBusy = null; this.portraitBusy = null;
this.portraitError = null; this.portraitError = null;
this.swarmConnected = null; this.swarmConnected = null;
} }
resetTabs(): void {
this.tab = 'overview';
this.logPage = null;
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
}
paint( paint(
container: HTMLElement, container: HTMLElement,
card: PersonCard | null, card: PersonCard | null,
@@ -67,7 +84,6 @@ export class PersonCardHost {
this.onOverviewMounted = onOverviewMounted; this.onOverviewMounted = onOverviewMounted;
clear(container); clear(container);
this.painted = card; this.painted = card;
this.mount = null;
if (card === null) { if (card === null) {
return; return;
@@ -84,30 +100,72 @@ export class PersonCardHost {
private repaint(container: HTMLElement, card: PersonCard): void { private repaint(container: HTMLElement, card: PersonCard): void {
clear(container); clear(container);
this.mount = renderPersonCard(container, card, this.handlers(this.onRelative, this.placeOf(card.id))); const place = this.placeOf(card.id);
this.onOverviewMounted?.(this.mount.overviewPane, card); renderPersonCard(container, card, this.onRelative, {
}
private handlers(onRelative: (id: string) => void, place: string): PersonCardHandlers {
return {
onRelative,
place, place,
schoolId: this.schoolId, away: place === formatPersonPlace('away'),
tab: this.tab, tab: this.tab,
onTabChange: (tab) => { log: this.tab === 'now' ? this.logPage : null,
logQuery: this.logQuery,
onTab: (tab) => {
this.tab = tab; this.tab = tab;
if (tab === 'portrait') { if (tab === 'now') {
void this.loadLog();
} else if (tab === 'portrait') {
void this.refreshSwarmStatus(); void this.refreshSwarmStatus();
} else { } else {
this.refreshPainted(); this.refreshPainted();
} }
}, },
onLogSearch: (q) => {
this.logQuery = { ...this.logQuery, q, page: 1 };
void this.loadLog();
},
onLogDir: (dir: PersonLogDir) => {
this.logQuery = { ...this.logQuery, dir, page: 1 };
void this.loadLog();
},
onLogPage: (page) => {
this.logQuery = { ...this.logQuery, page };
void this.loadLog();
},
schoolId: this.schoolId,
onGeneratePortrait: (kind) => void this.generate(kind), onGeneratePortrait: (kind) => void this.generate(kind),
portraitBusy: this.portraitBusy, portraitBusy: this.portraitBusy,
portraitError: this.portraitError, portraitError: this.portraitError,
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected, swarmConnected: this.swarmConnected,
}; });
const overview = container.querySelector('[data-card-tab="overview"]');
if (overview instanceof HTMLElement) {
this.onOverviewMounted?.(overview, card);
}
}
private async loadLog(): Promise<void> {
const schoolId = this.schoolId;
const painted = this.painted;
if (schoolId === null || painted === null || this.tab !== 'now') {
this.refreshPainted();
return;
}
const token = ++this.logToken;
try {
this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery);
if (token !== this.logToken) {
return;
}
this.refreshPainted();
} catch {
if (token !== this.logToken) {
return;
}
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
this.refreshPainted();
}
} }
private async generate(kind: 'avatar' | 'full'): Promise<void> { private async generate(kind: 'avatar' | 'full'): Promise<void> {
@@ -147,6 +205,11 @@ export class PersonCardHost {
return; return;
} }
line.textContent = placeOf(this.painted.id); const place = placeOf(this.painted.id);
line.textContent = place;
const now = this.container?.querySelector('.people__now-activity');
if (now instanceof HTMLElement) {
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
}
} }
} }
+12
View File
@@ -12,6 +12,16 @@ internal enum PersonLookupError
internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error); internal sealed record PersonCardResult(PersonCardResponse? Card, PersonLookupError Error);
internal sealed record PersonLogResult(PersonLogResponse? Page, PersonLookupError Error);
internal sealed record PersonLogResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonLogEntryResponse> Entries);
internal sealed record PersonLogEntryResponse(DateTime Time, string Type, string Label, string? ThingDef);
internal sealed record PeopleListResponse( internal sealed record PeopleListResponse(
int Total, int Total,
int Page, int Page,
@@ -66,6 +76,8 @@ internal sealed record PersonCardResponse(
IReadOnlyList<CarriedItemResponse> Carried, IReadOnlyList<CarriedItemResponse> Carried,
float CarryMass, float CarryMass,
float CarryCapacity, float CarryCapacity,
bool HasLocker = false,
int HomeCount = 0,
bool HasAvatar = false, bool HasAvatar = false,
bool HasFullBody = false); bool HasFullBody = false);
+96 -1
View File
@@ -7,7 +7,7 @@ namespace HSchool.Server.Api;
/// <summary> /// <summary>
/// The main menu talks to these: list, create, delete. People list and staffing read published /// The main menu talks to these: list, create, delete. People list and staffing read published
/// snapshots; the person card, hire and subject changes go through the school's mailbox. /// snapshots; the person card, the personal log, hire and subject changes go through the school's mailbox.
/// The timetable is a published snapshot; pin/unpin go through the mailbox. /// The timetable is a published snapshot; pin/unpin go through the mailbox.
/// </summary> /// </summary>
internal static class SchoolEndpoints internal static class SchoolEndpoints
@@ -196,6 +196,41 @@ internal static class SchoolEndpoints
}) })
.WithName("GetSchoolPerson"); .WithName("GetSchoolPerson");
schools.MapGet("/{id:int}/people/{personId}/log", async (
int id,
string personId,
string? q,
string? sort,
string? dir,
int? page,
int? pageSize,
string? lang,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!TryParsePersonLogQuery(q, sort, dir, page, pageSize, out var query, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var command = new GameCommand.GetPersonLog(id, personId, ParseLocale(lang), query, NewCompletion<PersonLogResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Page is not null => Results.Ok(outcome.Page),
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPersonLog");
schools.MapGet("/{id:int}/people/{personId}/portrait", async ( schools.MapGet("/{id:int}/people/{personId}/portrait", async (
int id, int id,
string personId, string personId,
@@ -479,6 +514,66 @@ internal static class SchoolEndpoints
return true; return true;
} }
private static bool TryParsePersonLogQuery(
string? search,
string? sort,
string? dir,
int? page,
int? pageSize,
out PersonLogQuery query,
out string error)
{
query = default;
error = string.Empty;
if (search is { Length: > 128 })
{
error = "q must be at most 128 characters.";
return false;
}
if (!string.IsNullOrWhiteSpace(sort) && !sort.Equals("time", StringComparison.OrdinalIgnoreCase))
{
error = "sort must be time.";
return false;
}
var descending = true;
if (!string.IsNullOrWhiteSpace(dir))
{
if (dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
{
descending = false;
}
else if (!dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
{
error = "dir must be asc or desc.";
return false;
}
}
var parsedPage = page ?? 1;
if (parsedPage < 1)
{
error = "page must be 1 or greater.";
return false;
}
var parsedPageSize = pageSize ?? PersonLogBrowser.DefaultPageSize;
if (parsedPageSize < 1 || parsedPageSize > PersonLogBrowser.MaxPageSize)
{
error = $"pageSize must be between 1 and {PersonLogBrowser.MaxPageSize}.";
return false;
}
query = new PersonLogQuery(
string.IsNullOrWhiteSpace(search) ? null : search.Trim(),
descending,
parsedPage,
parsedPageSize);
return true;
}
private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) => private static StaffingResponse MapStaffing(PublishedSchoolPeople published, float allocated, string locale) =>
StaffingMapper.From( StaffingMapper.From(
published.Roster, published.Roster,
+8
View File
@@ -59,6 +59,14 @@ internal abstract record GameCommand
string Locale, string Locale,
TaskCompletionSource<PersonCardResult> Result) : GameCommand; TaskCompletionSource<PersonCardResult> Result) : GameCommand;
/// <summary>Today's history for one person. Completes on that school's worker; not a snapshot.</summary>
internal sealed record GetPersonLog(
int SchoolId,
string PersonId,
string Locale,
PersonLogQuery Query,
TaskCompletionSource<PersonLogResult> Result) : GameCommand;
internal sealed record HireStaff( internal sealed record HireStaff(
int SchoolId, int SchoolId,
string PersonId, string PersonId,
@@ -187,6 +187,10 @@ internal sealed class GameLoopService(
HandleGetPerson(getPerson); HandleGetPerson(getPerson);
break; break;
case GameCommand.GetPersonLog getLog:
HandleGetPersonLog(getLog);
break;
case GameCommand.HireStaff hire: case GameCommand.HireStaff hire:
HandleStaffing( HandleStaffing(
hire.SchoolId, hire.SchoolId,
@@ -242,6 +246,15 @@ internal sealed class GameLoopService(
} }
} }
private void HandleGetPersonLog(GameCommand.GetPersonLog command)
{
if (!_workers.TryGetValue(command.SchoolId, out var worker)
|| !worker.Post(new WorkerCommand.GetPersonLog(command.PersonId, command.Locale, command.Query, command.Result)))
{
command.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
}
}
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result) private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
{ {
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+3 -1
View File
@@ -85,7 +85,9 @@ internal static class PersonCardReader
Worn(person, catalog, locale), Worn(person, catalog, locale),
Carried(person, catalog, locale), Carried(person, catalog, locale),
catalog is null ? 0f : CarryMass.Held(catalog, person.Items), catalog is null ? 0f : CarryMass.Held(catalog, person.Items),
Capacity(person, skills, catalog)); Capacity(person, skills, catalog),
person.Items.Any(item => item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)),
person.Items.Count(item => item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)));
} }
private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId) private static IReadOnlyDictionary<string, float>? LiveNeeds(World world, string personId)
@@ -0,0 +1,30 @@
using HSchool.Server.Api;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>Pages today's log on the worker thread. The list is not a published snapshot.</summary>
internal static class PersonLogReader
{
public static PersonLogResponse? Read(School school, string personId, PersonLogQuery query, string locale)
{
var roster = school.Roster;
if (roster is null)
{
return null;
}
var known = roster.People.Any(person => person.Id.Equals(personId, StringComparison.Ordinal))
|| (school.Applicants?.Applicants.Any(applicant => applicant.Person.Id.Equals(personId, StringComparison.Ordinal)) ?? false);
if (!known)
{
return null;
}
var page = PersonLogBrowser.Apply(school.DayLog, personId, query, school.Catalog, locale);
var entries = page.Entries
.Select(row => new PersonLogEntryResponse(row.Time, row.Type, row.Label, row.ThingDef))
.ToArray();
return new PersonLogResponse(page.Total, page.Page, page.PageSize, entries);
}
}
+14
View File
@@ -429,6 +429,14 @@ internal sealed class SchoolWorker
: new PersonCardResult(card, PersonLookupError.None)); : new PersonCardResult(card, PersonLookupError.None));
break; break;
case WorkerCommand.GetPersonLog getLog:
var log = PersonLogReader.Read(school, getLog.PersonId, getLog.Query, getLog.Locale);
getLog.Result.TrySetResult(
log is null
? new PersonLogResult(null, PersonLookupError.UnknownPerson)
: new PersonLogResult(log, PersonLookupError.None));
break;
case WorkerCommand.HireStaff hire: case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position)); hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position));
break; break;
@@ -485,6 +493,9 @@ internal sealed class SchoolWorker
case WorkerCommand.GetPerson getPerson: case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool));
break; break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool));
break;
case WorkerCommand.HireStaff hire: case WorkerCommand.HireStaff hire:
hire.Result.TrySetResult(Staffing.UnknownSchool()); hire.Result.TrySetResult(Staffing.UnknownSchool());
break; break;
@@ -513,6 +524,9 @@ internal sealed class SchoolWorker
case WorkerCommand.GetPerson getPerson: case WorkerCommand.GetPerson getPerson:
getPerson.Result.TrySetException(exception); getPerson.Result.TrySetException(exception);
break; break;
case WorkerCommand.GetPersonLog getLog:
getLog.Result.TrySetException(exception);
break;
case WorkerCommand.HireStaff hire: case WorkerCommand.HireStaff hire:
hire.Result.TrySetException(exception); hire.Result.TrySetException(exception);
break; break;
+7
View File
@@ -1,6 +1,7 @@
using HSchool.People; using HSchool.People;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Simulation;
namespace HSchool.Server.Game; namespace HSchool.Server.Game;
@@ -27,6 +28,12 @@ internal abstract record WorkerCommand
string Locale, string Locale,
TaskCompletionSource<PersonCardResult> Result) : WorkerCommand; TaskCompletionSource<PersonCardResult> Result) : WorkerCommand;
internal sealed record GetPersonLog(
string PersonId,
string Locale,
PersonLogQuery Query,
TaskCompletionSource<PersonLogResult> Result) : WorkerCommand;
internal sealed record HireStaff( internal sealed record HireStaff(
string PersonId, string PersonId,
string Position, string Position,
@@ -166,6 +166,8 @@
"ApparelConditionWorn": "worn", "ApparelConditionWorn": "worn",
"ApparelConditionTorn": "torn", "ApparelConditionTorn": "torn",
"ApparelConditionRags": "in rags", "ApparelConditionRags": "in rags",
"ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}", "ApparelReplaced": "got a new {0}",
"core": "Core", "core": "Core",
} }
@@ -166,6 +166,8 @@
"ApparelConditionWorn": "поношенная", "ApparelConditionWorn": "поношенная",
"ApparelConditionTorn": "порванная", "ApparelConditionTorn": "порванная",
"ApparelConditionRags": "висит лохмотьями", "ApparelConditionRags": "висит лохмотьями",
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}", "ApparelReplaced": "получил новую {0}",
"core": "Базовая игра", "core": "Базовая игра",
} }
-28
View File
@@ -1,4 +1,3 @@
using System.Globalization;
using Arch.Core; using Arch.Core;
using HSchool.Ai; using HSchool.Ai;
using HSchool.Content; using HSchool.Content;
@@ -6,33 +5,6 @@ using HSchool.People;
namespace HSchool.Simulation; namespace HSchool.Simulation;
/// <summary>Log row types phase 36 will page. Captions are ready even while the card has no tab.</summary>
public static class PersonLogTypes
{
public const string ApparelReplaced = "apparel-replaced";
}
/// <summary>
/// One thing that happened to a person today. Stored on the worker, not in <c>people.json</c>.
/// The day boundary is six in the morning — the same hour skip lands on.
/// </summary>
public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, string? ThingDef)
{
public string Caption(DefCatalog catalog, string locale)
{
ArgumentNullException.ThrowIfNull(catalog);
if (!Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal) || ThingDef is null)
{
return Type;
}
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
}
/// <summary>Condition captions live in the catalog. The client draws what it is told.</summary> /// <summary>Condition captions live in the catalog. The client draws what it is told.</summary>
public static class ApparelCondition public static class ApparelCondition
{ {
@@ -9,6 +9,10 @@
<PackageReference Include="Arch" /> <PackageReference Include="Arch" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.Simulation.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" /> <ProjectReference Include="..\HSchool.Ai\HSchool.Ai.csproj" />
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" /> <ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
+179
View File
@@ -0,0 +1,179 @@
using System.Globalization;
using Arch.Core;
using HSchool.Content;
namespace HSchool.Simulation;
/// <summary>
/// Log row types the person card pages. Captions for morning issue are ready even while phase 34
/// is the one that appends that row.
/// </summary>
public static class PersonLogTypes
{
public const string ActionStarted = "action-started";
public const string ActionEnded = "action-ended";
public const string ApparelReplaced = "apparel-replaced";
public const string ApparelChanged = "apparel-changed";
}
/// <summary>
/// One thing that happened to a person today. Stored on the school worker, not in <c>people.json</c>.
/// The day boundary is six in the morning — the same hour skip lands on.
/// </summary>
public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type, string? ThingDef)
{
public string Caption(DefCatalog catalog, string locale)
{
ArgumentNullException.ThrowIfNull(catalog);
if (ThingDef is null)
{
return Type;
}
if (Type.Equals(PersonLogTypes.ApparelReplaced, StringComparison.Ordinal))
{
var name = catalog.Things.TryGetValue(ThingDef, out var def)
? catalog.Label(locale, def)
: ThingDef;
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{
var name = catalog.Actions.TryGetValue(ThingDef, out var action)
? catalog.Label(locale, action)
: ThingDef;
var key = Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
? "ActionStarted"
: "ActionEnded";
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, key), name);
}
return Type;
}
}
public readonly record struct PersonLogQuery(string? Search, bool Descending, int Page, int PageSize);
public sealed record PersonLogPage(int Total, int Page, int PageSize, IReadOnlyList<PersonLogRow> Entries);
public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef);
/// <summary>Filters, sorts and pages today's log for one person. Does not touch a World.</summary>
public static class PersonLogBrowser
{
public const int DefaultPageSize = 20;
public const int MaxPageSize = 100;
public static PersonLogPage Apply(
IReadOnlyList<PersonLogEvent> log,
string personId,
PersonLogQuery query,
DefCatalog? catalog,
string locale)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentException.ThrowIfNullOrWhiteSpace(personId);
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, MaxPageSize);
var search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search.Trim();
var rows = new List<PersonLogRow>();
foreach (var row in log)
{
if (!row.PersonId.Equals(personId, StringComparison.Ordinal))
{
continue;
}
var label = catalog is null ? row.Type : row.Caption(catalog, locale);
if (search is not null
&& !label.Contains(search, StringComparison.OrdinalIgnoreCase)
&& !row.Type.Contains(search, StringComparison.OrdinalIgnoreCase)
&& (row.ThingDef is null || !row.ThingDef.Contains(search, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
rows.Add(new PersonLogRow(row.Time, row.Type, label, row.ThingDef));
}
rows.Sort((left, right) =>
{
var byTime = left.Time.CompareTo(right.Time);
return query.Descending ? -byTime : byTime;
});
var skip = (page - 1) * pageSize;
var slice = skip >= rows.Count ? Array.Empty<PersonLogRow>() : rows.Skip(skip).Take(pageSize).ToArray();
return new PersonLogPage(rows.Count, page, pageSize, slice);
}
}
/// <summary>Records action start/end and clears yesterday at six in the morning.</summary>
internal static class PersonDayLog
{
private static readonly QueryDescription IdentityAndActivity =
new QueryDescription().WithAll<PersonIdentity, PersonActivity>();
internal static bool CrossedDayStart(DateTime before, DateTime after)
{
if (after <= before)
{
return false;
}
var cursor = DateTime.SpecifyKind(before.Date, DateTimeKind.Utc).Add(SchoolDay.DayStart.ToTimeSpan());
if (before >= cursor)
{
cursor = cursor.AddDays(1);
}
return after >= cursor;
}
internal static void Sync(School school)
{
var world = school.World;
var seen = new HashSet<string>(StringComparer.Ordinal);
var query = IdentityAndActivity;
world.Query(in query, (ref PersonIdentity identity, ref PersonActivity activity) =>
{
seen.Add(identity.Id);
Record(school, identity.Id, activity.ActionId);
});
foreach (var id in school.LoggedActivity.Keys.ToArray())
{
if (!seen.Contains(id))
{
Record(school, id, current: null);
school.LoggedActivity.Remove(id);
}
}
}
private static void Record(School school, string personId, string? current)
{
school.LoggedActivity.TryGetValue(personId, out var last);
if (string.Equals(last, current, StringComparison.Ordinal))
{
school.LoggedActivity[personId] = current;
return;
}
if (last is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionEnded, last));
}
if (current is not null)
{
school.AppendDayLog(new PersonLogEvent(personId, school.Clock.Time, PersonLogTypes.ActionStarted, current));
}
school.LoggedActivity[personId] = current;
}
}
+24 -3
View File
@@ -116,11 +116,17 @@ public sealed class School : IDisposable
public int PendingDecisionCount => DecisionQueue.Count; public int PendingDecisionCount => DecisionQueue.Count;
/// <summary> /// <summary>
/// Today's history for phase 36. Cleared at six in the morning. Not written to disk. /// Today's history for the person card. Cleared at six in the morning. Not written to disk.
/// </summary> /// </summary>
public IReadOnlyList<PersonLogEvent> DayLog => _dayLog; public IReadOnlyList<PersonLogEvent> DayLog => _dayLog;
internal void ResetDayLog() => _dayLog.Clear(); internal Dictionary<string, string?> LoggedActivity { get; } = new(StringComparer.Ordinal);
internal void ResetDayLog()
{
_dayLog.Clear();
LoggedActivity.Clear();
}
internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row);
@@ -150,6 +156,7 @@ public sealed class School : IDisposable
LastDecisionSlot = null; LastDecisionSlot = null;
Plans.Clear(); Plans.Clear();
DecisionQueue.Clear(); DecisionQueue.Clear();
LoggedActivity.Clear();
SyncWeather(force: true); SyncWeather(force: true);
} }
@@ -158,7 +165,13 @@ public sealed class School : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(personId); ArgumentException.ThrowIfNullOrWhiteSpace(personId);
ArgumentException.ThrowIfNullOrWhiteSpace(actionId); ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
return ActivitySystem.TryStart(this, personId, actionId); var started = ActivitySystem.TryStart(this, personId, actionId);
if (started)
{
PersonDayLog.Sync(this);
}
return started;
} }
public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400) public void ConfigurePresence(int weekDays = 5, int maxDecisionsPerTick = 64, int maxSkipDays = 400)
@@ -217,6 +230,7 @@ public sealed class School : IDisposable
var before = Clock.Time; var before = Clock.Time;
Clock.JumpTo(next.Value); Clock.JumpTo(next.Value);
ResetDayLog();
var peopleChanged = TryYearlyIntake(before, next.Value); var peopleChanged = TryYearlyIntake(before, next.Value);
peopleChanged |= TryApplicantRefresh(); peopleChanged |= TryApplicantRefresh();
PlanDay = null; PlanDay = null;
@@ -273,6 +287,11 @@ public sealed class School : IDisposable
var peopleChanged = false; var peopleChanged = false;
if (gameMinutes > 0) if (gameMinutes > 0)
{ {
if (PersonDayLog.CrossedDayStart(before, Clock.Time))
{
ResetDayLog();
}
peopleChanged = TryYearlyIntake(before, Clock.Time); peopleChanged = TryYearlyIntake(before, Clock.Time);
peopleChanged |= TryApplicantRefresh(); peopleChanged |= TryApplicantRefresh();
if (peopleChanged) if (peopleChanged)
@@ -287,6 +306,8 @@ public sealed class School : IDisposable
PresenceSystem.Enqueue(this, id); PresenceSystem.Enqueue(this, id);
} }
PersonDayLog.Sync(this);
if (Catalog is not null) if (Catalog is not null)
{ {
var below = PresenceSystem.BelowThreshold(this); var below = PresenceSystem.BelowThreshold(this);
@@ -0,0 +1,106 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class PersonLogApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Card_DoesNotIncludeTheLog()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Карточка без лога", Start, seed: 36);
var page = await GetPeopleAsync(client, school.Id);
var id = page.People[0].Id;
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(id)}",
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.False(json.RootElement.TryGetProperty("entries", out _));
Assert.False(json.RootElement.TryGetProperty("log", out _));
var log = await client.GetFromJsonAsync<PersonLogResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(id)}/log?pageSize=20",
TestContext.Current.CancellationToken);
Assert.NotNull(log);
Assert.Equal(1, log.Page);
Assert.Equal(20, log.PageSize);
Assert.True(log.Total >= 0);
Assert.NotNull(log.Entries);
}
[Fact]
public async Task Log_UnknownPerson_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Лог 404", Start, seed: 36);
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/nobody/log",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("unknown-person", await ProblemCodeAsync(response));
}
[Fact]
public async Task Log_PagesDoNotOverlap()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Лог страницы", Start, seed: 36);
var page = await GetPeopleAsync(client, school.Id);
var id = page.People[0].Id;
var first = await GetLogAsync(client, school.Id, id, "page=1&pageSize=10&dir=asc");
var second = await GetLogAsync(client, school.Id, id, "page=2&pageSize=10&dir=asc");
Assert.Equal(first.Total, second.Total);
Assert.Empty(first.Entries.Select(row => (row.Time, row.Type, row.ThingDef))
.Intersect(second.Entries.Select(row => (row.Time, row.Type, row.ThingDef))));
}
private static async Task<PeopleList> GetPeopleAsync(HttpClient client, int schoolId)
{
var page = await client.GetFromJsonAsync<PeopleList>(
$"/api/schools/{schoolId}/people?pageSize=10",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
Assert.NotEmpty(page.People);
return page;
}
private static async Task<PersonLogResponse> GetLogAsync(HttpClient client, int schoolId, string personId, string query)
{
var page = await client.GetFromJsonAsync<PersonLogResponse>(
$"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}/log?{query}",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
return page;
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code);
private sealed record PeopleList(IReadOnlyList<PersonRow> People);
private sealed record PersonRow(string Id);
private sealed record PersonLogResponse(
int Total,
int Page,
int PageSize,
IReadOnlyList<PersonLogEntry> Entries);
private sealed record PersonLogEntry(DateTime Time, string Type, string Label, string? ThingDef);
}
@@ -0,0 +1,228 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation.Tests;
public class PersonDayLogTests
{
private static readonly DateTime TuesdayFive = new(2012, 4, 3, 5, 0, 0, DateTimeKind.Utc);
private static readonly DateTime TuesdayNoon = new(2012, 4, 3, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime TuesdayEvening = new(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc);
[Fact]
public void TryStartAndFinish_WriteStartAndEndRows()
{
using var school = OpenCafeteria(TuesdayNoon);
Spawn(school, "a", hunger: 0.3f);
Assert.True(school.TryStartAction("a", "EatLunch"));
Assert.Contains(
school.DayLog,
row => row.PersonId == "a"
&& row.Type == PersonLogTypes.ActionStarted
&& row.ThingDef == "EatLunch");
TickMinutes(school, 16);
Assert.Contains(
school.DayLog,
row => row.PersonId == "a"
&& row.Type == PersonLogTypes.ActionEnded
&& row.ThingDef == "EatLunch");
Assert.Equal(
"начал: Обед",
school.DayLog.First(row => row.Type == PersonLogTypes.ActionStarted).Caption(school.Catalog!, "ru"));
}
[Fact]
public void AfterSix_YesterdaysRowsAreGone()
{
using var school = OpenCafeteria(TuesdayFive);
Spawn(school, "a", hunger: 0.3f);
Assert.True(school.TryStartAction("a", "EatLunch"));
Assert.NotEmpty(school.DayLog);
TickMinutes(school, 61);
Assert.True(school.Clock.Time >= new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc));
Assert.Empty(school.DayLog);
}
[Fact]
public void SkipEmpty_ClearsYesterdaysRows()
{
using var school = OpenEmpty(TuesdayEvening);
school.AppendDayLog(new PersonLogEvent("f0.c0", TuesdayEvening, PersonLogTypes.ActionStarted, "EatLunch"));
Assert.NotEmpty(school.DayLog);
var result = school.TrySkipEmpty();
Assert.True(result.Succeeded);
Assert.Empty(school.DayLog);
}
[Fact]
public void Pages_DoNotOverlap()
{
var time = TuesdayNoon;
var log = new List<PersonLogEvent>();
for (var i = 0; i < 25; i++)
{
log.Add(new PersonLogEvent("p", time.AddMinutes(i), PersonLogTypes.ActionStarted, "EatLunch"));
}
var first = PersonLogBrowser.Apply(log, "p", new PersonLogQuery(null, Descending: false, Page: 1, PageSize: 10), catalog: null, "ru");
var second = PersonLogBrowser.Apply(log, "p", new PersonLogQuery(null, Descending: false, Page: 2, PageSize: 10), catalog: null, "ru");
Assert.Equal(25, first.Total);
Assert.Equal(10, first.Entries.Count);
Assert.Equal(10, second.Entries.Count);
Assert.Empty(first.Entries.Select(row => row.Time).Intersect(second.Entries.Select(row => row.Time)));
Assert.Equal(time, first.Entries[0].Time);
Assert.Equal(time.AddMinutes(10), second.Entries[0].Time);
}
[Fact]
public void Search_FiltersByCaption()
{
var log = new[]
{
new PersonLogEvent("p", TuesdayNoon, PersonLogTypes.ActionStarted, "EatLunch"),
new PersonLogEvent("p", TuesdayNoon.AddMinutes(1), PersonLogTypes.ActionStarted, "UseToilet"),
};
var page = PersonLogBrowser.Apply(log, "p", new PersonLogQuery("toilet", Descending: false, Page: 1, PageSize: 20), catalog: null, "en");
Assert.Equal(1, page.Total);
Assert.Equal("action-started", page.Entries[0].Type);
Assert.Equal("UseToilet", page.Entries[0].ThingDef);
}
private static void TickMinutes(School school, int minutes)
{
for (var i = 0; i < minutes; i++)
{
school.Tick(0.2d, 5d);
}
}
private static School OpenCafeteria(DateTime start)
{
var catalog = new CatalogLoader().Load(
[CatalogLoader.CorePackId],
[
new ContentDocument("core", "defs/actions/sit.jsonc", """{ "defName": "Sit", "abstract": true }"""),
new ContentDocument(
"core",
"defs/actions/eat.jsonc",
"""
{
"defName": "EatLunch",
"room": "Cafeteria",
"thing": "Chair",
"minutes": 15,
"need": "Hunger",
"needGain": 0.5,
"roles": ["student"]
}
"""),
new ContentDocument(
"core",
"defs/needs/hunger.jsonc",
"""{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 }"""),
new ContentDocument("core", "defs/things/chair.jsonc", """{ "defName": "Chair", "actions": ["Sit"] }"""),
new ContentDocument("core", "defs/territories/yard.jsonc", """{ "defName": "Yard", "travelMinutes": 1 }"""),
new ContentDocument("core", "defs/buildings/main.jsonc", """{ "defName": "Main" }"""),
new ContentDocument("core", "defs/floors/floor.jsonc", """{ "defName": "Floor" }"""),
new ContentDocument(
"core",
"defs/rooms/cafeteria.jsonc",
"""
{
"defName": "Cafeteria",
"slots": [{ "key": "seats", "thing": "Chair" }],
"travelMinutes": 1
}
"""),
new ContentDocument("core", "localizations/ru.jsonc", """{ "EatLunch": "Обед", "ActionStarted": "начал: {0}", "ActionEnded": "закончил: {0}" }"""),
]);
var map = new MapLayout
{
Territory = new TerritoryNode { Id = "yard", Def = "Yard" },
Buildings = [new BuildingNode { Id = "main", Def = "Main" }],
Floors = [new FloorNode { Id = "floor-1", Def = "Floor", Building = "main" }],
Rooms =
[
new RoomNode
{
Id = "cafe",
Def = "Cafeteria",
Building = "main",
Floor = "floor-1",
Slots = [new SlotFill { Key = "seats", Thing = "Chair", Count = 8 }],
},
],
Links = [new MapLink { A = "yard", B = "cafe" }],
};
MapValidator.Validate(map, catalog);
return School.Create(1, "Столовая", start, catalog, map);
}
private static void Spawn(School school, string id, float hunger)
{
school.World.Create(
new PersonIdentity(id, "f", false, TuesdayNoon, DummyName()),
new PersonRoles(true, false, false, null, null, null),
new PersonNeeds(new Dictionary<string, float>(StringComparer.Ordinal) { ["Hunger"] = hunger }),
new Presence("cafe", 0f, "cafe", false, []),
PersonActivity.Idle);
}
private static School OpenEmpty(DateTime start)
{
var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
var school = School.Create(1, "Пустая", start, catalog, map);
school.InstallPeople(roster, seed: 1, "Russia", pool);
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
return school;
}
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
private static PersonName DummyName()
{
var cases = new CaseTable
{
Nom = "А",
Gen = "А",
Dat = "А",
Acc = "А",
Ins = "А",
Pre = "А",
};
return new PersonName("А", "А", "А", cases, cases, cases);
}
}