diff --git a/docs/phases/36-person-card-tabs.md b/docs/phases/36-person-card-tabs.md index f633755..3b9b487 100644 --- a/docs/phases/36-person-card-tabs.md +++ b/docs/phases/36-person-card-tabs.md @@ -13,29 +13,30 @@ ## Задачи -- [ ] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас** -- [ ] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора +- [x] Окно карточки: вкладки **Обзор / Одежда / Ноша / Сейчас** +- [x] Обзор — прежнее: тело, навыки, черты, нужды, семья, расписание; текущее дело с обзора уходит на «Сейчас» -- [ ] Одежда: слои, цвет, полоска износа и подпись, короткая уместность к ситуации -- [ ] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного +- [x] Одежда: слои, цвет, полоска износа и подпись (если сервер прислал `condition` / + `conditionLabel`), короткая уместность — прочерк до фазы 35 +- [x] Ноша: сумка, несёшь/можешь, шкафчик или его отсутствие, кратко «ещё N дома» без полного домашнего списка -- [ ] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`, +- [x] Сейчас: на территории школы — подпись действия (`ест`, `идёт`, `учится (математика)`, `переодевается`). Вне школы — «дома» -- [ ] История за сегодня под текущим делом: поиск, сортировка, пейджинг -- [ ] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента +- [x] История за сегодня под текущим делом: поиск, сортировка, пейджинг +- [x] `GET /api/schools/{id}/people/{personId}/log` — страница и фильтр; не сокет, не лента школы. Граница суток — шесть утра; skip ночи обнуляет вчерашнее -- [ ] Запись в лог: начало/конец действия, утренняя выдача одежды, переодевание. Хранит - работник, не `people.json` -- [ ] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away -- [ ] `docs/protocol.md` — HTTP лога в том же коммите +- [ ] Запись в лог: начало/конец действия есть; утренняя выдача одежды — тип + `apparel-replaced` подхватывается, пишет фаза 34; переодевание — после фазы 35 +- [x] Vitest: переключение вкладок, пейджер лога, «дома» когда activity пустая и человек away +- [x] `docs/protocol.md` — HTTP лога в том же коммите ## Тесты, без которых фаза не закрыта -- [ ] Лог не едет в `GET .../people/{id}` — только отдельным запросом -- [ ] Страница лога не пересекается; неизвестный человек — `404` -- [ ] После 6:00 записей вчера нет -- [ ] Вне школы вкладка «Сейчас» показывает «дома», не пусто -- [ ] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога +- [x] Лог не едет в `GET .../people/{id}` — только отдельным запросом +- [x] Страница лога не пересекается; неизвестный человек — `404` +- [x] После 6:00 записей вчера нет +- [x] Вне школы вкладка «Сейчас» показывает «дома», не пусто +- [x] Клиентский тест: вкладка «Одежда» не монтирует таблицу лога ## Критерий готовности diff --git a/docs/protocol.md b/docs/protocol.md index 27beb78..74a99b9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -259,8 +259,9 @@ no Chemistry; a related tongue from the name set may sit beside the native at a (0–1) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` / `висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds. `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. -The people list does not include any of these fields. +overload does not slow walking. `hasLocker` is true when anything sits in a locker; `homeCount` +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 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, "carryCapacity": 14, + "hasLocker": false, + "homeCount": 3, "hasAvatar": 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` Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 10508dc..c77b51f 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -136,6 +136,21 @@ const ru = { peopleChildren: 'Дети', peopleSiblings: 'Братья и сёстры', peoplePartners: 'Супруг(а)', + peopleTabOverview: 'Обзор', + peopleTabApparel: 'Одежда', + peopleTabCarry: 'Ноша', + peopleTabNow: 'Сейчас', + peopleAtHome: 'дома', + peopleApparelFit: 'Уместность: {value}', + peopleLockerYes: 'Есть шкафчик', + peopleLockerNone: 'Шкафчика нет', + peopleHomeCount: 'ещё {n} дома', + peopleLogSearch: 'Поиск', + peopleLogNewest: 'Сначала новые', + peopleLogOldest: 'Сначала старые', + peopleLogTime: 'Время', + peopleLogEvent: 'Событие', + peopleLogEmpty: 'Сегодня записей нет.', peopleTabOverview: 'Обзор', peopleTabPortrait: 'Портрет', @@ -376,6 +391,21 @@ const en: Messages = { peopleChildren: 'Children', peopleSiblings: 'Siblings', 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', peopleTabPortrait: 'Portrait', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index b13ca52..26bf448 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -295,6 +295,8 @@ export interface PersonCard { readonly carried: readonly CarriedItem[]; readonly carryMass: number; readonly carryCapacity: number; + readonly hasLocker: boolean; + readonly homeCount: number; readonly hasAvatar: 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 { + 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( + `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/log?${params.toString()}`, + ); +} + export interface GameStatus { readonly tick: number; readonly tickRate: number; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index c442972..5259110 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -673,11 +673,93 @@ body { } .people__card-place, -.people__card-activity { +.people__card-activity, +.people__now-activity { margin: 4px 0 0; 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 { margin-top: 10px; } @@ -705,8 +787,8 @@ body { border-top: 1px solid var(--border); } -/* The first block sits right under the name, where a divider would only add noise. */ -.people__card > .people__card-meta + .people__section > .people__section-title { +/* The first block sits right under the tabs, where a divider would only add noise. */ +.people__tab-panel > .people__section:first-child > .people__section-title { margin-top: 0; padding-top: 0; border-top: 0; diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts index c79cfb3..ee9d52f 100644 --- a/src/HSchool.Client/src/ui/applicantsDialog.ts +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -1,5 +1,4 @@ import { - fetchGameStatus, fetchPerson, hireStaff, type PersonCard, @@ -10,7 +9,8 @@ import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.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 { actionError, fillSelect, @@ -68,15 +68,13 @@ export class ApplicantsDialog { private sort: ApplicantSort = 'name'; private dir: 'asc' | 'desc' = 'asc'; private busy = false; - private cardTab: PersonCardTab = 'overview'; - private swarmConfigured = false; - private swarmConnected: boolean | null = null; private painted: PersonCard | null = null; + private readonly cardHost = new PersonCardHost(); constructor(private readonly options: ApplicantsDialogOptions) { this.staffing = options.staffing; this.selectedId = options.selectedId ?? null; - void this.refreshSwarmStatus(); + this.cardHost.attach(options.schoolId); this.error.hidden = true; this.ageMinInput.min = '0'; this.ageMaxInput.min = '0'; @@ -295,6 +293,7 @@ export class ApplicantsDialog { } private async openCard(personId: string): Promise { + this.cardHost.resetTabs(); try { const card = await fetchPerson(this.options.schoolId, personId, getLocale()); this.paintCard(card); @@ -307,47 +306,21 @@ export class ApplicantsDialog { private paintCard(card: PersonCard | null): void { this.painted = card; 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') })); return; } - clear(this.card); - renderPersonCard(this.card, card, { - onRelative: (id) => this.openRelative(id), - place: this.placeOf(card.id), - schoolId: this.options.schoolId, - tab: this.cardTab, - 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); + this.cardHost.paint( + this.card, + card, + (id) => this.openRelative(id), + (id) => this.placeOf(id), + (overview) => this.appendHireIfNeeded(overview), + ); } - private async refreshSwarmStatus(): Promise { - 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 { + private appendHireIfNeeded(parent: HTMLElement): void { const personId = this.selectedId ?? this.painted?.id; const applicant = personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId); diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index aa529ed..4cf69ad 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -32,7 +32,9 @@ vi.mock('../net/api.ts', async (importOriginal) => { maxSchools: 6, connections: 0, swarmUiConfigured: false, + swarmUiConnected: null, }), + fetchPersonLog: vi.fn(), hireStaff: vi.fn(), assignSubject: vi.fn(), unassignSubject: vi.fn(), @@ -68,6 +70,8 @@ function personCard(): PersonCard { carried: [], carryMass: 0, carryCapacity: 0, + hasLocker: false, + homeCount: 0, hasAvatar: false, hasFullBody: false, }; @@ -128,6 +132,7 @@ describe('ManagementPanel payroll cap', () => { maxSchools: 6, connections: 0, swarmUiConfigured: false, + swarmUiConnected: null, }); vi.mocked(hireStaff).mockRejectedValue( new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000), diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 3ebbfaa..b06a478 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -383,6 +383,7 @@ export class ManagementPanel { } const token = ++this.cardToken; + this.cardHost.resetTabs(); try { const card = await fetchPerson(schoolId, personId, getLocale()); if (token !== this.cardToken) { diff --git a/src/HSchool.Client/src/ui/peoplePanel.test.ts b/src/HSchool.Client/src/ui/peoplePanel.test.ts index d6f2185..6e81d58 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.test.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.test.ts @@ -20,6 +20,7 @@ vi.mock('../net/api.ts', async (importOriginal) => { ...actual, fetchPeople: vi.fn(), fetchPerson: vi.fn(), + fetchPersonLog: vi.fn(), fetchTimetable: vi.fn(), }; }); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index 0e041df..8111e30 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -342,6 +342,7 @@ export class PeoplePanel { // Also covers the links inside a card: opening a relative is picking somebody too. this.options.onSelect(); + this.cardHost.resetTabs(); const token = ++this.cardToken; try { const card = await fetchPerson(schoolId, personId, getLocale()); diff --git a/src/HSchool.Client/src/ui/personCard.test.ts b/src/HSchool.Client/src/ui/personCard.test.ts index b910da1..1e2bf6f 100644 --- a/src/HSchool.Client/src/ui/personCard.test.ts +++ b/src/HSchool.Client/src/ui/personCard.test.ts @@ -2,10 +2,10 @@ * @vitest-environment happy-dom */ 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 { t } from '../i18n/strings.ts'; -import { renderPersonCard, type PersonCardHandlers } from './personCard.ts'; +import { renderPersonCard, type PersonCardTab, type RenderPersonCardOptions } from './personCard.ts'; const initial = getLocale(); @@ -27,7 +27,7 @@ function card(overrides: Partial = {}): PersonCard { classId: 'class-1', position: null, positionLabel: null, - body: [], + body: [{ id: 'Height', label: 'Рост', value: '164' }], skills: [], traits: [], needs: [], @@ -58,37 +58,69 @@ function card(overrides: Partial = {}): PersonCard { ], carryMass: 1.2, carryCapacity: 14, + hasLocker: false, + homeCount: 2, hasAvatar: false, hasFullBody: false, ...overrides, }; } -function handlers(overrides: Partial = {}): PersonCardHandlers { +function options(overrides: RenderPersonCardOptions = {}): RenderPersonCardOptions { return { - onRelative: () => {}, schoolId: 1, tab: 'overview', - onTabChange: () => {}, - portraitBusy: null, - portraitError: null, ...overrides, }; } +function logPage(overrides: Partial = {}): 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', () => { - it('shows worn layers with colour and carried mass on the overview tab', () => { + it('switches overview, clothes, carried and now', () => { setLocale('ru'); 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('[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', () => { @@ -109,7 +141,8 @@ describe('renderPersonCard', () => { }, ], }), - handlers(), + () => {}, + options({ tab: 'apparel' }), ); expect(root.textContent).toContain('целая'); @@ -120,11 +153,11 @@ describe('renderPersonCard', () => { it('shows the avatar on overview only when hasAvatar is true', () => { 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(); 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; expect(image).not.toBeNull(); expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar'); @@ -133,44 +166,97 @@ describe('renderPersonCard', () => { it('does not mount apparel on the portrait tab', () => { setLocale('ru'); const root = document.createElement('div'); - renderPersonCard(root, card(), handlers({ tab: 'portrait' })); + renderPersonCard(root, card(), () => {}, options({ tab: 'portrait' })); const panel = root.querySelector('.people__portrait-panel'); expect(panel).not.toBeNull(); 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', () => { setLocale('en'); 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')); expect(buttons.length).toBeGreaterThan(0); expect(buttons.every((button) => button.disabled)).toBe(true); }); - it('calls onTabChange when a tab is clicked', () => { - const onTabChange = vi.fn(); + it('calls onTab when a tab is clicked', () => { + const onTab = vi.fn(); 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')); portraitTab?.click(); - expect(onTabChange).toHaveBeenCalledWith('portrait'); + expect(onTab).toHaveBeenCalledWith('portrait'); }); it('shows Swarm connection status on the portrait tab', () => { setLocale('en'); 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'); 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'); }); + + 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(''); + }); }); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index ec39ea3..afbb049 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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 { formatGameTimeOfDay } from '../format/gameTime.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { el } from './dom.ts'; @@ -9,14 +18,19 @@ const ROLE_KEYS: Record = { parent: 'peopleRoleParent', }; -export type PersonCardTab = 'overview' | 'portrait'; +export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait'; -export interface PersonCardHandlers { - readonly onRelative: (id: string) => void; +export interface RenderPersonCardOptions { readonly place?: string; - readonly schoolId: number | null; - readonly tab: PersonCardTab; - readonly onTabChange: (tab: PersonCardTab) => void; + readonly away?: boolean; + readonly tab?: PersonCardTab; + 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 portraitBusy?: 'avatar' | 'full' | null; readonly portraitError?: string | null; @@ -25,10 +39,6 @@ export interface PersonCardHandlers { readonly swarmConnected?: boolean | null; } -export interface PersonCardMount { - readonly overviewPane: HTMLElement; -} - export function roleLabels(roles: readonly string[]): string { return roles .map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role)) @@ -48,46 +58,28 @@ export function placement(person: Pick 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( parent: HTMLElement, card: PersonCard, - handlers: PersonCardHandlers, -): PersonCardMount { - const tabs = el('div', { class: 'people__tabs' }); - const overviewTab = tabButton(t('peopleTabOverview'), handlers.tab === 'overview', () => handlers.onTabChange('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 { + onRelative: (id: string) => void, + options: RenderPersonCardOptions = {}, +): void { + const tab = options.tab ?? 'overview'; 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( el('img', { class: 'people__avatar', 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); parent.append(header); - if (handlers.place !== undefined && handlers.place.length > 0) { - parent.append(el('p', { class: 'people__card-place', text: handlers.place })); + if (options.place !== undefined && options.place.length > 0) { + 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 = { + 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 = { + overview: 'peopleTabOverview', + apparel: 'peopleTabApparel', + carry: 'peopleTabCarry', + now: 'peopleTabNow', + portrait: 'peopleTabPortrait', + }; + + const buttons: Partial> = {}; + 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('peopleSkills'), card.skills); appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label)); appendNeeds(parent, t('peopleNeeds'), card.needs); - appendApparel(parent, card.worn); - appendCarry(parent, card); const family = section(t('peopleFamily')); - appendRelatives(family, t('peopleParents'), card.family.parents, handlers.onRelative); - appendRelatives(family, t('peopleChildren'), card.family.children, handlers.onRelative); - appendRelatives(family, t('peopleSiblings'), card.family.siblings, handlers.onRelative); - appendRelatives(family, t('peoplePartners'), card.family.partners, handlers.onRelative); + appendRelatives(family, t('peopleParents'), card.family.parents, onRelative); + appendRelatives(family, t('peopleChildren'), card.family.children, onRelative); + appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative); + appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative); if (family.childElementCount > 1) { parent.append(family); } } -function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { - parent.append(swarmStatusLine(handlers)); +function fillApparel(parent: HTMLElement, card: PersonCard): void { + 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(portraitPreview(card, handlers, 'avatar')); + parent.append(portraitPreview(card, options, 'avatar')); parent.append( el('button', { class: 'button button--small', type: 'button', - text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), - disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), - onClick: () => handlers.onGeneratePortrait?.('avatar'), + text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), + disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), + onClick: () => options.onGeneratePortrait?.('avatar'), }), ); 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( el('button', { class: 'button button--small', type: 'button', - text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), - disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers), - onClick: () => handlers.onGeneratePortrait?.('full'), + text: options.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), + disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options), + onClick: () => options.onGeneratePortrait?.('full'), }), ); - if (handlers.swarmConfigured === false) { + if (options.swarmConfigured === false) { 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') })); } - if ((handlers.portraitError?.length ?? 0) > 0) { - parent.append(el('p', { class: 'panel__error', text: handlers.portraitError! })); + const portraitError = options.portraitError; + if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) { + parent.append(el('p', { class: 'panel__error', text: portraitError })); } } -function portraitGenerateEnabled(handlers: PersonCardHandlers): boolean { - if (handlers.swarmConfigured !== true) { +function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean { + if (options.swarmConfigured !== true) { return false; } - return handlers.swarmConnected === true; + return options.swarmConnected === true; } -function swarmStatusLine(handlers: PersonCardHandlers): HTMLElement { - if (handlers.swarmConfigured === false) { +function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement { + if (options.swarmConfigured === false) { 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') }); } - 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--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; - if (hasImage && handlers.schoolId !== null) { + if (hasImage && options.schoolId !== null && options.schoolId !== undefined) { return el('img', { class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full', 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); } -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 { return String(Math.round(value * 100) / 100); } diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index 9bf0e0d..16eea3a 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -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 { t } from '../i18n/strings.ts'; import { clear } from './dom.ts'; import { + formatPersonPlace, + nowActivityText, renderPersonCard, - type PersonCardHandlers, - type PersonCardMount, type PersonCardTab, } 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 { private tab: PersonCardTab = 'overview'; private portraitBusy: 'avatar' | 'full' | null = null; @@ -18,11 +28,13 @@ export class PersonCardHost { private swarmConnected: boolean | null = null; private schoolId: number | null = null; private painted: PersonCard | null = null; - private mount: PersonCardMount | null = null; private container: HTMLElement | null = null; private onRelative: (id: string) => void = () => {}; private placeOf: (id: string) => string = () => ''; 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 { this.schoolId = schoolId; @@ -45,15 +57,20 @@ export class PersonCardHost { detach(): void { this.schoolId = null; this.painted = null; - this.mount = null; this.container = null; this.onOverviewMounted = undefined; - this.tab = 'overview'; + this.resetTabs(); this.portraitBusy = null; this.portraitError = null; this.swarmConnected = null; } + resetTabs(): void { + this.tab = 'overview'; + this.logPage = null; + this.logQuery = { page: 1, pageSize: 20, dir: 'desc' }; + } + paint( container: HTMLElement, card: PersonCard | null, @@ -67,7 +84,6 @@ export class PersonCardHost { this.onOverviewMounted = onOverviewMounted; clear(container); this.painted = card; - this.mount = null; if (card === null) { return; @@ -84,30 +100,72 @@ export class PersonCardHost { private repaint(container: HTMLElement, card: PersonCard): void { clear(container); - this.mount = renderPersonCard(container, card, this.handlers(this.onRelative, this.placeOf(card.id))); - this.onOverviewMounted?.(this.mount.overviewPane, card); - } - - private handlers(onRelative: (id: string) => void, place: string): PersonCardHandlers { - return { - onRelative, + const place = this.placeOf(card.id); + renderPersonCard(container, card, this.onRelative, { place, - schoolId: this.schoolId, + away: place === formatPersonPlace('away'), tab: this.tab, - onTabChange: (tab) => { + log: this.tab === 'now' ? this.logPage : null, + logQuery: this.logQuery, + onTab: (tab) => { this.tab = tab; - if (tab === 'portrait') { + if (tab === 'now') { + void this.loadLog(); + } else if (tab === 'portrait') { void this.refreshSwarmStatus(); } else { 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), portraitBusy: this.portraitBusy, portraitError: this.portraitError, swarmConfigured: this.swarmConfigured, swarmConnected: this.swarmConnected, - }; + }); + const overview = container.querySelector('[data-card-tab="overview"]'); + if (overview instanceof HTMLElement) { + this.onOverviewMounted?.(overview, card); + } + } + + private async loadLog(): Promise { + 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 { @@ -147,6 +205,11 @@ export class PersonCardHost { 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')); + } } } diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 5d9a3aa..6e7023f 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -12,6 +12,16 @@ internal enum PersonLookupError 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 Entries); + +internal sealed record PersonLogEntryResponse(DateTime Time, string Type, string Label, string? ThingDef); + internal sealed record PeopleListResponse( int Total, int Page, @@ -66,6 +76,8 @@ internal sealed record PersonCardResponse( IReadOnlyList Carried, float CarryMass, float CarryCapacity, + bool HasLocker = false, + int HomeCount = 0, bool HasAvatar = false, bool HasFullBody = false); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 9d8b58e..82ca947 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -7,7 +7,7 @@ namespace HSchool.Server.Api; /// /// 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. /// internal static class SchoolEndpoints @@ -196,6 +196,41 @@ internal static class SchoolEndpoints }) .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()); + 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 ( int id, string personId, @@ -479,6 +514,66 @@ internal static class SchoolEndpoints 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) => StaffingMapper.From( published.Roster, diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index a0f4e64..2b975d1 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -59,6 +59,14 @@ internal abstract record GameCommand string Locale, TaskCompletionSource Result) : GameCommand; + /// Today's history for one person. Completes on that school's worker; not a snapshot. + internal sealed record GetPersonLog( + int SchoolId, + string PersonId, + string Locale, + PersonLogQuery Query, + TaskCompletionSource Result) : GameCommand; + internal sealed record HireStaff( int SchoolId, string PersonId, diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 312d409..14ff3b7 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -187,6 +187,10 @@ internal sealed class GameLoopService( HandleGetPerson(getPerson); break; + case GameCommand.GetPersonLog getLog: + HandleGetPersonLog(getLog); + break; + case GameCommand.HireStaff hire: HandleStaffing( 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 result) { if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index e777e94..1c5827f 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -85,7 +85,9 @@ internal static class PersonCardReader Worn(person, catalog, locale), Carried(person, catalog, locale), 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? LiveNeeds(World world, string personId) diff --git a/src/HSchool.Server/Game/PersonLogReader.cs b/src/HSchool.Server/Game/PersonLogReader.cs new file mode 100644 index 0000000..febaf7f --- /dev/null +++ b/src/HSchool.Server/Game/PersonLogReader.cs @@ -0,0 +1,30 @@ +using HSchool.Server.Api; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// Pages today's log on the worker thread. The list is not a published snapshot. +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); + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index e9d9c45..5815ee8 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -429,6 +429,14 @@ internal sealed class SchoolWorker : new PersonCardResult(card, PersonLookupError.None)); 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: hire.Result.TrySetResult(ApplyHire(school, hire.PersonId, hire.Position)); break; @@ -485,6 +493,9 @@ internal sealed class SchoolWorker case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetResult(new PersonCardResult(null, PersonLookupError.UnknownSchool)); break; + case WorkerCommand.GetPersonLog getLog: + getLog.Result.TrySetResult(new PersonLogResult(null, PersonLookupError.UnknownSchool)); + break; case WorkerCommand.HireStaff hire: hire.Result.TrySetResult(Staffing.UnknownSchool()); break; @@ -513,6 +524,9 @@ internal sealed class SchoolWorker case WorkerCommand.GetPerson getPerson: getPerson.Result.TrySetException(exception); break; + case WorkerCommand.GetPersonLog getLog: + getLog.Result.TrySetException(exception); + break; case WorkerCommand.HireStaff hire: hire.Result.TrySetException(exception); break; diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 5b263ae..5fdbda5 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -1,6 +1,7 @@ using HSchool.People; using HSchool.Server.Api; using HSchool.Server.Net; +using HSchool.Simulation; namespace HSchool.Server.Game; @@ -27,6 +28,12 @@ internal abstract record WorkerCommand string Locale, TaskCompletionSource Result) : WorkerCommand; + internal sealed record GetPersonLog( + string PersonId, + string Locale, + PersonLogQuery Query, + TaskCompletionSource Result) : WorkerCommand; + internal sealed record HireStaff( string PersonId, string Position, diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 07fe741..530dc15 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -166,6 +166,8 @@ "ApparelConditionWorn": "worn", "ApparelConditionTorn": "torn", "ApparelConditionRags": "in rags", + "ActionStarted": "started: {0}", + "ActionEnded": "finished: {0}", "ApparelReplaced": "got a new {0}", "core": "Core", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 4bc62c4..085a25d 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -166,6 +166,8 @@ "ApparelConditionWorn": "поношенная", "ApparelConditionTorn": "порванная", "ApparelConditionRags": "висит лохмотьями", + "ActionStarted": "начал: {0}", + "ActionEnded": "закончил: {0}", "ApparelReplaced": "получил новую {0}", "core": "Базовая игра", } diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs index cdad112..cb6efd7 100644 --- a/src/HSchool.Simulation/ApparelWear.cs +++ b/src/HSchool.Simulation/ApparelWear.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Arch.Core; using HSchool.Ai; using HSchool.Content; @@ -6,33 +5,6 @@ using HSchool.People; namespace HSchool.Simulation; -/// Log row types phase 36 will page. Captions are ready even while the card has no tab. -public static class PersonLogTypes -{ - public const string ApparelReplaced = "apparel-replaced"; -} - -/// -/// One thing that happened to a person today. Stored on the worker, not in people.json. -/// The day boundary is six in the morning — the same hour skip lands on. -/// -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); - } -} - /// Condition captions live in the catalog. The client draws what it is told. public static class ApparelCondition { diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj index 6f95035..2a40490 100644 --- a/src/HSchool.Simulation/HSchool.Simulation.csproj +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -9,6 +9,10 @@ + + + + diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs new file mode 100644 index 0000000..59984f2 --- /dev/null +++ b/src/HSchool.Simulation/PersonLog.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using Arch.Core; +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Log row types the person card pages. Captions for morning issue are ready even while phase 34 +/// is the one that appends that row. +/// +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"; +} + +/// +/// One thing that happened to a person today. Stored on the school worker, not in people.json. +/// The day boundary is six in the morning — the same hour skip lands on. +/// +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 Entries); + +public sealed record PersonLogRow(DateTime Time, string Type, string Label, string? ThingDef); + +/// Filters, sorts and pages today's log for one person. Does not touch a World. +public static class PersonLogBrowser +{ + public const int DefaultPageSize = 20; + + public const int MaxPageSize = 100; + + public static PersonLogPage Apply( + IReadOnlyList 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(); + 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() : rows.Skip(skip).Take(pageSize).ToArray(); + return new PersonLogPage(rows.Count, page, pageSize, slice); + } +} + +/// Records action start/end and clears yesterday at six in the morning. +internal static class PersonDayLog +{ + private static readonly QueryDescription IdentityAndActivity = + new QueryDescription().WithAll(); + + 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(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; + } +} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 81dcf13..83ffe66 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -116,11 +116,17 @@ public sealed class School : IDisposable public int PendingDecisionCount => DecisionQueue.Count; /// - /// 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. /// public IReadOnlyList DayLog => _dayLog; - internal void ResetDayLog() => _dayLog.Clear(); + internal Dictionary LoggedActivity { get; } = new(StringComparer.Ordinal); + + internal void ResetDayLog() + { + _dayLog.Clear(); + LoggedActivity.Clear(); + } internal void AppendDayLog(PersonLogEvent row) => _dayLog.Add(row); @@ -150,6 +156,7 @@ public sealed class School : IDisposable LastDecisionSlot = null; Plans.Clear(); DecisionQueue.Clear(); + LoggedActivity.Clear(); SyncWeather(force: true); } @@ -158,7 +165,13 @@ public sealed class School : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(personId); 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) @@ -217,6 +230,7 @@ public sealed class School : IDisposable var before = Clock.Time; Clock.JumpTo(next.Value); + ResetDayLog(); var peopleChanged = TryYearlyIntake(before, next.Value); peopleChanged |= TryApplicantRefresh(); PlanDay = null; @@ -273,6 +287,11 @@ public sealed class School : IDisposable var peopleChanged = false; if (gameMinutes > 0) { + if (PersonDayLog.CrossedDayStart(before, Clock.Time)) + { + ResetDayLog(); + } + peopleChanged = TryYearlyIntake(before, Clock.Time); peopleChanged |= TryApplicantRefresh(); if (peopleChanged) @@ -287,6 +306,8 @@ public sealed class School : IDisposable PresenceSystem.Enqueue(this, id); } + PersonDayLog.Sync(this); + if (Catalog is not null) { var below = PresenceSystem.BelowThreshold(this); diff --git a/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs b/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs new file mode 100644 index 0000000..8504e8b --- /dev/null +++ b/tests/HSchool.AppHost.Tests/PersonLogApiTests.cs @@ -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( + $"/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 GetPeopleAsync(HttpClient client, int schoolId) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people?pageSize=10", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + Assert.NotEmpty(page.People); + return page; + } + + private static async Task GetLogAsync(HttpClient client, int schoolId, string personId, string query) + { + var page = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/people/{Uri.EscapeDataString(personId)}/log?{query}", + TestContext.Current.CancellationToken); + Assert.NotNull(page); + return page; + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code); + + private sealed record PeopleList(IReadOnlyList People); + + private sealed record PersonRow(string Id); + + private sealed record PersonLogResponse( + int Total, + int Page, + int PageSize, + IReadOnlyList Entries); + + private sealed record PersonLogEntry(DateTime Time, string Type, string Label, string? ThingDef); +} diff --git a/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs b/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs new file mode 100644 index 0000000..8eae1bd --- /dev/null +++ b/tests/HSchool.Simulation.Tests/PersonDayLogTests.cs @@ -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(); + 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(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(); + 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); + } +}