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Б)`.
Свободная комната остаётся без скобок Свободная комната остаётся без скобок
- [ ] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса - [x] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
- [ ] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели - [x] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
- [ ] В карточке человека — его личное расписание, рядом с навыками и семьёй - [x] В карточке человека — его личное расписание, рядом с навыками и семьёй
- [ ] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие - [x] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
- [ ] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием - [x] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
- [ ] Строки через `t(...)`, обе локали - [x] Строки через `t(...)`, обе локали
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -67,4 +67,4 @@
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы | | [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
| [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета | | [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета |
| [16. Расписание в школе](16-timetable-in-school.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 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 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 ```json
{ {
@@ -208,6 +209,7 @@ cards.
"roles": ["student"], "roles": ["student"],
"classYear": 5, "classYear": 5,
"classLetter": "А", "classLetter": "А",
"classId": "class-classroom-105",
"body": [{ "id": "Height", "label": "Рост", "value": "164" }], "body": [{ "id": "Height", "label": "Рост", "value": "164" }],
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }], "skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }], "traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
@@ -334,8 +336,9 @@ assignment is `404` `unknown-assignment`.
### `GET /api/schools/{id}/timetable` ### `GET /api/schools/{id}/timetable`
The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter 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 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`. 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. `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", "teacherId": "f3.p1",
"teacherName": "Иванова Ольга Михайловна", "teacherName": "Иванова Ольга Михайловна",
"roomId": "classroom-204", "roomId": "classroom-204",
"roomLabel": "Класс 204",
"day": 1, "day": 1,
"period": 3, "period": 3,
"locked": false "locked": false
@@ -367,7 +371,9 @@ worker. Unknown `{id}` is `404` `unknown-school`.
"subjectLabel": "Информатика", "subjectLabel": "Информатика",
"hours": 1 "hours": 1
} }
] ],
"classes": [{ "id": "c5A", "year": 5, "letter": "A" }],
"rooms": [{ "id": "classroom-204", "label": "Класс 204" }]
} }
``` ```
@@ -5,8 +5,10 @@ import {
formatGameDateTime, formatGameDateTime,
formatGameTimeOfDay, formatGameTimeOfDay,
formatGameWeekday, formatGameWeekday,
formatGameWeekdayShort,
fromDateAndTimeInputs, fromDateAndTimeInputs,
toDateAndTimeInputs, toDateAndTimeInputs,
weekdayDate,
} from './gameTime.ts'; } from './gameTime.ts';
// The default start of a new school: 31 March 2012, 06:00 — a Saturday. // 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('суббота'); 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', () => { it('shows the full date', () => {
expect(formatGameDate(START)).toContain('2012'); expect(formatGameDate(START)).toContain('2012');
expect(formatGameDate(START)).toContain('марта'); expect(formatGameDate(START)).toContain('марта');
+12
View File
@@ -14,6 +14,7 @@ interface Formats {
readonly date: Intl.DateTimeFormat; readonly date: Intl.DateTimeFormat;
readonly time: Intl.DateTimeFormat; readonly time: Intl.DateTimeFormat;
readonly weekday: Intl.DateTimeFormat; readonly weekday: Intl.DateTimeFormat;
readonly weekdayShort: Intl.DateTimeFormat;
readonly short: Intl.DateTimeFormat; readonly short: Intl.DateTimeFormat;
} }
@@ -39,6 +40,7 @@ function formats(): Formats {
hour12: false, hour12: false,
}), }),
weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }), weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }),
weekdayShort: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'short' }),
short: new Intl.DateTimeFormat(tag, { short: new Intl.DateTimeFormat(tag, {
timeZone: UTC, timeZone: UTC,
day: '2-digit', day: '2-digit',
@@ -73,6 +75,16 @@ export function formatGameWeekday(date: Date): string {
return formats().weekday.format(date); 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. */ /** Compact form the school cards use — day-month-year in both languages. */
export function formatGameDateTime(date: Date): string { export function formatGameDateTime(date: Date): string {
return formats().short.format(date); 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('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' })) 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.'); .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: 'Предмет можно назначить только учителю.', staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
staffErrorAssigned: 'Этот предмет уже назначен.', staffErrorAssigned: 'Этот предмет уже назначен.',
staffErrorSubject: 'Такого предмета нет.', 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; } as const;
type Messages = { [K in keyof typeof ru]: string }; type Messages = { [K in keyof typeof ru]: string };
@@ -334,6 +354,26 @@ const en: Messages = {
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.', staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
staffErrorAssigned: 'That subject is already assigned.', staffErrorAssigned: 'That subject is already assigned.',
staffErrorSubject: 'That subject is not in the catalog.', 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 }; const catalogs: Record<Locale, Messages> = { ru, en };
+15
View File
@@ -257,6 +257,7 @@ export interface PersonCard {
readonly roles: readonly string[]; readonly roles: readonly string[];
readonly classYear: number | null; readonly classYear: number | null;
readonly classLetter: string | null; readonly classLetter: string | null;
readonly classId: string | null;
readonly position: string | null; readonly position: string | null;
readonly positionLabel: string | null; readonly positionLabel: string | null;
readonly body: readonly LabeledStat[]; readonly body: readonly LabeledStat[];
@@ -392,6 +393,7 @@ export interface TimetableLesson {
readonly teacherId: string; readonly teacherId: string;
readonly teacherName: string; readonly teacherName: string;
readonly roomId: string; readonly roomId: string;
readonly roomLabel: string;
readonly day: number; readonly day: number;
readonly period: number; readonly period: number;
readonly locked: boolean; readonly locked: boolean;
@@ -406,11 +408,24 @@ export interface UncoveredLesson {
readonly hours: number; 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 { export interface Timetable {
readonly weekDays: number; readonly weekDays: number;
readonly lessonCount: number; readonly lessonCount: number;
readonly lessons: readonly TimetableLesson[]; readonly lessons: readonly TimetableLesson[];
readonly uncovered: readonly UncoveredLesson[]; readonly uncovered: readonly UncoveredLesson[];
readonly classes: readonly TimetableClass[];
readonly rooms: readonly TimetableRoom[];
} }
export async function fetchTimetable( export async function fetchTimetable(
+77
View File
@@ -764,6 +764,83 @@ body {
max-height: 220px; 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. */ /* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) { @media (max-width: 900px) {
#app { #app {
+15 -5
View File
@@ -216,7 +216,7 @@ export class GameScreen {
const button = el('button', { const button = el('button', {
class: 'tree__button', class: 'tree__button',
type: 'button', type: 'button',
text: node.name, text: treeLabel(node),
onClick: () => this.select(node.id), onClick: () => this.select(node.id),
}); });
button.style.paddingLeft = `${8 + depth * 14}px`; button.style.paddingLeft = `${8 + depth * 14}px`;
@@ -287,10 +287,15 @@ export class GameScreen {
const pupilSlots = node?.pupilSlots ?? 0; const pupilSlots = node?.pupilSlots ?? 0;
this.pupilSlotsLine.hidden = pupilSlots <= 0; this.pupilSlotsLine.hidden = pupilSlots <= 0;
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : ''; this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
const activity = const activity = [];
node && (node.activitySubject.length > 0 || node.activityClass.length > 0) if (node?.activitySubject) {
? [[node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ')] activity.push(node.activitySubject);
: []; }
if (node?.activityClass) {
activity.push(node.activityClass);
}
paintList(this.activitiesList, this.activitiesEmpty, activity); paintList(this.activitiesList, this.activitiesEmpty, activity);
paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []); paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []);
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []); 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); 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 { function formatItem(item: MapSnapshotItem): string {
return item.count === 1 ? item.name : `${item.name} ×${item.count}`; return item.count === 1 ? item.name : `${item.name} ×${item.count}`;
} }
+113 -1
View File
@@ -3,17 +3,20 @@ import {
assignSubject, assignSubject,
fetchPerson, fetchPerson,
fetchStaffing, fetchStaffing,
fetchTimetable,
hireStaff, hireStaff,
unassignSubject, unassignSubject,
type PersonCard, type PersonCard,
type Staffing, type Staffing,
type StaffingApplicant, type StaffingApplicant,
type StaffMember, type StaffMember,
type Timetable,
} from '../net/api.ts'; } from '../net/api.ts';
import { getLocale, intlTag } from '../i18n/locale.ts'; import { getLocale, intlTag } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { renderPersonCard } from './personCard.ts'; import { renderPersonCard } from './personCard.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
const TEACHER = 'Teacher'; const TEACHER = 'Teacher';
@@ -40,12 +43,26 @@ export class ManagementPanel {
private readonly staffTitle = el('h3', { class: 'panel__section-title' }); private readonly staffTitle = el('h3', { class: 'panel__section-title' });
private readonly staffTable = el('table', { class: 'people__table' }); private readonly staffTable = el('table', { class: 'people__table' });
private readonly staffEmpty = el('p', { class: 'panel__empty' }); 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 card = el('aside', { class: 'panel__body people__card' });
private readonly positionSelect = el('select', { class: 'input people__input' }); private readonly positionSelect = el('select', { class: 'input people__input' });
private readonly subjectSelect = el('select', { class: 'input people__input' }); private readonly subjectSelect = el('select', { class: 'input people__input' });
private schoolId: number | null = null; private schoolId: number | null = null;
private staffing: Staffing | null = null; private staffing: Staffing | null = null;
private timetable: Timetable | null = null;
private classId: string | null = null;
private selection: Selection | null = null; private selection: Selection | null = null;
private loadToken = 0; private loadToken = 0;
private cardToken = 0; private cardToken = 0;
@@ -71,8 +88,19 @@ export class ManagementPanel {
this.staffTitle, this.staffTitle,
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty), 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.cardElement = this.card;
this.classSelect.addEventListener('change', () => {
this.classId = this.classSelect.value || null;
this.timetableGrid.setTable(this.timetable, this.classId);
});
this.localize(); this.localize();
} }
@@ -80,9 +108,14 @@ export class ManagementPanel {
this.uncoveredTitle.textContent = t('staffUncovered'); this.uncoveredTitle.textContent = t('staffUncovered');
this.applicantsTitle.textContent = t('staffApplicants'); this.applicantsTitle.textContent = t('staffApplicants');
this.staffTitle.textContent = t('staffHired'); this.staffTitle.textContent = t('staffHired');
this.timetableTitle.textContent = t('timetableTitle');
this.classLabel.textContent = t('timetableClass');
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty'); this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
this.applicantsEmpty.textContent = t('staffApplicantsEmpty'); this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
this.staffEmpty.textContent = t('staffHiredEmpty'); this.staffEmpty.textContent = t('staffHiredEmpty');
this.timetableGrid.localize();
this.personGrid.localize();
this.personTimetableTitle.textContent = t('timetableTitle');
this.paint(); this.paint();
if (this.selection !== null) { if (this.selection !== null) {
void this.openCard(this.selection); void this.openCard(this.selection);
@@ -97,9 +130,12 @@ export class ManagementPanel {
if (switched) { if (switched) {
this.selection = null; this.selection = null;
this.staffing = null; this.staffing = null;
this.timetable = null;
this.classId = null;
this.clearError(); this.clearError();
} }
this.timetableGrid.attach(schoolId);
void this.reload(); void this.reload();
} }
@@ -111,14 +147,20 @@ export class ManagementPanel {
const token = ++this.loadToken; const token = ++this.loadToken;
try { try {
const staffing = await fetchStaffing(schoolId, getLocale()); const [staffing, timetable] = await Promise.all([
fetchStaffing(schoolId, getLocale()),
fetchTimetable(schoolId, getLocale()),
]);
if (token !== this.loadToken) { if (token !== this.loadToken) {
return; return;
} }
this.staffing = staffing; this.staffing = staffing;
this.timetable = timetable;
this.syncClass(timetable);
this.syncSelection(); this.syncSelection();
this.paint(); this.paint();
this.timetableGrid.setTable(timetable, this.classId);
if (this.selection !== null) { if (this.selection !== null) {
void this.openCard(this.selection); void this.openCard(this.selection);
} else { } 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 { private syncSelection(): void {
const staffing = this.staffing; const staffing = this.staffing;
const selected = this.selection; const selected = this.selection;
@@ -158,6 +223,7 @@ export class ManagementPanel {
this.paintUncovered(staffing); this.paintUncovered(staffing);
this.paintApplicants(staffing); this.paintApplicants(staffing);
this.paintStaff(staffing); this.paintStaff(staffing);
this.paintClasses(this.timetable);
} }
private paintMoney(staffing: Staffing | null): void { private paintMoney(staffing: Staffing | null): void {
@@ -339,9 +405,37 @@ export class ManagementPanel {
} }
renderPersonCard(this.card, card, (id) => void this.openRelative(id)); renderPersonCard(this.card, card, (id) => void this.openRelative(id));
this.mountPersonTimetable(card);
this.appendActions(card.id); 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> { private async openRelative(personId: string): Promise<void> {
const staffing = this.staffing; const staffing = this.staffing;
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) { 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()); const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
this.staffing = staffing; this.staffing = staffing;
this.selection = { kind: 'staff', id: personId }; this.selection = { kind: 'staff', id: personId };
await this.refreshTimetable();
} catch (error) { } catch (error) {
this.showError(actionError(error)); this.showError(actionError(error));
} finally { } finally {
@@ -492,6 +587,7 @@ export class ManagementPanel {
try { try {
this.staffing = await assignSubject(schoolId, personId, subject, getLocale()); this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
this.selection = { kind: 'staff', id: personId }; this.selection = { kind: 'staff', id: personId };
await this.refreshTimetable();
} catch (error) { } catch (error) {
this.showError(actionError(error)); this.showError(actionError(error));
} finally { } finally {
@@ -514,6 +610,7 @@ export class ManagementPanel {
try { try {
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale()); this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
this.selection = { kind: 'staff', id: personId }; this.selection = { kind: 'staff', id: personId };
await this.refreshTimetable();
} catch (error) { } catch (error) {
this.showError(actionError(error)); this.showError(actionError(error));
} finally { } 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 { private showError(message: string): void {
this.error.hidden = false; this.error.hidden = false;
this.error.textContent = message; this.error.textContent = message;
+34 -8
View File
@@ -1,15 +1,9 @@
import { import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts';
fetchPeople,
fetchPerson,
type PeoplePage,
type PersonCard,
type PersonRole,
type PersonSort,
} from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts'; import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts'; import { clear, el } from './dom.ts';
import { placement, renderPersonCard, roleLabels } from './personCard.ts'; import { placement, renderPersonCard, roleLabels } from './personCard.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
{ sort: 'surname', label: 'peopleColName' }, { sort: 'surname', label: 'peopleColName' },
@@ -55,6 +49,8 @@ export class PeoplePanel {
private readonly prevButton = el('button', { class: 'button button--small', type: 'button' }); private readonly prevButton = el('button', { class: 'button button--small', type: 'button' });
private readonly nextButton = 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 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 schoolId: number | null = null;
private sort: PersonSort = 'surname'; private sort: PersonSort = 'surname';
@@ -125,6 +121,8 @@ export class PeoplePanel {
this.prevButton.textContent = t('peoplePrev'); this.prevButton.textContent = t('peoplePrev');
this.nextButton.textContent = t('peopleNext'); this.nextButton.textContent = t('peopleNext');
this.empty.textContent = t('peopleEmpty'); this.empty.textContent = t('peopleEmpty');
this.personTimetableTitle.textContent = t('timetableTitle');
this.personGrid.localize();
fillFixedSelect(this.roleSelect, [ fillFixedSelect(this.roleSelect, [
{ value: '', label: t('peopleRoleAll') }, { value: '', label: t('peopleRoleAll') },
{ value: 'student', label: t('peopleRoleStudent') }, { value: 'student', label: t('peopleRoleStudent') },
@@ -357,6 +355,34 @@ export class PeoplePanel {
} }
renderPersonCard(this.card, card, (id) => void this.openCard(id)); 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, IReadOnlyList<string> Roles,
int? ClassYear, int? ClassYear,
string? ClassLetter, string? ClassLetter,
string? ClassId,
string? Position, string? Position,
string? PositionLabel, string? PositionLabel,
IReadOnlyList<LabeledStatResponse> Body, IReadOnlyList<LabeledStatResponse> Body,
+10 -2
View File
@@ -111,10 +111,18 @@ internal static class TimetableEndpoints
var roster = published.Roster ?? new Roster([], [], []); var roster = published.Roster ?? new Roster([], [], []);
if (published.Catalog is null) 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( private static IResult TimetableResult(
+36 -4
View File
@@ -29,7 +29,9 @@ internal sealed record TimetableResponse(
int WeekDays, int WeekDays,
int LessonCount, int LessonCount,
IReadOnlyList<TimetableLessonResponse> Lessons, IReadOnlyList<TimetableLessonResponse> Lessons,
IReadOnlyList<UncoveredLessonResponse> Uncovered); IReadOnlyList<UncoveredLessonResponse> Uncovered,
IReadOnlyList<TimetableClassResponse> Classes,
IReadOnlyList<TimetableRoomResponse> Rooms);
internal sealed record TimetableLessonResponse( internal sealed record TimetableLessonResponse(
string ClassId, string ClassId,
@@ -40,6 +42,7 @@ internal sealed record TimetableLessonResponse(
string TeacherId, string TeacherId,
string TeacherName, string TeacherName,
string RoomId, string RoomId,
string RoomLabel,
int Day, int Day,
int Period, int Period,
bool Locked); bool Locked);
@@ -52,12 +55,17 @@ internal sealed record UncoveredLessonResponse(
string SubjectLabel, string SubjectLabel,
int Hours); int Hours);
internal sealed record TimetableClassResponse(string Id, int Year, string Letter);
internal sealed record TimetableRoomResponse(string Id, string Label);
internal static class TimetableMapper internal static class TimetableMapper
{ {
public static TimetableResponse From( public static TimetableResponse From(
Timetable table, Timetable table,
Roster roster, Roster roster,
DefCatalog catalog, DefCatalog catalog,
MapLayout? map,
int weekDays, int weekDays,
string locale, string locale,
string? classId, string? classId,
@@ -65,6 +73,7 @@ internal static class TimetableMapper
{ {
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var classes = roster.Classes.ToDictionary(item => item.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 lessons = table.Lessons.AsEnumerable();
var uncovered = table.Uncovered.AsEnumerable(); var uncovered = table.Uncovered.AsEnumerable();
if (!string.IsNullOrWhiteSpace(classId)) if (!string.IsNullOrWhiteSpace(classId))
@@ -81,8 +90,15 @@ internal static class TimetableMapper
return new TimetableResponse( return new TimetableResponse(
weekDays, weekDays,
catalog.DayFrame?.LessonCount ?? 0, catalog.DayFrame?.LessonCount ?? 0,
lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale)).ToArray(), lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale, roomNames)).ToArray(),
uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).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( private static TimetableLessonResponse MapLesson(
@@ -90,7 +106,8 @@ internal static class TimetableMapper
IReadOnlyDictionary<string, SchoolClass> classes, IReadOnlyDictionary<string, SchoolClass> classes,
IReadOnlyDictionary<string, Person> people, IReadOnlyDictionary<string, Person> people,
DefCatalog catalog, DefCatalog catalog,
string locale) string locale,
IReadOnlyDictionary<string, string> roomNames)
{ {
classes.TryGetValue(lesson.ClassId, out var schoolClass); classes.TryGetValue(lesson.ClassId, out var schoolClass);
people.TryGetValue(lesson.TeacherId, out var teacher); people.TryGetValue(lesson.TeacherId, out var teacher);
@@ -107,6 +124,7 @@ internal static class TimetableMapper
lesson.TeacherId, lesson.TeacherId,
teacher?.Name.Full ?? lesson.TeacherId, teacher?.Name.Full ?? lesson.TeacherId,
lesson.RoomId, lesson.RoomId,
roomNames.GetValueOrDefault(lesson.RoomId, lesson.RoomId),
lesson.Day, lesson.Day,
lesson.Period, lesson.Period,
lesson.Locked); lesson.Locked);
@@ -131,4 +149,18 @@ internal static class TimetableMapper
subjectLabel, subjectLabel,
row.Hours); 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.RosterSnapshot,
worker.ApplicantSnapshot, worker.ApplicantSnapshot,
worker.CatalogSnapshot, worker.CatalogSnapshot,
worker.TimetableSnapshot); worker.TimetableSnapshot,
worker.MapSnapshot);
} }
} }
@@ -643,4 +644,5 @@ internal sealed record PublishedSchoolPeople(
Roster? Roster, Roster? Roster,
ApplicantPool? Applicants, ApplicantPool? Applicants,
DefCatalog? Catalog, DefCatalog? Catalog,
Timetable? Timetable); Timetable? Timetable,
MapLayout? Map);
@@ -54,6 +54,7 @@ internal static class PersonCardReader
PeopleListMapper.RolesOf(person), PeopleListMapper.RolesOf(person),
year, year,
letter, letter,
person.ClassId,
person.Position, person.Position,
PeopleListMapper.PositionLabel(catalog, locale, person.Position), PeopleListMapper.PositionLabel(catalog, locale, person.Position),
Body(person, catalog, locale), Body(person, catalog, locale),
+6
View File
@@ -46,6 +46,7 @@ internal sealed class SchoolWorker
private ApplicantPool? _applicantSnapshot; private ApplicantPool? _applicantSnapshot;
private DefCatalog? _catalogSnapshot; private DefCatalog? _catalogSnapshot;
private Timetable? _timetableSnapshot; private Timetable? _timetableSnapshot;
private MapLayout? _mapSnapshot;
private OccupancyKey _occupancyKey; private OccupancyKey _occupancyKey;
private School? _school; private School? _school;
private Task? _run; 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> /// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot); 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() public void Start()
{ {
_run = Task.Factory.StartNew( _run = Task.Factory.StartNew(
@@ -205,6 +209,7 @@ internal sealed class SchoolWorker
var catalog = _mods.LoadCatalog(packIds, _logger); var catalog = _mods.LoadCatalog(packIds, _logger);
Volatile.Write(ref _catalogSnapshot, catalog); Volatile.Write(ref _catalogSnapshot, catalog);
var map = _mods.LoadMap(packIds, _savedMap); var map = _mods.LoadMap(packIds, _savedMap);
Volatile.Write(ref _mapSnapshot, map);
try try
{ {
MapValidator.Validate(map, catalog); MapValidator.Validate(map, catalog);
@@ -565,6 +570,7 @@ internal sealed class SchoolWorker
Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _rosterSnapshot, school.Roster);
Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _applicantSnapshot, school.Applicants);
Volatile.Write(ref _timetableSnapshot, school.Timetable); Volatile.Write(ref _timetableSnapshot, school.Timetable);
Volatile.Write(ref _mapSnapshot, school.Map);
} }
/// <summary> /// <summary>
@@ -31,6 +31,9 @@ public class TimetableApiTests(AppHostFixture fixture)
Assert.Equal(7, table.LessonCount); Assert.Equal(7, table.LessonCount);
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher); Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher);
Assert.DoesNotContain(table.Uncovered, row => row.Subject == "Mathematics"); 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( using var unassign = await client.DeleteAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics", $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics",
@@ -184,7 +187,9 @@ public class TimetableApiTests(AppHostFixture fixture)
int WeekDays, int WeekDays,
int LessonCount, int LessonCount,
IReadOnlyList<LessonResponse> Lessons, IReadOnlyList<LessonResponse> Lessons,
IReadOnlyList<UncoveredResponse> Uncovered); IReadOnlyList<UncoveredResponse> Uncovered,
IReadOnlyList<ClassResponse> Classes,
IReadOnlyList<RoomResponse> Rooms);
private sealed record LessonResponse( private sealed record LessonResponse(
string ClassId, string ClassId,
@@ -195,10 +200,15 @@ public class TimetableApiTests(AppHostFixture fixture)
string TeacherId, string TeacherId,
string TeacherName, string TeacherName,
string RoomId, string RoomId,
string RoomLabel,
int Day, int Day,
int Period, int Period,
bool Locked); bool Locked);
private sealed record ClassResponse(string Id, int Year, string Letter);
private sealed record RoomResponse(string Id, string Label);
private sealed record UncoveredResponse( private sealed record UncoveredResponse(
string ClassId, string ClassId,
int ClassYear, int ClassYear,