From 36ec5e9f8ea255254055bd73acfafa774dcc5a09 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 12:03:04 +0300 Subject: [PATCH] Restore school tabs and filters after refresh. The URL keeps the open school and current view; sessionStorage keeps list filters and searches so F5 and re-entry do not dump the player on an empty map. Co-authored-by: Cursor --- AGENTS.md | 3 + docs/design/off-queue.md | 32 ++ docs/phases/55-view-state.md | 46 +++ docs/phases/README.md | 3 +- src/HSchool.Client/src/main.ts | 67 +++- src/HSchool.Client/src/ui/applicantsDialog.ts | 57 ++- src/HSchool.Client/src/ui/gameScreen.test.ts | 75 +++- src/HSchool.Client/src/ui/gameScreen.ts | 99 ++++- .../src/ui/managementPanel.test.ts | 27 ++ src/HSchool.Client/src/ui/managementPanel.ts | 27 +- src/HSchool.Client/src/ui/peoplePanel.test.ts | 15 + src/HSchool.Client/src/ui/peoplePanel.ts | 121 +++++- src/HSchool.Client/src/ui/personCard.ts | 17 +- src/HSchool.Client/src/ui/personCardHost.ts | 37 ++ src/HSchool.Client/src/ui/viewState.test.ts | 162 ++++++++ src/HSchool.Client/src/ui/viewState.ts | 378 ++++++++++++++++++ 16 files changed, 1104 insertions(+), 62 deletions(-) create mode 100644 docs/phases/55-view-state.md create mode 100644 src/HSchool.Client/src/ui/viewState.test.ts create mode 100644 src/HSchool.Client/src/ui/viewState.ts diff --git a/AGENTS.md b/AGENTS.md index ccbab65..75e316e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,3 +207,6 @@ only resolved a status line. Solution-wide is CI. `run-aspire.ps1` / `run-aspire.sh` skip that with `--no-build` when AppHost and Server dlls are newer than C# / csproj / props. `--rebuild` forces a build. Bare `dotnet run --project src/HSchool.AppHost` still compiles. +- **School UI chrome lives in the URL query and `sessionStorage`, not RAM.** A test that assumes + `GameScreen.show()` starts on the map with empty people filters must clear both, or the previous + case leaks. F5 reopens `?school=`; the server still owns the clock. diff --git a/docs/design/off-queue.md b/docs/design/off-queue.md index 405de3c..902cf51 100644 --- a/docs/design/off-queue.md +++ b/docs/design/off-queue.md @@ -143,3 +143,35 @@ Ручной `CHANGELOG.md`. Запись на пользователе в `users.json`. Вызов git из бегущего процесса. Бамп версии сокета. Окно внутри открытой школы. + +## Состояние экрана + +### Зачем + +Менеджер — списки с фильтрами. Сейчас они живут только в RAM: F5 всегда меню, повторный вход +сбрасывает вкладки и запросы. Игрок сравнивает людей и соискателей; потерять «ученики 5А» на +обновлении — дырка в уже живых экранах, не новая фича. + +### Было / Стало / Почему + +**Было.** SPA без адреса. `GameScreen.show` и `PeoplePanel.show` обнуляют вкладки, фильтры и +выбор. Язык уже в `localStorage`; игровые часы — на сервере. + +**Стало.** Query URL держит *где я*: школа, обзор/управление, карта/люди, выбранные комната и +человек. `sessionStorage` по `schoolId` держит фильтры, поиски, сорт, страницу, вкладку карточки +и класс сетки — и последний маршрут, чтобы вход с меню поднял то же место. F5 с `?school=` +шлёт тот же `OpenSchool`, что карточка. Вход/выход — `pushState`; смена вкладки и фильтра — +`replaceState`, чтобы «назад» не листал каждый клик. + +**Почему.** Сервер не знает про вкладки; класть их в сейв — лишний HTTP. `localStorage` как у +языка держал бы «5А» через месяц. URL без фильтров остаётся коротким и копируемым. + +### Что видит игрок + +F5 внутри школы — та же школа, те же вкладки и фильтры. Назад — меню. Снова открыть ту же +школу — не сброс на карту. Гость не входит в управление по ссылке. Скорость часов, пауза, +открытый пул соискателей и мастер создания не вспоминаются. + +### Что не входит + +Поиск по имени в списках, где его нет. Роутер-библиотека. Состояние на сервере. Бамп протокола. diff --git a/docs/phases/55-view-state.md b/docs/phases/55-view-state.md new file mode 100644 index 0000000..bea6344 --- /dev/null +++ b/docs/phases/55-view-state.md @@ -0,0 +1,46 @@ +# Фаза 55. Состояние экрана + +## Зависимости + +Нет. Оболочка, люди, управление и соискатели уже на экране. + +## Зачем + +F5 и выход в меню не должны выкидывать игрока на карту с пустыми фильтрами. Школа, вкладки и +уже существующие поиски/фильтры восстанавливаются; сервер по-прежнему единственный источник +игровых данных. + +## Задачи + +- [x] Query URL: `school`, `mode`, `tab`, `node`, `person`, `inspect`. F5 с `school=` снова + открывает эту школу (`OpenSchool` как с карточки меню). Нет школы / не в списках — меню, + URL чистится. Гость с `mode=manage` остаётся в обзоре +- [x] `sessionStorage` ключ `h-school.view.{id}`: фильтры и пейджер людей, поиск/фильтры + соискателей, вкладка и поиски карточки, вкладка управления и выбранный класс сетки, + последний `mode`/`tab`/выбор — чтобы повторный вход с меню поднял то же место +- [x] `history.pushState` только вход и выход; смена вкладки и фильтров — `replaceState`. + «Назад» из школы — меню. Протокол, HTTP и сейв не трогать; роутер не добавлять +- [x] Подписи через `t(...)` не нужны: новых строк нет. `docs/design/off-queue.md` и эта фаза — + в том же коммите, что и код + +## Тесты, без которых фаза не закрыта + +- [x] Разбор и сборка query: валидные поля, мусорный `mode`/`school` → обзор / меню +- [x] Chrome в `sessionStorage` пишется и читается по школе; битый JSON → значения по умолчанию +- [x] Школа из списка: своя, чужая (`mine: false`), нет id → `null` +- [x] Панель людей: `show` поднимает сохранённые фильтры в запрос; смена фильтра пишет storage +- [x] Экран школы: URL `tab=people` открывает людей; гость игнорирует `mode=manage` +- [x] Управление: новая панель на той же школе открывает сохранённую вкладку «Правила» + +## Критерий готовности + +- Открыть школу, вкладка «Люди», фильтр «ученики / 5», карточка. F5 — снова эта школа, те же + фильтры и человек. Назад — меню. Снова открыть ту же школу с меню — те же вкладка и фильтры +- Гость не попадает в «Управление» по URL +- `npm --prefix src/HSchool.Client test` проходит на затронутых тестах + +## Стоп + +Не класть состояние на сервер. Не бампить протокол. Не добавлять поиск по имени в списки, где +его нет. Не держать открытым диалог соискателей и мастер создания. Не трогать скорость часов и +паузу — они уже на сервере. diff --git a/docs/phases/README.md b/docs/phases/README.md index 09a1c77..328c115 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -231,7 +231,7 @@ Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10. После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно -с 48. 49 и 54 от них не зависят. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md). +с 48. 49, 54 и 55 от них не зависят. Баг в уже сделанном — [`../bugs/README.md`](../bugs/README.md). | Фаза | Статус | Зачем | | --- | --- | --- | @@ -242,3 +242,4 @@ | [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост | | [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу | | [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита | +| [55. Состояние экрана](55-view-state.md) | 🔄 | F5 и переходы не сбрасывают школу, вкладки и фильтры | diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index e2592a4..88d772e 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -1,5 +1,5 @@ import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts'; -import { logoutSession } from './net/api.ts'; +import { fetchSchools, logoutSession } from './net/api.ts'; import { getLocale, onLocaleChange } from './i18n/locale.ts'; import { t, type MessageKey } from './i18n/strings.ts'; import { GameScreen } from './ui/gameScreen.ts'; @@ -8,6 +8,7 @@ import { MainMenu } from './ui/mainMenu.ts'; import { ensureSession } from './ui/sessionGate.ts'; import { showWhatsNew } from './ui/whatsNew.ts'; import type { School } from './net/api.ts'; +import { MENU_ROUTE, parseRoute, schoolFromList, writeRoute, type HistoryMode } from './ui/viewState.ts'; import './style.css'; const STATUS_KEYS: Record = { @@ -67,7 +68,7 @@ async function bootstrap(): Promise { onSchoolGone: (schoolId) => { // Deleted from another tab while we were inside it. if (openSchool?.id === schoolId) { - showMenu(); + showMenu('replace'); } }, onLatency: (rttMs) => { @@ -87,25 +88,48 @@ async function bootstrap(): Promise { } } - function enterSchool(school: School): void { + function enterSchool(school: School, historyMode: HistoryMode = 'push'): void { openSchool = school; menu.stop(); app.replaceChildren(game.element); - game.show(school); + game.show(school, historyMode); connection.openSchool(school.id); } function leaveSchool(): void { connection.closeSchool(); - showMenu(); + showMenu('push'); } - function showMenu(): void { + function showMenu(historyMode: HistoryMode | 'none' = 'replace'): void { openSchool = null; + if (historyMode !== 'none') { + writeRoute(MENU_ROUTE, historyMode); + } + app.replaceChildren(menu.element); menu.start(); } + async function openFromRoute(historyMode: HistoryMode): Promise { + const route = parseRoute(location.search); + if (route.schoolId === null) { + return false; + } + + try { + const school = schoolFromList(route.schoolId, await fetchSchools()); + if (school === null) { + return false; + } + + enterSchool(school, historyMode); + return true; + } catch { + return false; + } + } + async function handleLogout(): Promise { connection.close(); menu.stop(); @@ -113,7 +137,7 @@ async function bootstrap(): Promise { await ensureSession(); await showWhatsNew(); connection.connect(); - showMenu(); + showMenu('replace'); } onLocaleChange(() => { @@ -125,7 +149,34 @@ async function bootstrap(): Promise { paintChrome(); connection.connect(); - showMenu(); + + window.addEventListener('popstate', () => { + const route = parseRoute(location.search); + if (route.schoolId === null) { + if (openSchool !== null) { + connection.closeSchool(); + showMenu('none'); + } + + return; + } + + if (openSchool?.id === route.schoolId) { + return; + } + + void openFromRoute('replace').then((opened) => { + if (!opened && openSchool === null) { + showMenu('replace'); + } + }); + }); + + void openFromRoute('replace').then((opened) => { + if (!opened) { + showMenu('replace'); + } + }); window.addEventListener('beforeunload', () => connection.close()); } diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts index ee9d52f..5d645af 100644 --- a/src/HSchool.Client/src/ui/applicantsDialog.ts +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -19,11 +19,10 @@ import { formatTraits, parseOptionalInt, } from './staffingUi.ts'; +import { loadChrome, patchChrome, type ApplicantSort } from './viewState.ts'; const TEACHER = 'Teacher'; -type ApplicantSort = 'name' | 'age' | 'ask'; - const SORT_COLUMNS: readonly { sort: ApplicantSort; label: MessageKey }[] = [ { sort: 'name', label: 'staffColName' }, { sort: 'age', label: 'peopleColAge' }, @@ -78,11 +77,26 @@ export class ApplicantsDialog { this.error.hidden = true; this.ageMinInput.min = '0'; this.ageMaxInput.min = '0'; - this.search.addEventListener('input', () => this.paintList()); - this.sexSelect.addEventListener('change', () => this.paintList()); - this.kindSelect.addEventListener('change', () => this.paintList()); - this.ageMinInput.addEventListener('change', () => this.paintList()); - this.ageMaxInput.addEventListener('change', () => this.paintList()); + this.search.addEventListener('input', () => { + this.persist(); + this.paintList(); + }); + this.sexSelect.addEventListener('change', () => { + this.persist(); + this.paintList(); + }); + this.kindSelect.addEventListener('change', () => { + this.persist(); + this.paintList(); + }); + this.ageMinInput.addEventListener('change', () => { + this.persist(); + this.paintList(); + }); + this.ageMaxInput.addEventListener('change', () => { + this.persist(); + this.paintList(); + }); this.closeButton.addEventListener('click', () => this.modal?.close(undefined)); } @@ -111,6 +125,7 @@ export class ApplicantsDialog { el('div', { class: 'dialog__actions' }, this.closeButton), ); this.localize(); + this.restoreFilters(); return modal.open(this.search).finally(() => { this.modal = null; }); @@ -289,11 +304,37 @@ export class ApplicantsDialog { this.dir = 'asc'; } + this.persist(); this.paintList(); } + private restoreFilters(): void { + const saved = loadChrome(this.options.schoolId).applicants; + this.search.value = saved.search; + this.sexSelect.value = saved.sex; + this.kindSelect.value = saved.kind; + this.ageMinInput.value = saved.ageMin; + this.ageMaxInput.value = saved.ageMax; + this.sort = saved.sort; + this.dir = saved.dir; + this.paintList(); + } + + private persist(): void { + patchChrome(this.options.schoolId, { + applicants: { + search: this.search.value, + sex: this.sexSelect.value, + kind: this.kindSelect.value, + ageMin: this.ageMinInput.value, + ageMax: this.ageMaxInput.value, + sort: this.sort, + dir: this.dir, + }, + }); + } + private async openCard(personId: string): Promise { - this.cardHost.resetTabs(); try { const card = await fetchPerson(this.options.schoolId, personId, getLocale()); this.paintCard(card); diff --git a/src/HSchool.Client/src/ui/gameScreen.test.ts b/src/HSchool.Client/src/ui/gameScreen.test.ts index 75bf408..d97ea5b 100644 --- a/src/HSchool.Client/src/ui/gameScreen.test.ts +++ b/src/HSchool.Client/src/ui/gameScreen.test.ts @@ -6,18 +6,53 @@ import { PresenceState } from '../net/protocol.ts'; import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { GameScreen } from './gameScreen.ts'; +import type { School } from '../net/api.ts'; vi.mock('../net/api.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchDirectory: async () => [], + fetchPeople: async () => ({ + total: 0, + page: 1, + pageSize: 50, + people: [], + filters: { years: [], letters: [], positions: [] }, + }), + fetchGameStatus: async () => ({ + tick: 0, + tickRate: 20, + schools: 1, + maxSchools: 6, + connections: 0, + swarmUiConfigured: false, + swarmUiConnected: null, + }), }; }); const initial = getLocale(); -afterEach(() => setLocale(initial)); +function school(overrides: Partial = {}): School { + return { + id: 1, + name: 'Test', + gameTime: '2012-03-31T10:00:00.000Z', + running: true, + speedIndex: 1, + seed: 9, + mine: true, + ...overrides, + }; +} + +afterEach(() => { + sessionStorage.clear(); + history.replaceState(null, '', '/'); + document.body.replaceChildren(); + setLocale(initial); +}); describe('GameScreen speed buttons', () => { it('shows five speed buttons ×½ ×1 ×2 ×5 ×10', () => { @@ -127,3 +162,41 @@ describe('GameScreen location talk', () => { ); }); }); + +describe('GameScreen view restore', () => { + it('opens the people tab from the URL query', () => { + history.replaceState(null, '', '/?school=1&tab=people'); + const screen = new GameScreen({ + onLeave: () => {}, + onSetRunning: () => {}, + onSetSpeed: () => {}, + onSkip: () => {}, + }); + document.body.append(screen.element); + screen.show(school()); + + const peopleTab = [...screen.element.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('peopleTitle'), + ); + expect(peopleTab?.classList.contains('panel__tab--active')).toBe(true); + expect(screen.element.querySelector('.panel__body--fill')?.hasAttribute('hidden')).toBe(false); + }); + + it('keeps a guest on overview even when the URL asks for manage', () => { + history.replaceState(null, '', '/?school=3&mode=manage'); + const screen = new GameScreen({ + onLeave: () => {}, + onSetRunning: () => {}, + onSetSpeed: () => {}, + onSkip: () => {}, + }); + document.body.append(screen.element); + screen.show(school({ id: 3, mine: false, name: 'Foreign' })); + + const tabs = screen.element.querySelectorAll('.mode-tab'); + expect(tabs[1]?.hidden).toBe(true); + expect(tabs[0]?.classList.contains('mode-tab--active')).toBe(true); + expect(location.search).toContain('school=3'); + expect(location.search).not.toContain('mode=manage'); + }); +}); diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index b8953bf..a1b84d0 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -16,6 +16,17 @@ import { clear, el } from './dom.ts'; import { ManagementPanel } from './managementPanel.ts'; import { PeoplePanel } from './peoplePanel.ts'; import { locationPersonLine } from '../format/talkCircle.ts'; +import { formatPersonPlace } from './personCard.ts'; +import { + type HistoryMode, + type InspectWhat, + type LeftTab, + type ScreenMode, + loadChrome, + parseRoute, + patchChrome, + writeRoute, +} from './viewState.ts'; interface GameScreenOptions { readonly onLeave: () => void; @@ -66,7 +77,12 @@ export class GameScreen { private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsList = el('ul', { class: 'panel__list' }); - private readonly people = new PeoplePanel({ onSelect: () => this.inspect('person') }); + private readonly people = new PeoplePanel({ + onSelect: () => { + this.inspect('person'); + this.syncRoute(); + }, + }); private readonly management = new ManagementPanel(); private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' }); private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' }); @@ -91,7 +107,9 @@ export class GameScreen { private skipTarget: Date | null = null; private lastTemperatureTenths: number | null = null; private lastPrecipitation: number | null = null; - private inspected: 'location' | 'person' = 'location'; + private inspected: InspectWhat = 'location'; + private mode: ScreenMode = 'overview'; + private leftTab: LeftTab = 'map'; constructor(options: GameScreenOptions) { this.speedButtons = CLOCK_SPEEDS.map((_, index) => @@ -208,12 +226,9 @@ export class GameScreen { } /** Called when the screen opens, before the first clock frame and snapshot arrive. */ - show(school: School): void { + show(school: School, historyMode: HistoryMode = 'replace'): void { this.canManage = school.mine; this.manageTab.hidden = !this.canManage; - if (!this.canManage) { - this.showMode('overview'); - } const clockControls = this.root.querySelector('.clock__controls'); if (clockControls !== null) { @@ -227,16 +242,29 @@ export class GameScreen { this.nodes = []; this.presence = null; this.directory = new Map(); - this.selectedId = null; this.skipAllowed = false; this.skipTarget = null; this.rebuildTree(); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); - this.people.show(school.id); + + const url = parseRoute(location.search); + const saved = loadChrome(school.id); + const fromUrl = url.schoolId === school.id; + const locationState = fromUrl ? url : { schoolId: school.id, ...saved.route }; + this.mode = this.canManage ? locationState.mode : 'overview'; + this.leftTab = locationState.tab; + this.selectedId = locationState.nodeId; + this.inspected = locationState.inspect; + + this.people.show(school.id, { + selectedId: locationState.personId, + openCard: this.inspected === 'person', + }); this.bindPeople(); - this.showTab('map'); - this.inspect('location'); - this.showMode('overview'); + this.showTab(this.leftTab, false); + this.inspect(this.inspected); + this.showMode(this.mode, false); + this.syncRoute(historyMode); void this.loadDirectory(); } @@ -250,11 +278,15 @@ export class GameScreen { } this.nodes = nodes; - this.selectedId = this.selectedId !== null && nodes.some((node) => node.id === this.selectedId) - ? this.selectedId + const wanted = this.selectedId; + this.selectedId = wanted !== null && nodes.some((node) => node.id === wanted) + ? wanted : (nodes[0]?.id ?? null); this.rebuildTree(); this.paintSelection(); + if (wanted !== this.selectedId) { + this.syncRoute(); + } } applyPresence(schoolId: number, message: PresenceMessage): void { @@ -324,13 +356,15 @@ export class GameScreen { this.selectedId = id; this.inspect('location'); this.paintSelection(); + this.syncRoute(); } - private showMode(mode: 'overview' | 'manage'): void { + private showMode(mode: ScreenMode, persist = true): void { if (mode === 'manage' && !this.canManage) { mode = 'overview'; } + this.mode = mode; this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview'); this.manageTab.classList.toggle('mode-tab--active', mode === 'manage'); this.overview.hidden = mode !== 'overview'; @@ -342,26 +376,59 @@ export class GameScreen { if (mode === 'overview') { this.people.refresh(); } + + if (persist) { + this.syncRoute(); + } } - private showTab(tab: 'map' | 'people'): void { + private showTab(tab: LeftTab, persist = true): void { + this.leftTab = tab; this.mapTab.classList.toggle('panel__tab--active', tab === 'map'); this.peopleTab.classList.toggle('panel__tab--active', tab === 'people'); this.mapBody.hidden = tab !== 'map'; this.peopleBody.hidden = tab !== 'people'; + if (persist) { + this.syncRoute(); + } } /** * The middle panel follows the last thing picked, whichever list it came from. Switching tabs * on the left does not change it — the map tab is often just a way to find the next room. */ - private inspect(what: 'location' | 'person'): void { + private inspect(what: InspectWhat): void { this.inspected = what; this.locationBody.hidden = what !== 'location'; this.people.cardElement.hidden = what !== 'person'; this.paintInspectTitle(); } + private syncRoute(historyMode: HistoryMode = 'replace'): void { + if (this.schoolId === null) { + return; + } + + const route = { + schoolId: this.schoolId, + mode: this.mode, + tab: this.leftTab, + nodeId: this.selectedId, + personId: this.people.selectedPersonId, + inspect: this.inspected, + }; + patchChrome(this.schoolId, { + route: { + mode: route.mode, + tab: route.tab, + nodeId: route.nodeId, + personId: route.personId, + inspect: route.inspect, + }, + }); + writeRoute(route, historyMode); + } + private paintInspectTitle(): void { this.inspectTitle.textContent = this.inspected === 'person' ? t('personTitle') : t('locationName'); } diff --git a/src/HSchool.Client/src/ui/managementPanel.test.ts b/src/HSchool.Client/src/ui/managementPanel.test.ts index 36a6770..f2b2238 100644 --- a/src/HSchool.Client/src/ui/managementPanel.test.ts +++ b/src/HSchool.Client/src/ui/managementPanel.test.ts @@ -18,6 +18,7 @@ import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { ManagementPanel } from './managementPanel.ts'; import { formatMoney } from './staffingUi.ts'; +import { loadChrome } from './viewState.ts'; vi.mock('../net/api.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -122,6 +123,7 @@ function timetable(): Timetable { describe('ManagementPanel payroll cap', () => { beforeEach(() => { + sessionStorage.clear(); setLocale('en'); vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); @@ -217,6 +219,7 @@ describe('ManagementPanel payroll cap', () => { describe('ManagementPanel uncovered', () => { beforeEach(() => { + sessionStorage.clear(); setLocale('en'); vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); @@ -263,6 +266,7 @@ describe('ManagementPanel uncovered', () => { describe('ManagementPanel rules tab', () => { beforeEach(() => { + sessionStorage.clear(); setLocale('en'); vi.mocked(fetchStaffing).mockReset(); vi.mocked(fetchTimetable).mockReset(); @@ -312,4 +316,27 @@ describe('ManagementPanel rules tab', () => { expect(panel.listElement.textContent).toContain(t('timetableTitle')); expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull(); }); + + it('reopens the rules tab from sessionStorage', async () => { + const first = new ManagementPanel(); + document.body.append(first.listElement, first.cardElement); + first.show(2); + await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled()); + + const rulesTab = [...first.listElement.querySelectorAll('.panel__tab')].find( + (button) => button.textContent === t('manageTabRules'), + ); + if (!(rulesTab instanceof HTMLButtonElement)) { + throw new Error('rules tab is missing'); + } + + rulesTab.click(); + expect(loadChrome(2).management.tab).toBe('rules'); + + const second = new ManagementPanel(); + document.body.append(second.listElement, second.cardElement); + second.show(2); + expect(second.listElement.querySelector('.staffing')?.hasAttribute('hidden')).toBe(true); + expect(second.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false); + }); }); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 93a4774..79c5b5c 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -18,6 +18,7 @@ import { formatPersonPlace } from './personCard.ts'; import { PersonCardHost } from './personCardHost.ts'; import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; +import { loadChrome, patchChrome } from './viewState.ts'; const TEACHER = 'Teacher'; @@ -112,6 +113,7 @@ export class ManagementPanel { this.cardElement = this.card; this.classSelect.addEventListener('change', () => { this.classId = this.classSelect.value || null; + this.persist(); this.timetableGrid.setTable(this.timetable, this.classId); }); this.localize(); @@ -146,11 +148,12 @@ export class ManagementPanel { const switched = this.schoolId !== schoolId; this.schoolId = schoolId; if (switched) { - this.selectedId = null; + const saved = loadChrome(schoolId).management; + this.selectedId = saved.selectedId; this.staffing = null; this.timetable = null; - this.classId = null; - this.manageTab = 'staff'; + this.classId = saved.classId; + this.manageTab = saved.tab; this.clearError(); } @@ -166,6 +169,7 @@ export class ManagementPanel { private showManageTab(tab: 'staff' | 'rules'): void { this.manageTab = tab; + this.persist(); this.paintManageTabs(); const schoolId = this.schoolId; if (tab === 'rules' && schoolId !== null) { @@ -218,6 +222,7 @@ export class ManagementPanel { this.timetable = timetable; this.syncClass(timetable); this.syncSelection(); + this.persist(); this.paint(); this.poolDialog?.setStaffing(staffing); this.timetableGrid.setTable(timetable, this.classId); @@ -387,6 +392,7 @@ export class ManagementPanel { private async select(personId: string): Promise { this.selectedId = personId; + this.persist(); this.clearError(); this.paint(); await this.openCard(personId); @@ -415,6 +421,7 @@ export class ManagementPanel { onChange: (next, hiredId) => { this.staffing = next; this.selectedId = hiredId; + this.persist(); this.paint(); void this.refreshTimetable(); void this.openCard(hiredId); @@ -664,4 +671,18 @@ export class ManagementPanel { this.error.hidden = true; this.error.textContent = ''; } + + private persist(): void { + if (this.schoolId === null) { + return; + } + + patchChrome(this.schoolId, { + management: { + tab: this.manageTab, + classId: this.classId, + selectedId: this.selectedId, + }, + }); + } } diff --git a/src/HSchool.Client/src/ui/peoplePanel.test.ts b/src/HSchool.Client/src/ui/peoplePanel.test.ts index 6e81d58..98b5b3a 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.test.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.test.ts @@ -13,6 +13,7 @@ import { import { getLocale, setLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { PeoplePanel } from './peoplePanel.ts'; +import { loadChrome, patchChrome } from './viewState.ts'; vi.mock('../net/api.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -114,6 +115,7 @@ function controls(root: HTMLElement): { describe('PeoplePanel', () => { beforeEach(() => { + sessionStorage.clear(); setLocale('en'); vi.mocked(fetchPeople).mockReset(); vi.mocked(fetchPerson).mockReset(); @@ -209,4 +211,17 @@ describe('PeoplePanel', () => { expect(vi.mocked(fetchPeople).mock.calls.length).toBe(calls); expect(lastQuery().page).toBe(3); }); + + it('restores saved filters on show and writes them when they change', async () => { + patchChrome(3, { people: { role: 'student', year: '5', page: 2 } }); + const panel = new PeoplePanel({ onSelect: () => {} }); + panel.show(3); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(1)); + expect(lastQuery()).toMatchObject({ role: 'student', year: 5, page: 2 }); + + controls(panel.listElement).role.value = 'staff'; + controls(panel.listElement).role.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(fetchPeople).toHaveBeenCalledTimes(2)); + expect(loadChrome(3).people).toMatchObject({ role: 'staff', year: '5', page: 1 }); + }); }); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index ea1adac..292cac4 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -5,6 +5,7 @@ import { clear, el } from './dom.ts'; import { formatPersonPlace, placement, roleLabels } from './personCard.ts'; import { PersonCardHost } from './personCardHost.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; +import { loadChrome, patchChrome } from './viewState.ts'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ { sort: 'surname', label: 'peopleColName' }, @@ -17,6 +18,12 @@ interface PeoplePanelOptions { readonly onSelect: () => void; } +interface PeopleShowOptions { + readonly selectedId?: string | null; + /** When false, restore the list highlight without opening the card (location is in the inspector). */ + readonly openCard?: boolean; +} + /** * The people list and the card of whoever is picked. They live in two different panels — the list * on the left next to the map tree, the card in the middle inspector — so this owns two elements @@ -59,6 +66,13 @@ export class PeoplePanel { private page = 1; private pages = 1; private selectedId: string | null = null; + private role = ''; + private year = ''; + private letter = ''; + private position = ''; + private sex = ''; + private ageMin = ''; + private ageMax = ''; private token = 0; private cardToken = 0; private locate: ((id: string) => string) | null = null; @@ -79,6 +93,7 @@ export class PeoplePanel { this.prevButton.addEventListener('click', () => { if (this.page > 1) { this.page -= 1; + this.persist(); void this.reload(); } }); @@ -88,6 +103,7 @@ export class PeoplePanel { } this.page += 1; + this.persist(); void this.reload(); }); @@ -155,22 +171,32 @@ export class PeoplePanel { } } - show(schoolId: number): void { + get selectedPersonId(): string | null { + return this.selectedId; + } + + show(schoolId: number, restore: PeopleShowOptions = {}): void { this.schoolId = schoolId; this.cardHost.attach(schoolId); - this.sort = 'surname'; - this.dir = 'asc'; - this.page = 1; - this.selectedId = null; - this.roleSelect.value = ''; - this.yearSelect.value = ''; - this.letterSelect.value = ''; - this.positionSelect.value = ''; - this.sexSelect.value = ''; - this.ageMinInput.value = ''; - this.ageMaxInput.value = ''; + const saved = loadChrome(schoolId).people; + this.sort = saved.sort; + this.dir = saved.dir; + this.page = saved.page; + this.role = saved.role; + this.year = saved.year; + this.letter = saved.letter; + this.position = saved.position; + this.sex = saved.sex; + this.ageMin = saved.ageMin; + this.ageMax = saved.ageMax; + this.selectedId = restore.selectedId !== undefined ? restore.selectedId : null; + this.applyFixedFilters(); this.paintCard(null); - void this.reload(); + void this.reload().then(() => { + if (this.selectedId !== null && restore.openCard !== false) { + void this.openCard(this.selectedId); + } + }); } refresh(): void { @@ -189,7 +215,15 @@ export class PeoplePanel { } private onFilterChange(): void { + this.role = this.roleSelect.value; + this.year = this.yearSelect.value; + this.letter = this.letterSelect.value; + this.position = this.positionSelect.value; + this.sex = this.sexSelect.value; + this.ageMin = this.ageMinInput.value; + this.ageMax = this.ageMaxInput.value; this.page = 1; + this.persist(); void this.reload(); } @@ -202,9 +236,38 @@ export class PeoplePanel { } this.page = 1; + this.persist(); void this.reload(); } + private applyFixedFilters(): void { + this.roleSelect.value = this.role; + this.sexSelect.value = this.sex; + this.ageMinInput.value = this.ageMin; + this.ageMaxInput.value = this.ageMax; + } + + private persist(): void { + if (this.schoolId === null) { + return; + } + + patchChrome(this.schoolId, { + people: { + role: this.role, + year: this.year, + letter: this.letter, + position: this.position, + sex: this.sex, + ageMin: this.ageMin, + ageMax: this.ageMax, + sort: this.sort, + dir: this.dir, + page: this.page, + }, + }); + } + private async reload(): Promise { const schoolId = this.schoolId; if (schoolId === null) { @@ -216,13 +279,13 @@ export class PeoplePanel { const page = await fetchPeople( schoolId, { - role: (this.roleSelect.value || undefined) as PersonRole | undefined, - year: parseOptionalInt(this.yearSelect.value), - letter: this.letterSelect.value || undefined, - position: this.positionSelect.value || undefined, - sex: (this.sexSelect.value || undefined) as 'male' | 'female' | undefined, - ageMin: parseOptionalInt(this.ageMinInput.value), - ageMax: parseOptionalInt(this.ageMaxInput.value), + role: (this.role || undefined) as PersonRole | undefined, + year: parseOptionalInt(this.year), + letter: this.letter || undefined, + position: this.position || undefined, + sex: (this.sex || undefined) as 'male' | 'female' | undefined, + ageMin: parseOptionalInt(this.ageMin), + ageMax: parseOptionalInt(this.ageMax), sort: this.sort, dir: this.dir, page: this.page, @@ -236,6 +299,7 @@ export class PeoplePanel { this.fillDynamicFilters(page); this.paintRows(page); + this.persist(); } catch { if (token !== this.token) { return; @@ -266,6 +330,20 @@ export class PeoplePanel { page.filters.positions.map((position) => ({ value: position.defName, label: position.label })), t('peoplePositionAll'), ); + this.yearSelect.value = this.year; + if (this.yearSelect.value !== this.year) { + this.year = ''; + } + + this.letterSelect.value = this.letter; + if (this.letterSelect.value !== this.letter) { + this.letter = ''; + } + + this.positionSelect.value = this.position; + if (this.positionSelect.value !== this.position) { + this.position = ''; + } } private paintHeader(): void { @@ -276,7 +354,7 @@ export class PeoplePanel { class: 'people__sort', type: 'button', text: t(column.label), - onClick: () => this.onSort(column.sort === 'year' && this.roleSelect.value === 'staff' ? 'position' : column.sort), + onClick: () => this.onSort(column.sort === 'year' && this.role === 'staff' ? 'position' : column.sort), }); button.setAttribute( 'aria-sort', @@ -346,7 +424,6 @@ 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.ts b/src/HSchool.Client/src/ui/personCard.ts index 6a3141c..9bf7cdb 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -32,6 +32,8 @@ export interface RenderPersonCardOptions { readonly onLogSearch?: (query: string) => void; readonly onLogDir?: (dir: PersonLogDir) => void; readonly onLogPage?: (page: number) => void; + readonly connectionsQuery?: string; + readonly onConnectionsSearch?: (query: string) => void; readonly schoolId?: number | null; readonly onGeneratePortrait?: (kind: PortraitKind, promptExtra?: string) => void; readonly portraitBusy?: PortraitKind | null; @@ -162,7 +164,7 @@ export function renderPersonCard( fillApparel(panels.apparel, card); fillCarry(panels.carry, card); fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options); - fillConnections(panels.connections, card, onRelative); + fillConnections(panels.connections, card, onRelative, options); fillPortrait(panels.portrait, card, options); const showTab = (next: PersonCardTab): void => { @@ -277,7 +279,12 @@ function fillCarry(parent: HTMLElement, card: PersonCard): void { parent.append(grid ); } -function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void { +function fillConnections( + parent: HTMLElement, + card: PersonCard, + onRelative: (id: string) => void, + options: RenderPersonCardOptions, +): void { const connections = card.connections; if (connections === null) { return; @@ -314,6 +321,7 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: const search = el('input', { class: 'input people__input', type: 'search' }); search.placeholder = t('peopleConnectionsSearch'); + search.value = options.connectionsQuery ?? ''; const list = el('div', { class: 'people__connections-list' }); const renderMatches = (): void => { clear(list); @@ -329,7 +337,10 @@ function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: appendOpinionLinks(list, '', matches, onRelative, false); }; - search.addEventListener('input', renderMatches); + search.addEventListener('input', () => { + options.onConnectionsSearch?.(search.value); + renderMatches(); + }); parent.append(search, list); renderMatches(); } diff --git a/src/HSchool.Client/src/ui/personCardHost.ts b/src/HSchool.Client/src/ui/personCardHost.ts index af9b5be..a8b8964 100644 --- a/src/HSchool.Client/src/ui/personCardHost.ts +++ b/src/HSchool.Client/src/ui/personCardHost.ts @@ -21,6 +21,7 @@ import { renderPersonCard, type PersonCardTab, } from './personCard.ts'; +import { loadChrome, patchChrome } from './viewState.ts'; /** Shared card tab state, portrait generation, Swarm availability and the day log. */ export class PersonCardHost { @@ -46,9 +47,19 @@ export class PersonCardHost { private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' }; private logPage: PersonLogPage | null = null; private logToken = 0; + private connectionsQuery = ''; attach(schoolId: number): void { this.schoolId = schoolId; + const saved = loadChrome(schoolId).personCard; + this.tab = saved.tab; + this.connectionsQuery = saved.connectionsQ; + this.logQuery = { + page: saved.logPage, + pageSize: 20, + dir: saved.logDir, + q: saved.logQ.length > 0 ? saved.logQ : undefined, + }; void this.refreshSwarmStatus(); } @@ -87,6 +98,7 @@ export class PersonCardHost { this.tab = 'overview'; this.logPage = null; this.logQuery = { page: 1, pageSize: 20, dir: 'desc' }; + this.connectionsQuery = ''; } paint( @@ -137,6 +149,7 @@ export class PersonCardHost { logQuery: this.logQuery, onTab: (tab) => { this.tab = tab; + this.persistCard(); if (tab === 'now') { void this.loadLog(); } else if (tab === 'portrait') { @@ -147,16 +160,24 @@ export class PersonCardHost { }, onLogSearch: (q) => { this.logQuery = { ...this.logQuery, q, page: 1 }; + this.persistCard(); void this.loadLog(); }, onLogDir: (dir: PersonLogDir) => { this.logQuery = { ...this.logQuery, dir, page: 1 }; + this.persistCard(); void this.loadLog(); }, onLogPage: (page) => { this.logQuery = { ...this.logQuery, page }; + this.persistCard(); void this.loadLog(); }, + connectionsQuery: this.connectionsQuery, + onConnectionsSearch: (query) => { + this.connectionsQuery = query; + this.persistCard(); + }, schoolId: this.schoolId, onGeneratePortrait: (kind, promptExtra) => void this.generate(kind, promptExtra), portraitBusy: this.portraitBusy, @@ -319,4 +340,20 @@ export class PersonCardHost { now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'), names); } } + + private persistCard(): void { + if (this.schoolId === null) { + return; + } + + patchChrome(this.schoolId, { + personCard: { + tab: this.tab, + logQ: this.logQuery.q ?? '', + logDir: this.logQuery.dir ?? 'desc', + logPage: this.logQuery.page ?? 1, + connectionsQ: this.connectionsQuery, + }, + }); + } } diff --git a/src/HSchool.Client/src/ui/viewState.test.ts b/src/HSchool.Client/src/ui/viewState.test.ts new file mode 100644 index 0000000..ede5360 --- /dev/null +++ b/src/HSchool.Client/src/ui/viewState.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it } from 'vitest'; +import type { SchoolsResponse } from '../net/api.ts'; +import { + DEFAULT_CHROME, + chromeKey, + formatSearch, + loadChrome, + parseRoute, + patchChrome, + saveChrome, + schoolFromList, + writeRoute, +} from './viewState.ts'; + +const list: SchoolsResponse = { + maxSchools: 2, + maxSchoolsTotal: 6, + defaultStartDate: '2012-03-31T00:00:00.000Z', + gameMinutesPerRealSecond: 1, + schoolWeekDays: 6, + schools: [ + { + id: 3, + name: 'Mine', + gameTime: '2012-03-31T06:00:00.000Z', + running: true, + speedIndex: 1, + seed: 9, + mine: true, + }, + ], + others: [ + { + id: 8, + name: 'Theirs', + gameTime: '2012-03-31T06:00:00.000Z', + running: false, + speedIndex: 1, + seed: 2, + owner: 'Ada', + }, + ], +}; + +afterEach(() => { + sessionStorage.clear(); + history.replaceState(null, '', '/'); +}); + +describe('parseRoute / formatSearch', () => { + it('reads school, mode, tab, node, person and inspect from the query', () => { + expect( + parseRoute('?school=3&mode=manage&tab=people&node=room-1&person=p1&inspect=person'), + ).toEqual({ + schoolId: 3, + mode: 'manage', + tab: 'people', + nodeId: 'room-1', + personId: 'p1', + inspect: 'person', + }); + }); + + it('falls back to the menu and overview when school or mode is garbage', () => { + expect(parseRoute('?school=-1&mode=debug&tab=inventory')).toEqual({ + schoolId: null, + mode: 'overview', + tab: 'map', + nodeId: null, + personId: null, + inspect: 'location', + }); + expect(parseRoute('')).toMatchObject({ schoolId: null, mode: 'overview' }); + }); + + it('treats a person without inspect as inspecting the person', () => { + expect(parseRoute('?school=1&person=p1').inspect).toBe('person'); + }); + + it('omits default fields when writing the query', () => { + expect( + formatSearch({ + schoolId: 3, + mode: 'overview', + tab: 'map', + nodeId: null, + personId: null, + inspect: 'location', + }), + ).toBe('?school=3'); + expect( + formatSearch({ + schoolId: null, + mode: 'manage', + tab: 'people', + nodeId: 'n', + personId: 'p', + inspect: 'person', + }), + ).toBe(''); + }); + + it('round-trips a full route through the query string', () => { + const route = { + schoolId: 4, + mode: 'manage' as const, + tab: 'people' as const, + nodeId: 'hall', + personId: 'p2', + inspect: 'person' as const, + }; + expect(parseRoute(formatSearch(route))).toEqual(route); + }); +}); + +describe('schoolFromList', () => { + it('returns a owned school, a guest copy, or null', () => { + expect(schoolFromList(3, list)?.mine).toBe(true); + expect(schoolFromList(8, list)).toEqual({ ...list.others[0], mine: false }); + expect(schoolFromList(99, list)).toBeNull(); + }); +}); + +describe('chrome storage', () => { + it('writes and reads per school', () => { + patchChrome(3, { people: { role: 'student', year: '5', page: 2 } }); + patchChrome(8, { people: { role: 'staff' } }); + expect(loadChrome(3).people).toMatchObject({ role: 'student', year: '5', page: 2 }); + expect(loadChrome(8).people.role).toBe('staff'); + expect(loadChrome(3).people.role).toBe('student'); + expect(sessionStorage.getItem(chromeKey(3))).toContain('student'); + }); + + it('returns defaults for missing or broken JSON', () => { + expect(loadChrome(1)).toEqual(DEFAULT_CHROME); + sessionStorage.setItem(chromeKey(1), '{'); + expect(loadChrome(1)).toEqual(DEFAULT_CHROME); + saveChrome(1, DEFAULT_CHROME); + sessionStorage.setItem(chromeKey(1), '{"people":{"role":"nope","page":0}}'); + expect(loadChrome(1).people).toMatchObject({ role: '', page: 1 }); + }); +}); + +describe('writeRoute', () => { + it('replaceState puts the school on the query without a new history entry', () => { + writeRoute( + { + schoolId: 3, + mode: 'overview', + tab: 'people', + nodeId: null, + personId: 'p1', + inspect: 'person', + }, + 'replace', + ); + expect(location.search).toBe('?school=3&tab=people&person=p1&inspect=person'); + }); +}); diff --git a/src/HSchool.Client/src/ui/viewState.ts b/src/HSchool.Client/src/ui/viewState.ts new file mode 100644 index 0000000..dce132e --- /dev/null +++ b/src/HSchool.Client/src/ui/viewState.ts @@ -0,0 +1,378 @@ +import type { PersonLogDir, PersonSort, School, SchoolsResponse } from '../net/api.ts'; +import type { PersonCardTab } from './personCard.ts'; + +export type ScreenMode = 'overview' | 'manage'; +export type LeftTab = 'map' | 'people'; +export type InspectWhat = 'location' | 'person'; +export type ManageTab = 'staff' | 'rules'; +export type ApplicantSort = 'name' | 'age' | 'ask'; +export type HistoryMode = 'push' | 'replace'; + +export interface RouteState { + readonly schoolId: number | null; + readonly mode: ScreenMode; + readonly tab: LeftTab; + readonly nodeId: string | null; + readonly personId: string | null; + readonly inspect: InspectWhat; +} + +export interface RouteChrome { + readonly mode: ScreenMode; + readonly tab: LeftTab; + readonly nodeId: string | null; + readonly personId: string | null; + readonly inspect: InspectWhat; +} + +export interface PeopleChrome { + readonly role: string; + readonly year: string; + readonly letter: string; + readonly position: string; + readonly sex: string; + readonly ageMin: string; + readonly ageMax: string; + readonly sort: PersonSort; + readonly dir: 'asc' | 'desc'; + readonly page: number; +} + +export interface ApplicantsChrome { + readonly search: string; + readonly sex: string; + readonly kind: string; + readonly ageMin: string; + readonly ageMax: string; + readonly sort: ApplicantSort; + readonly dir: 'asc' | 'desc'; +} + +export interface PersonCardChrome { + readonly tab: PersonCardTab; + readonly logQ: string; + readonly logDir: PersonLogDir; + readonly logPage: number; + readonly connectionsQ: string; +} + +export interface ManagementChrome { + readonly tab: ManageTab; + readonly classId: string | null; + readonly selectedId: string | null; +} + +export interface SchoolChrome { + readonly route: RouteChrome; + readonly people: PeopleChrome; + readonly applicants: ApplicantsChrome; + readonly personCard: PersonCardChrome; + readonly management: ManagementChrome; +} + +export interface ChromePatch { + readonly route?: Partial; + readonly people?: Partial; + readonly applicants?: Partial; + readonly personCard?: Partial; + readonly management?: Partial; +} + +const STORAGE_PREFIX = 'h-school.view.'; + +const MODES: readonly ScreenMode[] = ['overview', 'manage']; +const TABS: readonly LeftTab[] = ['map', 'people']; +const INSPECT: readonly InspectWhat[] = ['location', 'person']; +const MANAGE_TABS: readonly ManageTab[] = ['staff', 'rules']; +const PERSON_SORTS: readonly PersonSort[] = ['surname', 'age', 'year', 'position']; +const APPLICANT_SORTS: readonly ApplicantSort[] = ['name', 'age', 'ask']; +const DIRS: readonly ('asc' | 'desc')[] = ['asc', 'desc']; +const CARD_TABS: readonly PersonCardTab[] = [ + 'overview', + 'apparel', + 'carry', + 'now', + 'connections', + 'portrait', +]; +const LOG_DIRS: readonly PersonLogDir[] = ['asc', 'desc']; +const ROLES = ['student', 'staff', 'parent'] as const; +const SEXES = ['male', 'female'] as const; +const APPLICANT_KINDS = ['parent', 'other'] as const; + +export const DEFAULT_ROUTE_CHROME: RouteChrome = { + mode: 'overview', + tab: 'map', + nodeId: null, + personId: null, + inspect: 'location', +}; + +export const MENU_ROUTE: RouteState = { + schoolId: null, + ...DEFAULT_ROUTE_CHROME, +}; + +export const DEFAULT_PEOPLE: PeopleChrome = { + role: '', + year: '', + letter: '', + position: '', + sex: '', + ageMin: '', + ageMax: '', + sort: 'surname', + dir: 'asc', + page: 1, +}; + +export const DEFAULT_APPLICANTS: ApplicantsChrome = { + search: '', + sex: '', + kind: '', + ageMin: '', + ageMax: '', + sort: 'name', + dir: 'asc', +}; + +export const DEFAULT_PERSON_CARD: PersonCardChrome = { + tab: 'overview', + logQ: '', + logDir: 'desc', + logPage: 1, + connectionsQ: '', +}; + +export const DEFAULT_MANAGEMENT: ManagementChrome = { + tab: 'staff', + classId: null, + selectedId: null, +}; + +export const DEFAULT_CHROME: SchoolChrome = { + route: DEFAULT_ROUTE_CHROME, + people: DEFAULT_PEOPLE, + applicants: DEFAULT_APPLICANTS, + personCard: DEFAULT_PERSON_CARD, + management: DEFAULT_MANAGEMENT, +}; + +export function chromeKey(schoolId: number): string { + return `${STORAGE_PREFIX}${schoolId}`; +} + +export function parseRoute(search: string): RouteState { + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + const schoolId = parseSchoolId(params.get('school')); + const personId = nonempty(params.get('person')); + const inspectRaw = pick(params.get('inspect'), INSPECT, personId !== null ? 'person' : 'location'); + return { + schoolId, + mode: pick(params.get('mode'), MODES, 'overview'), + tab: pick(params.get('tab'), TABS, 'map'), + nodeId: nonempty(params.get('node')), + personId, + inspect: personId === null && inspectRaw === 'person' ? 'location' : inspectRaw, + }; +} + +export function formatSearch(route: RouteState): string { + if (route.schoolId === null) { + return ''; + } + + const params = new URLSearchParams(); + params.set('school', String(route.schoolId)); + if (route.mode !== 'overview') { + params.set('mode', route.mode); + } + + if (route.tab !== 'map') { + params.set('tab', route.tab); + } + + if (route.nodeId !== null && route.nodeId.length > 0) { + params.set('node', route.nodeId); + } + + if (route.personId !== null && route.personId.length > 0) { + params.set('person', route.personId); + } + + if (route.inspect !== 'location') { + params.set('inspect', route.inspect); + } + + const query = params.toString(); + return query.length > 0 ? `?${query}` : ''; +} + +export function writeRoute(route: RouteState, mode: HistoryMode): void { + const next = `${location.pathname}${formatSearch(route)}${location.hash}`; + if (mode === 'push') { + history.pushState(null, '', next); + } else { + history.replaceState(null, '', next); + } +} + +export function schoolFromList(id: number, list: SchoolsResponse): School | null { + const mine = list.schools.find((row) => row.id === id); + if (mine !== undefined) { + return mine; + } + + const other = list.others.find((row) => row.id === id); + return other === undefined ? null : { ...other, mine: false }; +} + +export function loadChrome(schoolId: number): SchoolChrome { + try { + const raw = globalThis.sessionStorage?.getItem(chromeKey(schoolId)); + if (raw === null || raw === undefined) { + return DEFAULT_CHROME; + } + + return parseChrome(JSON.parse(raw)); + } catch { + return DEFAULT_CHROME; + } +} + +export function patchChrome(schoolId: number, patch: ChromePatch): void { + const current = loadChrome(schoolId); + saveChrome(schoolId, { + route: { ...current.route, ...patch.route }, + people: { ...current.people, ...patch.people }, + applicants: { ...current.applicants, ...patch.applicants }, + personCard: { ...current.personCard, ...patch.personCard }, + management: { ...current.management, ...patch.management }, + }); +} + +export function saveChrome(schoolId: number, chrome: SchoolChrome): void { + try { + globalThis.sessionStorage?.setItem(chromeKey(schoolId), JSON.stringify(chrome)); + } catch { + // Private mode, or tests without a store — RAM still has the live widgets. + } +} + +function parseChrome(value: unknown): SchoolChrome { + if (value === null || typeof value !== 'object') { + return DEFAULT_CHROME; + } + + const row = value as Record; + return { + route: parseRouteChrome(row.route), + people: parsePeople(row.people), + applicants: parseApplicants(row.applicants), + personCard: parsePersonCard(row.personCard), + management: parseManagement(row.management), + }; +} + +function parseRouteChrome(value: unknown): RouteChrome { + const row = asRecord(value); + const personId = optionalString(row?.personId); + const inspect = pick(row?.inspect, INSPECT, personId !== null ? 'person' : 'location'); + return { + mode: pick(row?.mode, MODES, 'overview'), + tab: pick(row?.tab, TABS, 'map'), + nodeId: optionalString(row?.nodeId), + personId, + inspect: personId === null && inspect === 'person' ? 'location' : inspect, + }; +} + +function parsePeople(value: unknown): PeopleChrome { + const row = asRecord(value); + return { + role: oneOf(row?.role, ROLES, ''), + year: stringField(row?.year), + letter: stringField(row?.letter), + position: stringField(row?.position), + sex: oneOf(row?.sex, SEXES, ''), + ageMin: stringField(row?.ageMin), + ageMax: stringField(row?.ageMax), + sort: pick(row?.sort, PERSON_SORTS, 'surname'), + dir: pick(row?.dir, DIRS, 'asc'), + page: positiveInt(row?.page, 1), + }; +} + +function parseApplicants(value: unknown): ApplicantsChrome { + const row = asRecord(value); + return { + search: stringField(row?.search), + sex: oneOf(row?.sex, SEXES, ''), + kind: oneOf(row?.kind, APPLICANT_KINDS, ''), + ageMin: stringField(row?.ageMin), + ageMax: stringField(row?.ageMax), + sort: pick(row?.sort, APPLICANT_SORTS, 'name'), + dir: pick(row?.dir, DIRS, 'asc'), + }; +} + +function parsePersonCard(value: unknown): PersonCardChrome { + const row = asRecord(value); + return { + tab: pick(row?.tab, CARD_TABS, 'overview'), + logQ: stringField(row?.logQ), + logDir: pick(row?.logDir, LOG_DIRS, 'desc'), + logPage: positiveInt(row?.logPage, 1), + connectionsQ: stringField(row?.connectionsQ), + }; +} + +function parseManagement(value: unknown): ManagementChrome { + const row = asRecord(value); + return { + tab: pick(row?.tab, MANAGE_TABS, 'staff'), + classId: optionalString(row?.classId), + selectedId: optionalString(row?.selectedId), + }; +} + +function parseSchoolId(value: string | null): number | null { + if (value === null || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function pick(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value) + ? (value as T) + : fallback; +} + +function oneOf(value: unknown, allowed: readonly T[], fallback: T | ''): T | '' { + return typeof value === 'string' && (allowed as readonly string[]).includes(value) + ? (value as T) + : fallback; +} + +function nonempty(value: string | null): string | null { + return value !== null && value.length > 0 ? value : null; +} + +function optionalString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function stringField(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function positiveInt(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' ? (value as Record) : null; +}