Enhance timetable functionality and UI integration
- Updated protocol documentation to include new `classId` and `roomLabel` fields in the timetable API responses. - Added classes and rooms to the timetable response structure, improving data accessibility for client applications. - Enhanced the UI components to display timetable information, including class and room details, in the management and people panels. - Implemented functionality to fetch and display personal timetables for individuals, ensuring a comprehensive view of schedules. - Revised localization strings to support new timetable features and improve user experience. - Added tests to validate the new timetable functionalities and ensure robustness in handling timetable data.
This commit is contained in:
@@ -12,14 +12,14 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`.
|
- [x] В дереве карты рядом с комнатой — что и у кого там сейчас: `Кабинет 204 (Математика · 5Б)`.
|
||||||
Свободная комната остаётся без скобок
|
Свободная комната остаётся без скобок
|
||||||
- [ ] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
|
- [x] Панель локации: «Сейчас» — урок и класс, «Персонажи» — учитель и ученики этого класса
|
||||||
- [ ] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
|
- [x] В «Управлении» — расписание класса: сетка «день × урок», выбор параллели
|
||||||
- [ ] В карточке человека — его личное расписание, рядом с навыками и семьёй
|
- [x] В карточке человека — его личное расписание, рядом с навыками и семьёй
|
||||||
- [ ] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
|
- [x] Правка урока из сетки: перенос и смена кабинета, видимая отметка «закреплено» и снятие
|
||||||
- [ ] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
|
- [x] Отказ планировщика (некуда поставить, кабинет занят) объясняется текстом, а не молчанием
|
||||||
- [ ] Строки через `t(...)`, обе локали
|
- [x] Строки через `t(...)`, обе локали
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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('марта');
|
||||||
|
|||||||
@@ -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Б)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user