From d61240d5c1336072453a6558a1e6409df5a25050 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 10:45:09 +0300 Subject: [PATCH] Enhance timetable functionality and UI integration - Updated protocol documentation to include new `classId` and `roomLabel` fields in the timetable API responses. - Added classes and rooms to the timetable response structure, improving data accessibility for client applications. - Enhanced the UI components to display timetable information, including class and room details, in the management and people panels. - Implemented functionality to fetch and display personal timetables for individuals, ensuring a comprehensive view of schedules. - Revised localization strings to support new timetable features and improve user experience. - Added tests to validate the new timetable functionalities and ensure robustness in handling timetable data. --- docs/phases/17-timetable-screen.md | 14 +- docs/phases/README.md | 2 +- docs/protocol.md | 14 +- .../src/format/gameTime.test.ts | 8 + src/HSchool.Client/src/format/gameTime.ts | 12 + src/HSchool.Client/src/i18n/strings.test.ts | 2 + src/HSchool.Client/src/i18n/strings.ts | 40 ++ src/HSchool.Client/src/net/api.ts | 15 + src/HSchool.Client/src/style.css | 77 ++++ src/HSchool.Client/src/ui/gameScreen.ts | 20 +- src/HSchool.Client/src/ui/managementPanel.ts | 114 ++++- src/HSchool.Client/src/ui/peoplePanel.ts | 42 +- src/HSchool.Client/src/ui/timetableGrid.ts | 410 ++++++++++++++++++ src/HSchool.Server/Api/PeopleModels.cs | 1 + src/HSchool.Server/Api/TimetableEndpoints.cs | 12 +- src/HSchool.Server/Api/TimetableModels.cs | 40 +- src/HSchool.Server/Game/GameLoopService.cs | 6 +- src/HSchool.Server/Game/PersonCardReader.cs | 1 + src/HSchool.Server/Game/SchoolWorker.cs | 6 + .../TimetableApiTests.cs | 12 +- 20 files changed, 813 insertions(+), 35 deletions(-) create mode 100644 src/HSchool.Client/src/ui/timetableGrid.ts diff --git a/docs/phases/17-timetable-screen.md b/docs/phases/17-timetable-screen.md index 4cb015f..a6057ed 100644 --- a/docs/phases/17-timetable-screen.md +++ b/docs/phases/17-timetable-screen.md @@ -12,14 +12,14 @@ ## Задачи -- [ ] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`. +- [x] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`. Свободная комната остаётся без скобок -- [ ] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса -- [ ] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели -- [ ] В карточке человека — его личное расписание, рядом с навыками и семьёй -- [ ] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие -- [ ] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием -- [ ] Строки через `t(...)`, обе локали +- [x] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса +- [x] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели +- [x] В карточке человека — его личное расписание, рядом с навыками и семьёй +- [x] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие +- [x] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием +- [x] Строки через `t(...)`, обе локали ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index a2848fe..0d2cb8f 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -67,4 +67,4 @@ | [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы | | [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета | | [16. Расписание в школе](16-timetable-in-school.md) | ✅ | Сейв, снимок, «кто где сейчас» | -| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | +| [17. Расписание на экране](17-timetable-screen.md) | ✅ | Скобки в дереве, сетка класса, расписание учителя | diff --git a/docs/protocol.md b/docs/protocol.md index 47213ff..ad1a918 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -196,7 +196,8 @@ tick with the clock. Unknown school is `404` `unknown-school`; unknown person is Family links are other members of the same family: parents and siblings for a child, children and partners for a parent. The client opens another card by id; this response does not nest -cards. +cards. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable +grid fetches `GET .../timetable?classId=` with it. ```json { @@ -208,6 +209,7 @@ cards. "roles": ["student"], "classYear": 5, "classLetter": "А", + "classId": "class-classroom-105", "body": [{ "id": "Height", "label": "Рост", "value": "164" }], "skills": [{ "id": "Math", "label": "Математика", "value": "62" }], "traits": [{ "defName": "Diligent", "label": "Усидчивый" }], @@ -334,8 +336,9 @@ assignment is `404` `unknown-assignment`. ### `GET /api/schools/{id}/timetable` The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter -the lessons. `?lang=ru|en` labels subjects. Reads the snapshot — it does not post to the -worker. Unknown `{id}` is `404` `unknown-school`. +the lessons. `?lang=ru|en` labels subjects and rooms. Reads the snapshot — it does not post to the +worker. Unknown `{id}` is `404` `unknown-school`. `classes` and `rooms` are the full school lists +so a picker does not need a second request. `day` is 0 = Monday. `period` is the 1-based lesson number from the day frame. @@ -353,6 +356,7 @@ worker. Unknown `{id}` is `404` `unknown-school`. "teacherId": "f3.p1", "teacherName": "Иванова Ольга Михайловна", "roomId": "classroom-204", + "roomLabel": "Класс 204", "day": 1, "period": 3, "locked": false @@ -367,7 +371,9 @@ worker. Unknown `{id}` is `404` `unknown-school`. "subjectLabel": "Информатика", "hours": 1 } - ] + ], + "classes": [{ "id": "c5A", "year": 5, "letter": "A" }], + "rooms": [{ "id": "classroom-204", "label": "Класс 204" }] } ``` diff --git a/src/HSchool.Client/src/format/gameTime.test.ts b/src/HSchool.Client/src/format/gameTime.test.ts index df9a93b..0a0c0f1 100644 --- a/src/HSchool.Client/src/format/gameTime.test.ts +++ b/src/HSchool.Client/src/format/gameTime.test.ts @@ -5,8 +5,10 @@ import { formatGameDateTime, formatGameTimeOfDay, formatGameWeekday, + formatGameWeekdayShort, fromDateAndTimeInputs, toDateAndTimeInputs, + weekdayDate, } from './gameTime.ts'; // The default start of a new school: 31 March 2012, 06:00 — a Saturday. @@ -25,6 +27,12 @@ describe('game time formatting', () => { expect(formatGameWeekday(START)).toBe('суббота'); }); + it('shows short weekday names for timetable columns', () => { + expect(formatGameWeekdayShort(weekdayDate(0)).toLowerCase()).toContain('пн'); + setLocale('en'); + expect(formatGameWeekdayShort(weekdayDate(0)).toLowerCase()).toContain('mon'); + }); + it('shows the full date', () => { expect(formatGameDate(START)).toContain('2012'); expect(formatGameDate(START)).toContain('марта'); diff --git a/src/HSchool.Client/src/format/gameTime.ts b/src/HSchool.Client/src/format/gameTime.ts index d465756..3f53f65 100644 --- a/src/HSchool.Client/src/format/gameTime.ts +++ b/src/HSchool.Client/src/format/gameTime.ts @@ -14,6 +14,7 @@ interface Formats { readonly date: Intl.DateTimeFormat; readonly time: Intl.DateTimeFormat; readonly weekday: Intl.DateTimeFormat; + readonly weekdayShort: Intl.DateTimeFormat; readonly short: Intl.DateTimeFormat; } @@ -39,6 +40,7 @@ function formats(): Formats { hour12: false, }), weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }), + weekdayShort: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'short' }), short: new Intl.DateTimeFormat(tag, { timeZone: UTC, day: '2-digit', @@ -73,6 +75,16 @@ export function formatGameWeekday(date: Date): string { return formats().weekday.format(date); } +/** "пн" / "Mon" — column headers on the timetable grid. */ +export function formatGameWeekdayShort(date: Date): string { + return formats().weekdayShort.format(date); +} + +/** Monday=0 … Sunday=6, same numbering the timetable uses. */ +export function weekdayDate(day: number): Date { + return new Date(Date.UTC(2012, 3, 2 + day, 12, 0, 0)); +} + /** Compact form the school cards use — day-month-year in both languages. */ export function formatGameDateTime(date: Date): string { return formats().short.format(date); diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index 09196d9..46bef30 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -24,6 +24,8 @@ describe('t', () => { expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512'); expect(t('staffErrorPayroll', { allocated: '10 000', payroll: '8 000', remaining: '2 000', attempted: '12 000' })) .toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.'); + expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' })) + .toBe('Кабинет 204 (Математика · 5Б)'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 42049e1..9a88971 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -165,6 +165,26 @@ const ru = { staffErrorNotTeacher: 'Предмет можно назначить только учителю.', staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorSubject: 'Такого предмета нет.', + + mapOccupancy: '{name} ({activity})', + timetableTitle: 'Расписание', + timetableClass: 'Класс', + timetableEmpty: 'Нет уроков.', + timetableLoadFailed: 'Не удалось загрузить расписание.', + timetablePeriod: '{n}', + timetableLocked: 'закреплено', + timetableDay: 'День', + timetablePeriodLabel: 'Урок', + timetableRoom: 'Кабинет', + timetableApply: 'Закрепить здесь', + timetableUnpin: 'Снять закрепление', + timetablePickLesson: 'Выберите урок в сетке, чтобы перенести или сменить кабинет.', + timetableUncovered: '{label} — {hours} ч.', + timetableUncoveredTitle: 'Не покрыто', + timetableErrorPin: 'Сюда поставить нельзя: слот или кабинет заняты, либо класс туда не влезает.', + timetableErrorNoTeacher: 'Некому вести этот предмет.', + timetableErrorUnknown: 'Не удалось изменить урок.', + timetableErrorLesson: 'Этого закрепления уже нет.', } as const; type Messages = { [K in keyof typeof ru]: string }; @@ -334,6 +354,26 @@ const en: Messages = { staffErrorNotTeacher: 'Only a teacher can be assigned a subject.', staffErrorAssigned: 'That subject is already assigned.', staffErrorSubject: 'That subject is not in the catalog.', + + mapOccupancy: '{name} ({activity})', + timetableTitle: 'Timetable', + timetableClass: 'Class', + timetableEmpty: 'No lessons.', + timetableLoadFailed: 'Could not load the timetable.', + timetablePeriod: '{n}', + timetableLocked: 'pinned', + timetableDay: 'Day', + timetablePeriodLabel: 'Period', + timetableRoom: 'Room', + timetableApply: 'Pin here', + timetableUnpin: 'Unpin', + timetablePickLesson: 'Pick a lesson in the grid to move it or change the room.', + timetableUncovered: '{label} — {hours} h.', + timetableUncoveredTitle: 'Uncovered', + timetableErrorPin: 'Cannot place that lesson: the slot or room is taken, or the class does not fit.', + timetableErrorNoTeacher: 'Nobody is assigned that subject.', + timetableErrorUnknown: 'Could not change the lesson.', + timetableErrorLesson: 'That pinned lesson is gone.', }; const catalogs: Record = { ru, en }; diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index db5e651..87a6901 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -257,6 +257,7 @@ export interface PersonCard { readonly roles: readonly string[]; readonly classYear: number | null; readonly classLetter: string | null; + readonly classId: string | null; readonly position: string | null; readonly positionLabel: string | null; readonly body: readonly LabeledStat[]; @@ -392,6 +393,7 @@ export interface TimetableLesson { readonly teacherId: string; readonly teacherName: string; readonly roomId: string; + readonly roomLabel: string; readonly day: number; readonly period: number; readonly locked: boolean; @@ -406,11 +408,24 @@ export interface UncoveredLesson { readonly hours: number; } +export interface TimetableClass { + readonly id: string; + readonly year: number; + readonly letter: string; +} + +export interface TimetableRoom { + readonly id: string; + readonly label: string; +} + export interface Timetable { readonly weekDays: number; readonly lessonCount: number; readonly lessons: readonly TimetableLesson[]; readonly uncovered: readonly UncoveredLesson[]; + readonly classes: readonly TimetableClass[]; + readonly rooms: readonly TimetableRoom[]; } export async function fetchTimetable( diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index ddfd48d..05cf18a 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -764,6 +764,83 @@ body { max-height: 220px; } +.timetable { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.timetable__wrap { + overflow: auto; +} + +.timetable__grid { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.timetable__grid th, +.timetable__grid td { + border: 1px solid var(--border); + padding: 4px 6px; + vertical-align: top; +} + +.timetable__grid thead th { + background: var(--surface-sunken); + font-weight: 600; + text-align: center; +} + +.timetable__grid tbody th { + background: var(--surface-sunken); + font-variant-numeric: tabular-nums; + text-align: right; + width: 2em; +} + +.timetable__cell { + min-width: 6.5em; +} + +.timetable--editable .timetable__cell:not(.timetable__cell--empty) { + cursor: pointer; +} + +.timetable__cell--empty { + cursor: default; + background: transparent; +} + +.timetable__cell--locked { + box-shadow: inset 3px 0 0 var(--accent); +} + +.timetable__cell--active { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.timetable__subject { + display: block; + font-weight: 600; +} + +.timetable__meta { + display: block; + color: var(--text-muted); + margin-top: 2px; +} + +.timetable__editor { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 8px; +} + /* Below three columns the panels stop competing for height and the page scrolls instead. */ @media (max-width: 900px) { #app { diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index f5e24ab..1e86b3a 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -216,7 +216,7 @@ export class GameScreen { const button = el('button', { class: 'tree__button', type: 'button', - text: node.name, + text: treeLabel(node), onClick: () => this.select(node.id), }); button.style.paddingLeft = `${8 + depth * 14}px`; @@ -287,10 +287,15 @@ 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(' · ')] - : []; + const activity = []; + if (node?.activitySubject) { + activity.push(node.activitySubject); + } + + if (node?.activityClass) { + activity.push(node.activityClass); + } + paintList(this.activitiesList, this.activitiesEmpty, activity); paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []); paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []); @@ -318,6 +323,11 @@ function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSna return nodes.filter((node) => node.parentId === parentId); } +function treeLabel(node: MapSnapshotNode): string { + const activity = [node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · '); + return activity.length > 0 ? t('mapOccupancy', { name: node.name, activity }) : node.name; +} + function formatItem(item: MapSnapshotItem): string { return item.count === 1 ? item.name : `${item.name} ×${item.count}`; } diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index c8e8ecc..3070404 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -3,17 +3,20 @@ import { assignSubject, fetchPerson, fetchStaffing, + fetchTimetable, hireStaff, unassignSubject, type PersonCard, type Staffing, type StaffingApplicant, type StaffMember, + type Timetable, } from '../net/api.ts'; import { getLocale, intlTag } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; import { renderPersonCard } from './personCard.ts'; +import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; const TEACHER = 'Teacher'; @@ -40,12 +43,26 @@ export class ManagementPanel { private readonly staffTitle = el('h3', { class: 'panel__section-title' }); private readonly staffTable = el('table', { class: 'people__table' }); private readonly staffEmpty = el('p', { class: 'panel__empty' }); + private readonly timetableTitle = el('h3', { class: 'panel__section-title' }); + private readonly classLabel = el('span', { class: 'people__label' }); + private readonly classSelect = el('select', { class: 'input people__input' }); + private readonly timetableGrid = new TimetableGrid({ + editable: true, + showClass: false, + onTable: (table) => { + this.timetable = table; + }, + }); + private readonly personGrid = new TimetableGrid({ editable: false, showClass: true }); + private readonly personTimetableTitle = el('h4', { class: 'people__section-title' }); private readonly card = el('aside', { class: 'panel__body people__card' }); private readonly positionSelect = el('select', { class: 'input people__input' }); private readonly subjectSelect = el('select', { class: 'input people__input' }); private schoolId: number | null = null; private staffing: Staffing | null = null; + private timetable: Timetable | null = null; + private classId: string | null = null; private selection: Selection | null = null; private loadToken = 0; private cardToken = 0; @@ -71,8 +88,19 @@ export class ManagementPanel { this.staffTitle, el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty), ), + el( + 'div', + { class: 'panel__section' }, + this.timetableTitle, + el('label', { class: 'people__field' }, this.classLabel, this.classSelect), + this.timetableGrid.element, + ), ); this.cardElement = this.card; + this.classSelect.addEventListener('change', () => { + this.classId = this.classSelect.value || null; + this.timetableGrid.setTable(this.timetable, this.classId); + }); this.localize(); } @@ -80,9 +108,14 @@ export class ManagementPanel { this.uncoveredTitle.textContent = t('staffUncovered'); this.applicantsTitle.textContent = t('staffApplicants'); this.staffTitle.textContent = t('staffHired'); + this.timetableTitle.textContent = t('timetableTitle'); + this.classLabel.textContent = t('timetableClass'); this.uncoveredEmpty.textContent = t('staffUncoveredEmpty'); this.applicantsEmpty.textContent = t('staffApplicantsEmpty'); this.staffEmpty.textContent = t('staffHiredEmpty'); + this.timetableGrid.localize(); + this.personGrid.localize(); + this.personTimetableTitle.textContent = t('timetableTitle'); this.paint(); if (this.selection !== null) { void this.openCard(this.selection); @@ -97,9 +130,12 @@ export class ManagementPanel { if (switched) { this.selection = null; this.staffing = null; + this.timetable = null; + this.classId = null; this.clearError(); } + this.timetableGrid.attach(schoolId); void this.reload(); } @@ -111,14 +147,20 @@ export class ManagementPanel { const token = ++this.loadToken; try { - const staffing = await fetchStaffing(schoolId, getLocale()); + const [staffing, timetable] = await Promise.all([ + fetchStaffing(schoolId, getLocale()), + fetchTimetable(schoolId, getLocale()), + ]); if (token !== this.loadToken) { return; } this.staffing = staffing; + this.timetable = timetable; + this.syncClass(timetable); this.syncSelection(); this.paint(); + this.timetableGrid.setTable(timetable, this.classId); if (this.selection !== null) { void this.openCard(this.selection); } else { @@ -133,6 +175,29 @@ export class ManagementPanel { } } + private syncClass(timetable: Timetable): void { + const ids = timetable.classes.map((row) => row.id); + if (this.classId !== null && ids.includes(this.classId)) { + return; + } + + this.classId = ids[0] ?? null; + } + + private paintClasses(timetable: Timetable | null): void { + const current = this.classId ?? ''; + this.classSelect.replaceChildren(); + for (const row of timetable?.classes ?? []) { + const option = el('option', { text: `${row.year}${row.letter}` }); + option.value = row.id; + this.classSelect.append(option); + } + + if ([...this.classSelect.options].some((option) => option.value === current)) { + this.classSelect.value = current; + } + } + private syncSelection(): void { const staffing = this.staffing; const selected = this.selection; @@ -158,6 +223,7 @@ export class ManagementPanel { this.paintUncovered(staffing); this.paintApplicants(staffing); this.paintStaff(staffing); + this.paintClasses(this.timetable); } private paintMoney(staffing: Staffing | null): void { @@ -339,9 +405,37 @@ export class ManagementPanel { } renderPersonCard(this.card, card, (id) => void this.openRelative(id)); + this.mountPersonTimetable(card); this.appendActions(card.id); } + private mountPersonTimetable(card: PersonCard): void { + const schoolId = this.schoolId; + const query = personTimetableQuery(card); + if (schoolId === null || query === null) { + return; + } + + this.card.append(this.personTimetableTitle, this.personGrid.element); + this.personGrid.attach(schoolId); + const token = this.cardToken; + void fetchTimetable(schoolId, getLocale(), query) + .then((table) => { + if (token !== this.cardToken) { + return; + } + + this.personGrid.setTable(table); + }) + .catch(() => { + if (token !== this.cardToken) { + return; + } + + this.personGrid.setTable(null); + }); + } + private async openRelative(personId: string): Promise { const staffing = this.staffing; if (staffing !== null && staffing.staff.some((row) => row.id === personId)) { @@ -469,6 +563,7 @@ export class ManagementPanel { const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale()); this.staffing = staffing; this.selection = { kind: 'staff', id: personId }; + await this.refreshTimetable(); } catch (error) { this.showError(actionError(error)); } finally { @@ -492,6 +587,7 @@ export class ManagementPanel { try { this.staffing = await assignSubject(schoolId, personId, subject, getLocale()); this.selection = { kind: 'staff', id: personId }; + await this.refreshTimetable(); } catch (error) { this.showError(actionError(error)); } finally { @@ -514,6 +610,7 @@ export class ManagementPanel { try { this.staffing = await unassignSubject(schoolId, personId, subject, getLocale()); this.selection = { kind: 'staff', id: personId }; + await this.refreshTimetable(); } catch (error) { this.showError(actionError(error)); } finally { @@ -525,6 +622,21 @@ export class ManagementPanel { } } + private async refreshTimetable(): Promise { + const schoolId = this.schoolId; + if (schoolId === null) { + return; + } + + try { + this.timetable = await fetchTimetable(schoolId, getLocale()); + this.syncClass(this.timetable); + this.timetableGrid.setTable(this.timetable, this.classId); + } catch { + this.showError(t('timetableLoadFailed')); + } + } + private showError(message: string): void { this.error.hidden = false; this.error.textContent = message; diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index e32146b..95a49f7 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -1,15 +1,9 @@ -import { - fetchPeople, - fetchPerson, - type PeoplePage, - type PersonCard, - type PersonRole, - type PersonSort, -} from '../net/api.ts'; +import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts'; import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; import { placement, renderPersonCard, roleLabels } from './personCard.ts'; +import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ { sort: 'surname', label: 'peopleColName' }, @@ -55,6 +49,8 @@ export class PeoplePanel { private readonly prevButton = el('button', { class: 'button button--small', type: 'button' }); private readonly nextButton = el('button', { class: 'button button--small', type: 'button' }); private readonly card = el('aside', { class: 'panel__body people__card' }); + private readonly personTimetableTitle = el('h4', { class: 'people__section-title' }); + private readonly personGrid = new TimetableGrid({ editable: false, showClass: true }); private schoolId: number | null = null; private sort: PersonSort = 'surname'; @@ -125,6 +121,8 @@ export class PeoplePanel { this.prevButton.textContent = t('peoplePrev'); this.nextButton.textContent = t('peopleNext'); this.empty.textContent = t('peopleEmpty'); + this.personTimetableTitle.textContent = t('timetableTitle'); + this.personGrid.localize(); fillFixedSelect(this.roleSelect, [ { value: '', label: t('peopleRoleAll') }, { value: 'student', label: t('peopleRoleStudent') }, @@ -357,6 +355,34 @@ export class PeoplePanel { } renderPersonCard(this.card, card, (id) => void this.openCard(id)); + this.mountPersonTimetable(card); + } + + private mountPersonTimetable(card: PersonCard): void { + const schoolId = this.schoolId; + const query = personTimetableQuery(card); + if (schoolId === null || query === null) { + return; + } + + this.card.append(this.personTimetableTitle, this.personGrid.element); + this.personGrid.attach(schoolId); + const token = this.cardToken; + void fetchTimetable(schoolId, getLocale(), query) + .then((table) => { + if (token !== this.cardToken) { + return; + } + + this.personGrid.setTable(table); + }) + .catch(() => { + if (token !== this.cardToken) { + return; + } + + this.personGrid.setTable(null); + }); } } diff --git a/src/HSchool.Client/src/ui/timetableGrid.ts b/src/HSchool.Client/src/ui/timetableGrid.ts new file mode 100644 index 0000000..008a581 --- /dev/null +++ b/src/HSchool.Client/src/ui/timetableGrid.ts @@ -0,0 +1,410 @@ +import { + ApiError, + pinLesson, + unpinLesson, + type PersonCard, + type Timetable, + type TimetableLesson, + type TimetableRoom, +} from '../net/api.ts'; +import { formatGameWeekdayShort, weekdayDate } from '../format/gameTime.ts'; +import { getLocale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; + +export interface TimetableGridOptions { + readonly editable: boolean; + readonly showClass: boolean; + readonly onTable?: (table: Timetable) => void; +} + +/** + * Day × period grid. Occupancy itself lives on the map snapshot; this is the week table the + * player reads and, in Management, pins. + */ +export class TimetableGrid { + readonly element: HTMLElement; + + private readonly error = el('p', { class: 'staffing__error' }); + private readonly hint = el('p', { class: 'panel__empty' }); + private readonly uncoveredTitle = el('h4', { class: 'people__section-title' }); + private readonly uncovered = el('div', { class: 'people__tags' }); + private readonly gridWrap = el('div', { class: 'timetable__wrap' }); + private readonly editor = el('div', { class: 'timetable__editor' }); + private readonly daySelect = el('select', { class: 'input people__input' }); + private readonly periodSelect = el('select', { class: 'input people__input' }); + private readonly roomSelect = el('select', { class: 'input people__input' }); + private readonly applyButton = el('button', { class: 'button button--small', type: 'button' }); + private readonly unpinButton = el('button', { class: 'button button--small', type: 'button' }); + private readonly dayLabel = el('span', { class: 'people__label' }); + private readonly periodLabel = el('span', { class: 'people__label' }); + private readonly roomLabel = el('span', { class: 'people__label' }); + + private schoolId: number | null = null; + private table: Timetable | null = null; + private classId: string | null = null; + private selected: TimetableLesson | null = null; + private busy = false; + + constructor(private readonly options: TimetableGridOptions) { + this.error.hidden = true; + this.editor.hidden = true; + this.uncovered.hidden = true; + this.editor.append( + el('label', { class: 'people__field' }, this.dayLabel, this.daySelect), + el('label', { class: 'people__field' }, this.periodLabel, this.periodSelect), + el('label', { class: 'people__field' }, this.roomLabel, this.roomSelect), + this.applyButton, + this.unpinButton, + ); + this.applyButton.addEventListener('click', () => void this.apply()); + this.unpinButton.addEventListener('click', () => void this.unpin()); + this.element = el( + 'div', + { class: this.options.editable ? 'timetable timetable--editable' : 'timetable' }, + this.error, + this.gridWrap, + this.uncoveredTitle, + this.uncovered, + this.hint, + this.editor, + ); + this.localize(); + } + + localize(): void { + this.hint.textContent = this.options.editable ? t('timetablePickLesson') : ''; + this.hint.hidden = !this.options.editable; + this.uncoveredTitle.textContent = t('timetableUncoveredTitle'); + this.dayLabel.textContent = t('timetableDay'); + this.periodLabel.textContent = t('timetablePeriodLabel'); + this.roomLabel.textContent = t('timetableRoom'); + this.applyButton.textContent = t('timetableApply'); + this.unpinButton.textContent = t('timetableUnpin'); + this.paint(); + } + + attach(schoolId: number): void { + this.schoolId = schoolId; + this.selected = null; + this.classId = null; + this.clearError(); + } + + setTable(table: Timetable | null, classId?: string | null): void { + this.table = table; + if (classId !== undefined) { + this.classId = classId; + } + + const selected = this.selected; + const visible = this.visible(); + if (selected !== null && visible !== null) { + this.selected = visible.lessons.find((lesson) => sameSlot(lesson, selected)) ?? null; + } + + this.paint(); + } + + private visible(): Timetable | null { + const table = this.table; + if (table === null) { + return null; + } + + const classId = this.classId; + if (classId === null) { + // Management never paints the whole school; a missing picker means an empty class grid. + return this.options.editable ? { ...table, lessons: [], uncovered: [] } : table; + } + + return { + ...table, + lessons: table.lessons.filter((lesson) => lesson.classId === classId), + uncovered: table.uncovered.filter((row) => row.classId === classId), + }; + } + + private paint(): void { + const table = this.visible(); + clear(this.gridWrap); + if (table === null) { + this.gridWrap.append(el('p', { class: 'panel__empty', text: t('timetableEmpty') })); + this.uncovered.hidden = true; + this.uncoveredTitle.hidden = true; + this.editor.hidden = true; + return; + } + + this.gridWrap.append(this.buildGrid(table)); + this.paintUncovered(table); + this.paintEditor(table); + } + + private buildGrid(table: Timetable): HTMLTableElement { + const bySlot = new Map(); + for (const lesson of table.lessons) { + bySlot.set(slotKey(lesson.day, lesson.period), lesson); + } + + const grid = el('table', { class: 'timetable__grid' }); + const head = el('tr'); + head.append(el('th', { text: '#' })); + for (let day = 0; day < table.weekDays; day++) { + head.append(el('th', { text: formatGameWeekdayShort(weekdayDate(day)) })); + } + + grid.append(el('thead', {}, head)); + const body = el('tbody'); + for (let period = 1; period <= table.lessonCount; period++) { + const row = el('tr'); + row.append(el('th', { text: t('timetablePeriod', { n: period }) })); + for (let day = 0; day < table.weekDays; day++) { + const lesson = bySlot.get(slotKey(day, period)); + row.append(this.cell(lesson)); + } + + body.append(row); + } + + grid.append(body); + return grid; + } + + private cell(lesson: TimetableLesson | undefined): HTMLTableCellElement { + if (lesson === undefined) { + return el('td', { class: 'timetable__cell timetable__cell--empty', text: '' }); + } + + const selected = this.selected !== null && sameSlot(lesson, this.selected); + const cell = el('td', { + class: [ + 'timetable__cell', + lesson.locked ? 'timetable__cell--locked' : '', + selected ? 'timetable__cell--active' : '', + ] + .filter((part) => part.length > 0) + .join(' '), + title: lesson.locked ? t('timetableLocked') : lesson.teacherName, + onClick: this.options.editable ? () => this.select(lesson) : undefined, + }); + const meta = this.options.showClass + ? `${lesson.classYear}${lesson.classLetter}` + : lesson.roomLabel; + cell.append( + el('span', { class: 'timetable__subject', text: lesson.subjectLabel }), + el('span', { class: 'timetable__meta', text: meta }), + ); + + return cell; + } + + private paintUncovered(table: Timetable): void { + if (this.options.showClass) { + this.uncoveredTitle.hidden = true; + this.uncovered.hidden = true; + return; + } + clear(this.uncovered); + const rows = table.uncovered; + this.uncoveredTitle.hidden = rows.length === 0; + this.uncovered.hidden = rows.length === 0; + for (const row of rows) { + this.uncovered.append( + el('span', { + class: 'people__tag', + text: t('timetableUncovered', { label: row.subjectLabel, hours: row.hours }), + }), + ); + } + } + + private paintEditor(table: Timetable): void { + if (!this.options.editable) { + this.editor.hidden = true; + this.hint.hidden = true; + return; + } + + const lesson = this.selected; + this.hint.hidden = lesson !== null; + this.editor.hidden = lesson === null; + if (lesson === null) { + return; + } + + fillNumericSelect(this.daySelect, table.weekDays, (day) => formatGameWeekdayShort(weekdayDate(day)), lesson.day); + fillNumericSelect( + this.periodSelect, + table.lessonCount, + (index) => t('timetablePeriod', { n: index + 1 }), + lesson.period - 1, + ); + fillRooms(this.roomSelect, table.rooms, lesson.roomId); + this.unpinButton.hidden = !lesson.locked; + this.applyButton.disabled = this.busy; + this.unpinButton.disabled = this.busy; + } + + private select(lesson: TimetableLesson): void { + this.selected = lesson; + this.clearError(); + this.paint(); + } + + private async apply(): Promise { + const schoolId = this.schoolId; + const lesson = this.selected; + if (schoolId === null || lesson === null || this.busy) { + return; + } + + const next = { + classId: lesson.classId, + subject: lesson.subject, + roomId: this.roomSelect.value, + day: Number(this.daySelect.value), + period: Number(this.periodSelect.value) + 1, + }; + this.busy = true; + this.clearError(); + this.paint(); + try { + const movedSlot = next.day !== lesson.day || next.period !== lesson.period; + let table: Timetable; + if (lesson.locked && movedSlot) { + await unpinLesson(schoolId, lesson, getLocale()); + try { + table = await pinLesson(schoolId, next, getLocale()); + } catch (error) { + table = await pinLesson( + schoolId, + { + classId: lesson.classId, + subject: lesson.subject, + roomId: lesson.roomId, + day: lesson.day, + period: lesson.period, + }, + getLocale(), + ); + this.table = table; + this.options.onTable?.(table); + throw error; + } + } else { + table = await pinLesson(schoolId, next, getLocale()); + } + + this.table = table; + this.options.onTable?.(table); + this.selected = table.lessons.find( + (row) => row.classId === next.classId && row.subject === next.subject && row.day === next.day && row.period === next.period, + ) ?? null; + } catch (error) { + this.showError(pinError(error)); + } finally { + this.busy = false; + this.paint(); + } + } + + private async unpin(): Promise { + const schoolId = this.schoolId; + const lesson = this.selected; + if (schoolId === null || lesson === null || !lesson.locked || this.busy) { + return; + } + + this.busy = true; + this.clearError(); + try { + const table = await unpinLesson(schoolId, lesson, getLocale()); + this.table = table; + this.options.onTable?.(table); + this.selected = table.lessons.find((row) => sameSlot(row, lesson)) ?? null; + } catch (error) { + this.showError(pinError(error)); + } finally { + this.busy = false; + this.paint(); + } + } + + private showError(message: string): void { + this.error.hidden = false; + this.error.textContent = message; + } + + private clearError(): void { + this.error.hidden = true; + this.error.textContent = ''; + } +} + +function slotKey(day: number, period: number): string { + return `${day}:${period}`; +} + +function sameSlot(left: TimetableLesson, right: TimetableLesson): boolean { + return left.classId === right.classId && left.subject === right.subject && left.day === right.day && left.period === right.period; +} + +function fillNumericSelect( + select: HTMLSelectElement, + count: number, + label: (index: number) => string, + selected: number, +): void { + select.replaceChildren(); + for (let index = 0; index < count; index++) { + const option = el('option', { text: label(index) }); + option.value = String(index); + select.append(option); + } + + select.value = String(selected); +} + +function fillRooms(select: HTMLSelectElement, rooms: readonly TimetableRoom[], selected: string): void { + select.replaceChildren(); + for (const room of rooms) { + const option = el('option', { text: room.label }); + option.value = room.id; + select.append(option); + } + + if (rooms.some((room) => room.id === selected)) { + select.value = selected; + } +} + +function pinError(error: unknown): string { + if (!(error instanceof ApiError)) { + return t('timetableErrorUnknown'); + } + + switch (error.code) { + case 'pin-rejected': + return t('timetableErrorPin'); + case 'no-teacher': + return t('timetableErrorNoTeacher'); + case 'unknown-lesson': + return t('timetableErrorLesson'); + default: + return error.message.length > 0 ? error.message : t('timetableErrorUnknown'); + } +} + +/** A teacher's own lessons, or a pupil's class table. Parents who are not staff have none. */ +export function personTimetableQuery(card: PersonCard): { classId?: string; personId?: string } | null { + if (card.roles.includes('staff')) { + return { personId: card.id }; + } + + if (card.classId !== null) { + return { classId: card.classId }; + } + + return null; +} diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 0deacd8..d0e2060 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -52,6 +52,7 @@ internal sealed record PersonCardResponse( IReadOnlyList Roles, int? ClassYear, string? ClassLetter, + string? ClassId, string? Position, string? PositionLabel, IReadOnlyList Body, diff --git a/src/HSchool.Server/Api/TimetableEndpoints.cs b/src/HSchool.Server/Api/TimetableEndpoints.cs index e0dbb32..82967a4 100644 --- a/src/HSchool.Server/Api/TimetableEndpoints.cs +++ b/src/HSchool.Server/Api/TimetableEndpoints.cs @@ -111,10 +111,18 @@ internal static class TimetableEndpoints var roster = published.Roster ?? new Roster([], [], []); if (published.Catalog is null) { - return new TimetableResponse(weekDays, 0, [], []); + return new TimetableResponse(weekDays, 0, [], [], [], []); } - return TimetableMapper.From(table, roster, published.Catalog, weekDays, locale, classId, personId); + return TimetableMapper.From( + table, + roster, + published.Catalog, + published.Map, + weekDays, + locale, + classId, + personId); } private static IResult TimetableResult( diff --git a/src/HSchool.Server/Api/TimetableModels.cs b/src/HSchool.Server/Api/TimetableModels.cs index 0d723e1..e36730a 100644 --- a/src/HSchool.Server/Api/TimetableModels.cs +++ b/src/HSchool.Server/Api/TimetableModels.cs @@ -29,7 +29,9 @@ internal sealed record TimetableResponse( int WeekDays, int LessonCount, IReadOnlyList Lessons, - IReadOnlyList Uncovered); + IReadOnlyList Uncovered, + IReadOnlyList Classes, + IReadOnlyList Rooms); internal sealed record TimetableLessonResponse( string ClassId, @@ -40,6 +42,7 @@ internal sealed record TimetableLessonResponse( string TeacherId, string TeacherName, string RoomId, + string RoomLabel, int Day, int Period, bool Locked); @@ -52,12 +55,17 @@ internal sealed record UncoveredLessonResponse( string SubjectLabel, int Hours); +internal sealed record TimetableClassResponse(string Id, int Year, string Letter); + +internal sealed record TimetableRoomResponse(string Id, string Label); + internal static class TimetableMapper { public static TimetableResponse From( Timetable table, Roster roster, DefCatalog catalog, + MapLayout? map, int weekDays, string locale, string? classId, @@ -65,6 +73,7 @@ internal static class TimetableMapper { var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var classes = roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal); + var roomNames = RoomNames(catalog, map, locale); var lessons = table.Lessons.AsEnumerable(); var uncovered = table.Uncovered.AsEnumerable(); if (!string.IsNullOrWhiteSpace(classId)) @@ -81,8 +90,15 @@ internal static class TimetableMapper return new TimetableResponse( weekDays, catalog.DayFrame?.LessonCount ?? 0, - lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale)).ToArray(), - uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray()); + lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale, roomNames)).ToArray(), + uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray(), + roster.Classes + .OrderBy(item => item.Year) + .ThenBy(item => item.Letter, StringComparer.Ordinal) + .ThenBy(item => item.Id, StringComparer.Ordinal) + .Select(item => new TimetableClassResponse(item.Id, item.Year, item.Letter)) + .ToArray(), + roomNames.Select(pair => new TimetableRoomResponse(pair.Key, pair.Value)).ToArray()); } private static TimetableLessonResponse MapLesson( @@ -90,7 +106,8 @@ internal static class TimetableMapper IReadOnlyDictionary classes, IReadOnlyDictionary people, DefCatalog catalog, - string locale) + string locale, + IReadOnlyDictionary roomNames) { classes.TryGetValue(lesson.ClassId, out var schoolClass); people.TryGetValue(lesson.TeacherId, out var teacher); @@ -107,6 +124,7 @@ internal static class TimetableMapper lesson.TeacherId, teacher?.Name.Full ?? lesson.TeacherId, lesson.RoomId, + roomNames.GetValueOrDefault(lesson.RoomId, lesson.RoomId), lesson.Day, lesson.Period, lesson.Locked); @@ -131,4 +149,18 @@ internal static class TimetableMapper subjectLabel, row.Hours); } + + private static IReadOnlyDictionary RoomNames(DefCatalog catalog, MapLayout? map, string locale) + { + if (map is null) + { + return new Dictionary(StringComparer.Ordinal); + } + + return MapView.Build(catalog, map, locale) + .Where(node => node.Kind == MapNodeKind.Room) + .OrderBy(node => node.Name, StringComparer.Ordinal) + .ThenBy(node => node.Id, StringComparer.Ordinal) + .ToDictionary(node => node.Id, node => node.Name, StringComparer.Ordinal); + } } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 4ca29e8..f1dbb68 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -70,7 +70,8 @@ internal sealed class GameLoopService( worker.RosterSnapshot, worker.ApplicantSnapshot, worker.CatalogSnapshot, - worker.TimetableSnapshot); + worker.TimetableSnapshot, + worker.MapSnapshot); } } @@ -643,4 +644,5 @@ internal sealed record PublishedSchoolPeople( Roster? Roster, ApplicantPool? Applicants, DefCatalog? Catalog, - Timetable? Timetable); + Timetable? Timetable, + MapLayout? Map); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index 7fb3d79..33ca0f2 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -54,6 +54,7 @@ internal static class PersonCardReader PeopleListMapper.RolesOf(person), year, letter, + person.ClassId, person.Position, PeopleListMapper.PositionLabel(catalog, locale, person.Position), Body(person, catalog, locale), diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 305d5c8..00b472e 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -46,6 +46,7 @@ internal sealed class SchoolWorker private ApplicantPool? _applicantSnapshot; private DefCatalog? _catalogSnapshot; private Timetable? _timetableSnapshot; + private MapLayout? _mapSnapshot; private OccupancyKey _occupancyKey; private School? _school; private Task? _run; @@ -109,6 +110,9 @@ internal sealed class SchoolWorker /// Last built timetable. Published like the roster — HTTP never reads the live school. public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot); + /// Frozen map instance. Safe to read from HTTP with the catalog; it does not mutate. + public MapLayout? MapSnapshot => Volatile.Read(ref _mapSnapshot); + public void Start() { _run = Task.Factory.StartNew( @@ -205,6 +209,7 @@ internal sealed class SchoolWorker var catalog = _mods.LoadCatalog(packIds, _logger); Volatile.Write(ref _catalogSnapshot, catalog); var map = _mods.LoadMap(packIds, _savedMap); + Volatile.Write(ref _mapSnapshot, map); try { MapValidator.Validate(map, catalog); @@ -565,6 +570,7 @@ internal sealed class SchoolWorker Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _timetableSnapshot, school.Timetable); + Volatile.Write(ref _mapSnapshot, school.Map); } /// diff --git a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs index 33e8553..43cec49 100644 --- a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs +++ b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs @@ -31,6 +31,9 @@ public class TimetableApiTests(AppHostFixture fixture) Assert.Equal(7, table.LessonCount); Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher); Assert.DoesNotContain(table.Uncovered, row => row.Subject == "Mathematics"); + Assert.NotEmpty(table.Classes); + Assert.Contains(table.Rooms, room => room.Id == "classroom-101"); + Assert.Contains(table.Lessons, lesson => lesson.RoomLabel.Length > 0); using var unassign = await client.DeleteAsync( $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics", @@ -184,7 +187,9 @@ public class TimetableApiTests(AppHostFixture fixture) int WeekDays, int LessonCount, IReadOnlyList Lessons, - IReadOnlyList Uncovered); + IReadOnlyList Uncovered, + IReadOnlyList Classes, + IReadOnlyList Rooms); private sealed record LessonResponse( string ClassId, @@ -195,10 +200,15 @@ public class TimetableApiTests(AppHostFixture fixture) string TeacherId, string TeacherName, string RoomId, + string RoomLabel, int Day, int Period, bool Locked); + private sealed record ClassResponse(string Id, int Year, string Letter); + + private sealed record RoomResponse(string Id, string Label); + private sealed record UncoveredResponse( string ClassId, int ClassYear,