Update wire protocol to version 6 and enhance timetable functionality
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s

- 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.
This commit is contained in:
Leonid Pershin
2026-08-19 10:05:05 +03:00
parent 2011d12b1d
commit cad3068bac
30 changed files with 1621 additions and 43 deletions
+77 -2
View File
@@ -1,6 +1,6 @@
/**
* HTTP side of the server: the main menu, the in-school people list/card, and staffing. The
* realtime clock arrives over the WebSocket instead — see `connection.ts`.
* HTTP side of the server: the main menu, the in-school people list/card, staffing and the
* timetable. The realtime clock arrives over the WebSocket instead — see `connection.ts`.
*/
export interface School {
@@ -383,6 +383,81 @@ export async function unassignSubject(
);
}
export interface TimetableLesson {
readonly classId: string;
readonly classYear: number;
readonly classLetter: string;
readonly subject: string;
readonly subjectLabel: string;
readonly teacherId: string;
readonly teacherName: string;
readonly roomId: string;
readonly day: number;
readonly period: number;
readonly locked: boolean;
}
export interface UncoveredLesson {
readonly classId: string;
readonly classYear: number;
readonly classLetter: string;
readonly subject: string;
readonly subjectLabel: string;
readonly hours: number;
}
export interface Timetable {
readonly weekDays: number;
readonly lessonCount: number;
readonly lessons: readonly TimetableLesson[];
readonly uncovered: readonly UncoveredLesson[];
}
export async function fetchTimetable(
schoolId: number,
lang: string,
filters: { classId?: string; personId?: string } = {},
): Promise<Timetable> {
const params = new URLSearchParams({ lang });
if (filters.classId) {
params.set('classId', filters.classId);
}
if (filters.personId) {
params.set('personId', filters.personId);
}
return request<Timetable>(`/api/schools/${schoolId}/timetable?${params.toString()}`);
}
export async function pinLesson(
schoolId: number,
lesson: { classId: string; subject: string; roomId: string; day: number; period: number },
lang: string,
): Promise<Timetable> {
const params = new URLSearchParams({ lang });
return request<Timetable>(`/api/schools/${schoolId}/timetable/pin?${params.toString()}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(lesson),
});
}
export async function unpinLesson(
schoolId: number,
lesson: { classId: string; subject: string; day: number; period: number },
lang: string,
): Promise<Timetable> {
const params = new URLSearchParams({ lang });
params.set('classId', lesson.classId);
params.set('subject', lesson.subject);
params.set('day', String(lesson.day));
params.set('period', String(lesson.period));
return request<Timetable>(`/api/schools/${schoolId}/timetable/pin?${params.toString()}`, {
method: 'DELETE',
});
}
async function request<T>(
url: string,
init?: RequestInit,
+118 -3
View File
@@ -129,7 +129,7 @@ describe('decodeServerMessage', () => {
const id = encoder.encode('yard');
const parent = encoder.encode('');
const name = encoder.encode('Двор');
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1);
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1 + 1 + 1);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot);
view.setInt32(1, 7, true);
@@ -152,12 +152,27 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot',
schoolId: 7,
nodes: [
{ kind: 0, id: 'yard', parentId: '', name: 'Двор', pupilSlots: 0, items: [], positions: [] },
{
kind: 0,
id: 'yard',
parentId: '',
name: 'Двор',
pupilSlots: 0,
items: [],
positions: [],
activitySubject: '',
activityClass: '',
characters: [],
},
],
});
});
@@ -169,7 +184,7 @@ describe('decodeServerMessage', () => {
const name = encoder.encode('Класс 1A');
const itemName = encoder.encode('Парта');
const buffer = new ArrayBuffer(
7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1,
7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1 + 1 + 1,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot);
@@ -201,6 +216,10 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 16);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot',
@@ -214,6 +233,102 @@ describe('decodeServerMessage', () => {
pupilSlots: 16,
items: [{ name: 'Парта', count: 16 }],
positions: [],
activitySubject: '',
activityClass: '',
characters: [],
},
],
});
});
it('reads occupancy after positions', () => {
const encoder = new TextEncoder();
const id = encoder.encode('classroom-101');
const parent = encoder.encode('floor-1');
const name = encoder.encode('Класс 101');
const itemName = encoder.encode('Парта');
const subject = encoder.encode('Математика');
const schoolClass = encoder.encode('5А');
const teacher = encoder.encode('Иванова');
const buffer = new ArrayBuffer(
7
+ 1
+ 2 + id.length
+ 2 + parent.length
+ 2 + name.length
+ 2
+ 1
+ 2 + itemName.length
+ 1
+ 1
+ 1
+ 2 + subject.length
+ 2 + schoolClass.length
+ 1
+ 2 + teacher.length,
);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot);
view.setInt32(1, 3, true);
view.setUint16(5, 1, true);
let offset = 7;
view.setUint8(offset, 3);
offset += 1;
view.setUint16(offset, id.length, true);
offset += 2;
new Uint8Array(buffer).set(id, offset);
offset += id.length;
view.setUint16(offset, parent.length, true);
offset += 2;
new Uint8Array(buffer).set(parent, offset);
offset += parent.length;
view.setUint16(offset, name.length, true);
offset += 2;
new Uint8Array(buffer).set(name, offset);
offset += name.length;
view.setUint16(offset, 16, true);
offset += 2;
view.setUint8(offset, 1);
offset += 1;
view.setUint16(offset, itemName.length, true);
offset += 2;
new Uint8Array(buffer).set(itemName, offset);
offset += itemName.length;
view.setUint8(offset, 16);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 1);
offset += 1;
view.setUint16(offset, subject.length, true);
offset += 2;
new Uint8Array(buffer).set(subject, offset);
offset += subject.length;
view.setUint16(offset, schoolClass.length, true);
offset += 2;
new Uint8Array(buffer).set(schoolClass, offset);
offset += schoolClass.length;
view.setUint8(offset, 1);
offset += 1;
view.setUint16(offset, teacher.length, true);
offset += 2;
new Uint8Array(buffer).set(teacher, offset);
expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot',
schoolId: 3,
nodes: [
{
kind: 3,
id: 'classroom-101',
parentId: 'floor-1',
name: 'Класс 101',
pupilSlots: 16,
items: [{ name: 'Парта', count: 16 }],
positions: [],
activitySubject: 'Математика',
activityClass: '5А',
characters: ['Иванова'],
},
],
});
+29 -1
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/
export const PROTOCOL_VERSION = 5;
export const PROTOCOL_VERSION = 6;
export const MessageType = {
ClientHello: 0x01,
@@ -82,6 +82,9 @@ export interface MapSnapshotNode {
readonly pupilSlots: number;
readonly items: readonly MapSnapshotItem[];
readonly positions: readonly string[];
readonly activitySubject: string;
readonly activityClass: string;
readonly characters: readonly string[];
}
export interface MapSnapshotMessage {
@@ -265,6 +268,28 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
offset = value.next;
}
const hasActivity = readU8(view, offset);
offset += 1;
let activitySubject = '';
let activityClass = '';
if (hasActivity !== 0) {
const subject = readString(view, offset);
offset = subject.next;
const schoolClass = readString(view, offset);
offset = schoolClass.next;
activitySubject = subject.text;
activityClass = schoolClass.text;
}
const characterCount = readU8(view, offset);
offset += 1;
const characters: string[] = [];
for (let person = 0; person < characterCount; person++) {
const value = readString(view, offset);
characters.push(value.text);
offset = value.next;
}
nodes.push({
kind,
id: id.text,
@@ -273,6 +298,9 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
pupilSlots,
items,
positions,
activitySubject,
activityClass,
characters,
});
}
+14 -2
View File
@@ -43,8 +43,10 @@ export class GameScreen {
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' });
@@ -118,8 +120,8 @@ export class GameScreen {
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),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
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),
);
@@ -181,6 +183,10 @@ export class GameScreen {
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;
@@ -281,6 +287,12 @@ export class GameScreen {
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 ?? []);
}