- 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.
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
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 }));
|
||
}
|
||
}
|