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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 внутри школы — та же школа, те же вкладки и фильтры. Назад — меню. Снова открыть ту же
|
||||
школу — не сброс на карту. Гость не входит в управление по ссылке. Скорость часов, пауза,
|
||||
открытый пул соискателей и мастер создания не вспоминаются.
|
||||
|
||||
### Что не входит
|
||||
|
||||
Поиск по имени в списках, где его нет. Роутер-библиотека. Состояние на сервере. Бамп протокола.
|
||||
|
||||
@@ -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` проходит на затронутых тестах
|
||||
|
||||
## Стоп
|
||||
|
||||
Не класть состояние на сервер. Не бампить протокол. Не добавлять поиск по имени в списки, где
|
||||
его нет. Не держать открытым диалог соискателей и мастер создания. Не трогать скорость часов и
|
||||
паузу — они уже на сервере.
|
||||
@@ -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 и переходы не сбрасывают школу, вкладки и фильтры |
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user