Files
h-school/src/HSchool.Client/src/ui/gameScreen.ts
T
Leonid Pershin cad3068bac
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s
Update wire protocol to version 6 and enhance timetable functionality
- Bumped the wire protocol version to 6, reflecting changes in the communication structure.
- Expanded the timetable API with new endpoints for fetching and managing lesson schedules, including `GET /api/schools/{id}/timetable` and `POST /api/schools/{id}/timetable/pin`.
- Updated the protocol documentation to include detailed descriptions of the new timetable features and message structures.
- Enhanced the client-side implementation to support the new timetable functionalities, including lesson pinning and unpinning.
- Revised server-side logic to handle timetable operations and ensure proper integration with existing school management features.
- Added tests to validate the new timetable functionalities and ensure robustness in handling lesson data.
2026-08-19 10:05:05 +03:00

334 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotItem, type MapSnapshotNode } from '../net/protocol.ts';
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import { t } from '../i18n/strings.ts';
import type { School } from '../net/api.ts';
import { clear, el } from './dom.ts';
import { ManagementPanel } from './managementPanel.ts';
import { PeoplePanel } from './peoplePanel.ts';
interface GameScreenOptions {
readonly onLeave: () => void;
readonly onSetRunning: (running: boolean) => void;
readonly onSetSpeed: (speedIndex: number) => void;
}
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
/**
* The inside of a school: calendar controls plus the manager shell. The tree and location
* lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot
* on the client. The people panel loads its page over HTTP.
*/
export class GameScreen {
private readonly root = el('section', { class: 'screen game' });
private readonly backButton = el('button', { class: 'button', type: 'button' });
private readonly schoolName = el('h1', { class: 'screen__title' });
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' });
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly speedButtons: HTMLButtonElement[];
private readonly mapTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly peopleTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly tree = el('ul', { class: 'tree' });
private readonly mapBody = el('div', { class: 'panel__body' });
private readonly peopleBody = el('div', { class: 'panel__body panel__body--fill' });
private readonly inspectTitle = el('h2', { class: 'panel__title' });
private readonly locationBody = el('div', { class: 'panel__body' });
private readonly locationName = el('p', { class: 'panel__name' });
private readonly itemsHeading = el('h3', { class: 'panel__section-title' });
private readonly itemsEmpty = el('p', { class: 'panel__empty' });
private readonly itemsList = el('ul', { class: 'panel__list' });
private readonly pupilSlotsLine = el('p', { class: 'panel__meta' });
private readonly charactersHeading = el('h3', { class: 'panel__section-title' });
private readonly charactersEmpty = el('p', { class: 'panel__empty' });
private readonly charactersList = el('ul', { class: 'panel__list' });
private readonly activitiesHeading = el('h3', { class: 'panel__section-title' });
private readonly activitiesEmpty = el('p', { class: 'panel__empty' });
private readonly activitiesList = el('ul', { class: 'panel__list' });
private readonly positionsHeading = el('h3', { class: 'panel__section-title' });
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 management = new ManagementPanel();
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' });
private readonly overview = el('div', { class: 'manager' });
private readonly manage = el('div', { class: 'manager' });
private readonly manageListTitle = el('h2', { class: 'panel__title' });
private readonly manageCardTitle = el('h2', { class: 'panel__title' });
private readonly treeButtons = new Map<string, HTMLButtonElement>();
private nodes: readonly MapSnapshotNode[] = [];
private selectedId: string | null = null;
private schoolId: number | null = null;
private running = false;
private lastGameTime: Date | null = null;
private lastSpeedIndex = 0;
private inspected: 'location' | 'person' = 'location';
constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', {
class: 'button button--small',
type: 'button',
text: SPEED_LABELS[index] ?? `×${CLOCK_SPEEDS[index]}`,
onClick: () => options.onSetSpeed(index),
}),
);
this.backButton.addEventListener('click', options.onLeave);
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
this.root.append(
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el(
'div',
{ class: 'clockbar' },
el(
'div',
{ class: 'clockbar__now' },
this.time,
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
),
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
),
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
this.overview,
this.manage,
);
this.overview.append(
el(
'section',
{ class: 'panel' },
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
this.mapBody,
this.peopleBody,
),
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
);
this.manage.append(
el('section', { class: 'panel' }, this.manageListTitle, this.management.listElement),
el('section', { class: 'panel' }, this.manageCardTitle, this.management.cardElement),
);
this.mapBody.append(this.tree);
this.peopleBody.append(this.people.listElement);
this.locationBody.append(
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty, this.charactersList),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty, this.activitiesList),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
);
this.mapTab.addEventListener('click', () => this.showTab('map'));
this.peopleTab.addEventListener('click', () => this.showTab('people'));
this.overviewTab.addEventListener('click', () => this.showMode('overview'));
this.manageTab.addEventListener('click', () => this.showMode('manage'));
this.showTab('map');
this.inspect('location');
this.showMode('overview');
this.localize();
}
get element(): HTMLElement {
return this.root;
}
localize(): void {
this.backButton.textContent = t('backToMenu');
this.overviewTab.textContent = t('modeOverview');
this.manageTab.textContent = t('modeManage');
this.manageListTitle.textContent = t('modeManage');
this.manageCardTitle.textContent = t('personTitle');
this.mapTab.textContent = t('mapTitle');
this.peopleTab.textContent = t('peopleTitle');
this.paintInspectTitle();
this.itemsHeading.textContent = t('locationItems');
this.itemsEmpty.textContent = t('itemsEmpty');
this.charactersHeading.textContent = t('locationCharacters');
this.charactersEmpty.textContent = t('charactersEmpty');
this.activitiesHeading.textContent = t('locationActivities');
this.activitiesEmpty.textContent = t('activitiesEmpty');
this.positionsHeading.textContent = t('locationPositions');
this.positionsEmpty.textContent = t('positionsEmpty');
this.people.localize();
this.management.localize();
this.paintSelection();
if (this.lastGameTime !== null) {
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex);
} else {
this.playPauseButton.title = t('resume');
}
}
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
show(school: School): void {
this.schoolId = school.id;
this.schoolName.textContent = school.name;
this.nodes = [];
this.selectedId = null;
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
this.people.show(school.id);
this.showTab('map');
this.inspect('location');
this.showMode('overview');
}
/**
* The server sends the tree on OpenSchool and again when the current lesson slot changes.
* Occupancy is on the snapshot; this screen must not derive who is where from the clock.
*/
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
if (this.schoolId !== schoolId) {
return;
}
this.nodes = nodes;
this.selectedId = this.selectedId !== null && nodes.some((node) => node.id === this.selectedId)
? this.selectedId
: (nodes[0]?.id ?? null);
this.rebuildTree();
this.paintSelection();
}
update(clock: ClockMessage): void {
this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
}
private rebuildTree(): void {
clear(this.tree);
this.treeButtons.clear();
this.buildTree(this.tree, childrenOf(this.nodes, ''), 0);
}
private buildTree(list: HTMLUListElement, nodes: readonly MapSnapshotNode[], depth: number): void {
for (const node of nodes) {
const item = el('li', { class: 'tree__node' });
const button = el('button', {
class: 'tree__button',
type: 'button',
text: node.name,
onClick: () => this.select(node.id),
});
button.style.paddingLeft = `${8 + depth * 14}px`;
this.treeButtons.set(node.id, button);
item.append(button);
const nestedNodes = childrenOf(this.nodes, node.id);
if (nestedNodes.length > 0) {
const nested = el('ul', { class: 'tree' });
this.buildTree(nested, nestedNodes, depth + 1);
item.append(nested);
}
list.append(item);
}
}
private select(id: string): void {
this.selectedId = id;
this.inspect('location');
this.paintSelection();
}
private showMode(mode: 'overview' | 'manage'): void {
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
this.overview.hidden = mode !== 'overview';
this.manage.hidden = mode !== 'manage';
if (mode === 'manage' && this.schoolId !== null) {
this.management.show(this.schoolId);
}
if (mode === 'overview') {
this.people.refresh();
}
}
private showTab(tab: 'map' | 'people'): void {
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';
}
/**
* 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 {
this.inspected = what;
this.locationBody.hidden = what !== 'location';
this.people.cardElement.hidden = what !== 'person';
this.paintInspectTitle();
}
private paintInspectTitle(): void {
this.inspectTitle.textContent = this.inspected === 'person' ? t('personTitle') : t('locationName');
}
private paintSelection(): void {
for (const [id, button] of this.treeButtons) {
button.classList.toggle('tree__button--active', id === this.selectedId);
}
const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
this.locationName.textContent = node?.name ?? '';
paintList(this.itemsList, this.itemsEmpty, (node?.items ?? []).map(formatItem));
const pupilSlots = node?.pupilSlots ?? 0;
this.pupilSlotsLine.hidden = pupilSlots <= 0;
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
const activity =
node && (node.activitySubject.length > 0 || node.activityClass.length > 0)
? [[node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ')]
: [];
paintList(this.activitiesList, this.activitiesEmpty, activity);
paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []);
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
}
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
this.running = running;
this.lastGameTime = gameTime;
this.lastSpeedIndex = speedIndex;
this.time.textContent = formatGameTimeOfDay(gameTime);
this.date.textContent = formatGameDate(gameTime);
this.weekday.textContent = formatGameWeekday(gameTime);
this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? t('pause') : t('resume');
this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex);
});
}
}
function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSnapshotNode[] {
return nodes.filter((node) => node.parentId === parentId);
}
function formatItem(item: MapSnapshotItem): string {
return item.count === 1 ? item.name : `${item.name} ×${item.count}`;
}
function paintList(list: HTMLUListElement, empty: HTMLParagraphElement, values: readonly string[]): void {
clear(list);
empty.hidden = values.length > 0;
list.hidden = values.length === 0;
for (const value of values) {
list.append(el('li', { text: value }));
}
}