Merge branch 'phase/55-view-state'
ci / server (push) Failing after 3m39s
ci / client (push) Successful in 20s

# Conflicts:
#	docs/phases/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 12:10:55 +03:00
16 changed files with 1104 additions and 62 deletions
+3
View File
@@ -208,3 +208,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.
+32
View File
@@ -144,3 +144,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 внутри школы — та же школа, те же вкладки и фильтры. Назад — меню. Снова открыть ту же
школу — не сброс на карту. Гость не входит в управление по ссылке. Скорость часов, пауза,
открытый пул соискателей и мастер создания не вспоминаются.
### Что не входит
Поиск по имени в списках, где его нет. Роутер-библиотека. Состояние на сервере. Бамп протокола.
+46
View File
@@ -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` проходит на затронутых тестах
## Стоп
Не класть состояние на сервер. Не бампить протокол. Не добавлять поиск по имени в списки, где
его нет. Не держать открытым диалог соискателей и мастер создания. Не трогать скорость часов и
паузу — они уже на сервере.
+2 -1
View File
@@ -261,7 +261,7 @@
Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят с именованными
срезами. После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно
параллельно с 48. 49 и 54 от них не зависят. Баг в уже сделанном —
параллельно с 48. 49, 54 и 55 от них не зависят. Баг в уже сделанном —
[`../bugs/README.md`](../bugs/README.md).
| Фаза | Статус | Зачем |
@@ -273,3 +273,4 @@
| [52. Учебник на уроке](52-textbook-lesson.md) | ✅ | Нет в сумке — половинный рост |
| [53. Погода на дороге](53-weather-commute.md) | ✅ | Снег и дождь добавляют минуты к приходу |
| [54. Что нового](54-whats-new.md) | ✅ | После входа — окно коммитов с прошлого визита |
| [55. Состояние экрана](55-view-state.md) | ✅ | F5 и переходы не сбрасывают школу, вкладки и фильтры |
+59 -8
View File
@@ -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<ConnectionStatus, MessageKey> = {
@@ -67,7 +68,7 @@ async function bootstrap(): Promise<void> {
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<void> {
}
}
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<boolean> {
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<void> {
connection.close();
menu.stop();
@@ -113,7 +137,7 @@ async function bootstrap(): Promise<void> {
await ensureSession();
await showWhatsNew();
connection.connect();
showMenu();
showMenu('replace');
}
onLocaleChange(() => {
@@ -125,7 +149,34 @@ async function bootstrap(): Promise<void> {
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());
}
+49 -8
View File
@@ -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<void> {
this.cardHost.resetTabs();
try {
const card = await fetchPerson(this.options.schoolId, personId, getLocale());
this.paintCard(card);
+74 -1
View File
@@ -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<typeof import('../net/api.ts')>();
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> = {}): 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<HTMLElement>('.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');
});
});
+83 -16
View File
@@ -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<HTMLElement>('.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');
}
@@ -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<typeof import('../net/api.ts')>();
@@ -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);
});
});
+24 -3
View File
@@ -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<void> {
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,
},
});
}
}
@@ -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<typeof import('../net/api.ts')>();
@@ -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 });
});
});
+99 -22
View File
@@ -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<void> {
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());
+14 -3
View File
@@ -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();
}
@@ -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,
},
});
}
}
+162
View File
@@ -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');
});
});
+378
View File
@@ -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<RouteChrome>;
readonly people?: Partial<PeopleChrome>;
readonly applicants?: Partial<ApplicantsChrome>;
readonly personCard?: Partial<PersonCardChrome>;
readonly management?: Partial<ManagementChrome>;
}
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<string, unknown>;
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<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback;
}
function oneOf<T extends string>(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<string, unknown> | null {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}