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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: ['Иванова'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user