Enhance timetable functionality and UI integration
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 13s

- 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.
This commit is contained in:
Leonid Pershin
2026-08-19 10:45:09 +03:00
parent cad3068bac
commit d61240d5c1
20 changed files with 813 additions and 35 deletions
+7 -7
View File
@@ -12,14 +12,14 @@
## Задачи
- [ ] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`.
- [x] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`.
Свободная комната остаётся без скобок
- [ ] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
- [ ] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
- [ ] В карточке человека — его личное расписание, рядом с навыками и семьёй
- [ ] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
- [ ] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
- [ ] Строки через `t(...)`, обе локали
- [x] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
- [x] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
- [x] В карточке человека — его личное расписание, рядом с навыками и семьёй
- [x] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
- [x] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
- [x] Строки через `t(...)`, обе локали
## Критерий готовности
+1 -1
View File
@@ -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) | | Скобки в дереве, сетка класса, расписание учителя |
+10 -4
View File
@@ -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" }]
}
```
@@ -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('марта');
+12
View File
@@ -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);
@@ -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Б)');
});
});
+40
View File
@@ -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<Locale, Messages> = { ru, en };
+15
View File
@@ -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(
+77
View File
@@ -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 {
+15 -5
View File
@@ -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}`;
}
+113 -1
View File
@@ -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<void> {
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<void> {
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;
+34 -8
View File
@@ -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);
});
}
}
+410
View File
@@ -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<string, TimetableLesson>();
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<void> {
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<void> {
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;
}
+1
View File
@@ -52,6 +52,7 @@ internal sealed record PersonCardResponse(
IReadOnlyList<string> Roles,
int? ClassYear,
string? ClassLetter,
string? ClassId,
string? Position,
string? PositionLabel,
IReadOnlyList<LabeledStatResponse> Body,
+10 -2
View File
@@ -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(
+36 -4
View File
@@ -29,7 +29,9 @@ internal sealed record TimetableResponse(
int WeekDays,
int LessonCount,
IReadOnlyList<TimetableLessonResponse> Lessons,
IReadOnlyList<UncoveredLessonResponse> Uncovered);
IReadOnlyList<UncoveredLessonResponse> Uncovered,
IReadOnlyList<TimetableClassResponse> Classes,
IReadOnlyList<TimetableRoomResponse> 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<string, SchoolClass> classes,
IReadOnlyDictionary<string, Person> people,
DefCatalog catalog,
string locale)
string locale,
IReadOnlyDictionary<string, string> 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<string, string> RoomNames(DefCatalog catalog, MapLayout? map, string locale)
{
if (map is null)
{
return new Dictionary<string, string>(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);
}
}
+4 -2
View File
@@ -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);
@@ -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),
+6
View File
@@ -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
/// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot);
/// <summary>Frozen map instance. Safe to read from HTTP with the catalog; it does not mutate.</summary>
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);
}
/// <summary>
@@ -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<LessonResponse> Lessons,
IReadOnlyList<UncoveredResponse> Uncovered);
IReadOnlyList<UncoveredResponse> Uncovered,
IReadOnlyList<ClassResponse> Classes,
IReadOnlyList<RoomResponse> 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,