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:
@@ -5,8 +5,10 @@ import {
|
||||
formatGameDateTime,
|
||||
formatGameTimeOfDay,
|
||||
formatGameWeekday,
|
||||
formatGameWeekdayShort,
|
||||
fromDateAndTimeInputs,
|
||||
toDateAndTimeInputs,
|
||||
weekdayDate,
|
||||
} from './gameTime.ts';
|
||||
|
||||
// The default start of a new school: 31 March 2012, 06:00 — a Saturday.
|
||||
@@ -25,6 +27,12 @@ describe('game time formatting', () => {
|
||||
expect(formatGameWeekday(START)).toBe('суббота');
|
||||
});
|
||||
|
||||
it('shows short weekday names for timetable columns', () => {
|
||||
expect(formatGameWeekdayShort(weekdayDate(0)).toLowerCase()).toContain('пн');
|
||||
setLocale('en');
|
||||
expect(formatGameWeekdayShort(weekdayDate(0)).toLowerCase()).toContain('mon');
|
||||
});
|
||||
|
||||
it('shows the full date', () => {
|
||||
expect(formatGameDate(START)).toContain('2012');
|
||||
expect(formatGameDate(START)).toContain('марта');
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Formats {
|
||||
readonly date: Intl.DateTimeFormat;
|
||||
readonly time: Intl.DateTimeFormat;
|
||||
readonly weekday: Intl.DateTimeFormat;
|
||||
readonly weekdayShort: Intl.DateTimeFormat;
|
||||
readonly short: Intl.DateTimeFormat;
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ function formats(): Formats {
|
||||
hour12: false,
|
||||
}),
|
||||
weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }),
|
||||
weekdayShort: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'short' }),
|
||||
short: new Intl.DateTimeFormat(tag, {
|
||||
timeZone: UTC,
|
||||
day: '2-digit',
|
||||
@@ -73,6 +75,16 @@ export function formatGameWeekday(date: Date): string {
|
||||
return formats().weekday.format(date);
|
||||
}
|
||||
|
||||
/** "пн" / "Mon" — column headers on the timetable grid. */
|
||||
export function formatGameWeekdayShort(date: Date): string {
|
||||
return formats().weekdayShort.format(date);
|
||||
}
|
||||
|
||||
/** Monday=0 … Sunday=6, same numbering the timetable uses. */
|
||||
export function weekdayDate(day: number): Date {
|
||||
return new Date(Date.UTC(2012, 3, 2 + day, 12, 0, 0));
|
||||
}
|
||||
|
||||
/** Compact form the school cards use — day-month-year in both languages. */
|
||||
export function formatGameDateTime(date: Date): string {
|
||||
return formats().short.format(date);
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('t', () => {
|
||||
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
|
||||
expect(t('staffErrorPayroll', { allocated: '10 000', payroll: '8 000', remaining: '2 000', attempted: '12 000' }))
|
||||
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
||||
.toBe('Кабинет 204 (Математика · 5Б)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -165,6 +165,26 @@ const ru = {
|
||||
staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
|
||||
staffErrorAssigned: 'Этот предмет уже назначен.',
|
||||
staffErrorSubject: 'Такого предмета нет.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
timetableTitle: 'Расписание',
|
||||
timetableClass: 'Класс',
|
||||
timetableEmpty: 'Нет уроков.',
|
||||
timetableLoadFailed: 'Не удалось загрузить расписание.',
|
||||
timetablePeriod: '{n}',
|
||||
timetableLocked: 'закреплено',
|
||||
timetableDay: 'День',
|
||||
timetablePeriodLabel: 'Урок',
|
||||
timetableRoom: 'Кабинет',
|
||||
timetableApply: 'Закрепить здесь',
|
||||
timetableUnpin: 'Снять закрепление',
|
||||
timetablePickLesson: 'Выберите урок в сетке, чтобы перенести или сменить кабинет.',
|
||||
timetableUncovered: '{label} — {hours} ч.',
|
||||
timetableUncoveredTitle: 'Не покрыто',
|
||||
timetableErrorPin: 'Сюда поставить нельзя: слот или кабинет заняты, либо класс туда не влезает.',
|
||||
timetableErrorNoTeacher: 'Некому вести этот предмет.',
|
||||
timetableErrorUnknown: 'Не удалось изменить урок.',
|
||||
timetableErrorLesson: 'Этого закрепления уже нет.',
|
||||
} as const;
|
||||
|
||||
type Messages = { [K in keyof typeof ru]: string };
|
||||
@@ -334,6 +354,26 @@ const en: Messages = {
|
||||
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
|
||||
staffErrorAssigned: 'That subject is already assigned.',
|
||||
staffErrorSubject: 'That subject is not in the catalog.',
|
||||
|
||||
mapOccupancy: '{name} ({activity})',
|
||||
timetableTitle: 'Timetable',
|
||||
timetableClass: 'Class',
|
||||
timetableEmpty: 'No lessons.',
|
||||
timetableLoadFailed: 'Could not load the timetable.',
|
||||
timetablePeriod: '{n}',
|
||||
timetableLocked: 'pinned',
|
||||
timetableDay: 'Day',
|
||||
timetablePeriodLabel: 'Period',
|
||||
timetableRoom: 'Room',
|
||||
timetableApply: 'Pin here',
|
||||
timetableUnpin: 'Unpin',
|
||||
timetablePickLesson: 'Pick a lesson in the grid to move it or change the room.',
|
||||
timetableUncovered: '{label} — {hours} h.',
|
||||
timetableUncoveredTitle: 'Uncovered',
|
||||
timetableErrorPin: 'Cannot place that lesson: the slot or room is taken, or the class does not fit.',
|
||||
timetableErrorNoTeacher: 'Nobody is assigned that subject.',
|
||||
timetableErrorUnknown: 'Could not change the lesson.',
|
||||
timetableErrorLesson: 'That pinned lesson is gone.',
|
||||
};
|
||||
|
||||
const catalogs: Record<Locale, Messages> = { ru, en };
|
||||
|
||||
@@ -257,6 +257,7 @@ export interface PersonCard {
|
||||
readonly roles: readonly string[];
|
||||
readonly classYear: number | null;
|
||||
readonly classLetter: string | null;
|
||||
readonly classId: string | null;
|
||||
readonly position: string | null;
|
||||
readonly positionLabel: string | null;
|
||||
readonly body: readonly LabeledStat[];
|
||||
@@ -392,6 +393,7 @@ export interface TimetableLesson {
|
||||
readonly teacherId: string;
|
||||
readonly teacherName: string;
|
||||
readonly roomId: string;
|
||||
readonly roomLabel: string;
|
||||
readonly day: number;
|
||||
readonly period: number;
|
||||
readonly locked: boolean;
|
||||
@@ -406,11 +408,24 @@ export interface UncoveredLesson {
|
||||
readonly hours: number;
|
||||
}
|
||||
|
||||
export interface TimetableClass {
|
||||
readonly id: string;
|
||||
readonly year: number;
|
||||
readonly letter: string;
|
||||
}
|
||||
|
||||
export interface TimetableRoom {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface Timetable {
|
||||
readonly weekDays: number;
|
||||
readonly lessonCount: number;
|
||||
readonly lessons: readonly TimetableLesson[];
|
||||
readonly uncovered: readonly UncoveredLesson[];
|
||||
readonly classes: readonly TimetableClass[];
|
||||
readonly rooms: readonly TimetableRoom[];
|
||||
}
|
||||
|
||||
export async function fetchTimetable(
|
||||
|
||||
@@ -764,6 +764,83 @@ body {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.timetable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timetable__wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.timetable__grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timetable__grid th,
|
||||
.timetable__grid td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.timetable__grid thead th {
|
||||
background: var(--surface-sunken);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timetable__grid tbody th {
|
||||
background: var(--surface-sunken);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.timetable__cell {
|
||||
min-width: 6.5em;
|
||||
}
|
||||
|
||||
.timetable--editable .timetable__cell:not(.timetable__cell--empty) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timetable__cell--empty {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.timetable__cell--locked {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.timetable__cell--active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.timetable__subject {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timetable__meta {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.timetable__editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Below three columns the panels stop competing for height and the page scrolls instead. */
|
||||
@media (max-width: 900px) {
|
||||
#app {
|
||||
|
||||
@@ -216,7 +216,7 @@ export class GameScreen {
|
||||
const button = el('button', {
|
||||
class: 'tree__button',
|
||||
type: 'button',
|
||||
text: node.name,
|
||||
text: treeLabel(node),
|
||||
onClick: () => this.select(node.id),
|
||||
});
|
||||
button.style.paddingLeft = `${8 + depth * 14}px`;
|
||||
@@ -287,10 +287,15 @@ export class GameScreen {
|
||||
const pupilSlots = node?.pupilSlots ?? 0;
|
||||
this.pupilSlotsLine.hidden = pupilSlots <= 0;
|
||||
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
|
||||
const activity =
|
||||
node && (node.activitySubject.length > 0 || node.activityClass.length > 0)
|
||||
? [[node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ')]
|
||||
: [];
|
||||
const activity = [];
|
||||
if (node?.activitySubject) {
|
||||
activity.push(node.activitySubject);
|
||||
}
|
||||
|
||||
if (node?.activityClass) {
|
||||
activity.push(node.activityClass);
|
||||
}
|
||||
|
||||
paintList(this.activitiesList, this.activitiesEmpty, activity);
|
||||
paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []);
|
||||
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
|
||||
@@ -318,6 +323,11 @@ function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSna
|
||||
return nodes.filter((node) => node.parentId === parentId);
|
||||
}
|
||||
|
||||
function treeLabel(node: MapSnapshotNode): string {
|
||||
const activity = [node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ');
|
||||
return activity.length > 0 ? t('mapOccupancy', { name: node.name, activity }) : node.name;
|
||||
}
|
||||
|
||||
function formatItem(item: MapSnapshotItem): string {
|
||||
return item.count === 1 ? item.name : `${item.name} ×${item.count}`;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ import {
|
||||
assignSubject,
|
||||
fetchPerson,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
hireStaff,
|
||||
unassignSubject,
|
||||
type PersonCard,
|
||||
type Staffing,
|
||||
type StaffingApplicant,
|
||||
type StaffMember,
|
||||
type Timetable,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { renderPersonCard } from './personCard.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const TEACHER = 'Teacher';
|
||||
|
||||
@@ -40,12 +43,26 @@ export class ManagementPanel {
|
||||
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly staffTable = el('table', { class: 'people__table' });
|
||||
private readonly staffEmpty = el('p', { class: 'panel__empty' });
|
||||
private readonly timetableTitle = el('h3', { class: 'panel__section-title' });
|
||||
private readonly classLabel = el('span', { class: 'people__label' });
|
||||
private readonly classSelect = el('select', { class: 'input people__input' });
|
||||
private readonly timetableGrid = new TimetableGrid({
|
||||
editable: true,
|
||||
showClass: false,
|
||||
onTable: (table) => {
|
||||
this.timetable = table;
|
||||
},
|
||||
});
|
||||
private readonly personGrid = new TimetableGrid({ editable: false, showClass: true });
|
||||
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
|
||||
private readonly card = el('aside', { class: 'panel__body people__card' });
|
||||
private readonly positionSelect = el('select', { class: 'input people__input' });
|
||||
private readonly subjectSelect = el('select', { class: 'input people__input' });
|
||||
|
||||
private schoolId: number | null = null;
|
||||
private staffing: Staffing | null = null;
|
||||
private timetable: Timetable | null = null;
|
||||
private classId: string | null = null;
|
||||
private selection: Selection | null = null;
|
||||
private loadToken = 0;
|
||||
private cardToken = 0;
|
||||
@@ -71,8 +88,19 @@ export class ManagementPanel {
|
||||
this.staffTitle,
|
||||
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'panel__section' },
|
||||
this.timetableTitle,
|
||||
el('label', { class: 'people__field' }, this.classLabel, this.classSelect),
|
||||
this.timetableGrid.element,
|
||||
),
|
||||
);
|
||||
this.cardElement = this.card;
|
||||
this.classSelect.addEventListener('change', () => {
|
||||
this.classId = this.classSelect.value || null;
|
||||
this.timetableGrid.setTable(this.timetable, this.classId);
|
||||
});
|
||||
this.localize();
|
||||
}
|
||||
|
||||
@@ -80,9 +108,14 @@ export class ManagementPanel {
|
||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||
this.applicantsTitle.textContent = t('staffApplicants');
|
||||
this.staffTitle.textContent = t('staffHired');
|
||||
this.timetableTitle.textContent = t('timetableTitle');
|
||||
this.classLabel.textContent = t('timetableClass');
|
||||
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
||||
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
|
||||
this.staffEmpty.textContent = t('staffHiredEmpty');
|
||||
this.timetableGrid.localize();
|
||||
this.personGrid.localize();
|
||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||
this.paint();
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
@@ -97,9 +130,12 @@ export class ManagementPanel {
|
||||
if (switched) {
|
||||
this.selection = null;
|
||||
this.staffing = null;
|
||||
this.timetable = null;
|
||||
this.classId = null;
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
this.timetableGrid.attach(schoolId);
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
@@ -111,14 +147,20 @@ export class ManagementPanel {
|
||||
|
||||
const token = ++this.loadToken;
|
||||
try {
|
||||
const staffing = await fetchStaffing(schoolId, getLocale());
|
||||
const [staffing, timetable] = await Promise.all([
|
||||
fetchStaffing(schoolId, getLocale()),
|
||||
fetchTimetable(schoolId, getLocale()),
|
||||
]);
|
||||
if (token !== this.loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.staffing = staffing;
|
||||
this.timetable = timetable;
|
||||
this.syncClass(timetable);
|
||||
this.syncSelection();
|
||||
this.paint();
|
||||
this.timetableGrid.setTable(timetable, this.classId);
|
||||
if (this.selection !== null) {
|
||||
void this.openCard(this.selection);
|
||||
} else {
|
||||
@@ -133,6 +175,29 @@ export class ManagementPanel {
|
||||
}
|
||||
}
|
||||
|
||||
private syncClass(timetable: Timetable): void {
|
||||
const ids = timetable.classes.map((row) => row.id);
|
||||
if (this.classId !== null && ids.includes(this.classId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.classId = ids[0] ?? null;
|
||||
}
|
||||
|
||||
private paintClasses(timetable: Timetable | null): void {
|
||||
const current = this.classId ?? '';
|
||||
this.classSelect.replaceChildren();
|
||||
for (const row of timetable?.classes ?? []) {
|
||||
const option = el('option', { text: `${row.year}${row.letter}` });
|
||||
option.value = row.id;
|
||||
this.classSelect.append(option);
|
||||
}
|
||||
|
||||
if ([...this.classSelect.options].some((option) => option.value === current)) {
|
||||
this.classSelect.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
private syncSelection(): void {
|
||||
const staffing = this.staffing;
|
||||
const selected = this.selection;
|
||||
@@ -158,6 +223,7 @@ export class ManagementPanel {
|
||||
this.paintUncovered(staffing);
|
||||
this.paintApplicants(staffing);
|
||||
this.paintStaff(staffing);
|
||||
this.paintClasses(this.timetable);
|
||||
}
|
||||
|
||||
private paintMoney(staffing: Staffing | null): void {
|
||||
@@ -339,9 +405,37 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id));
|
||||
this.mountPersonTimetable(card);
|
||||
this.appendActions(card.id);
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
.then((table) => {
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.personGrid.setTable(table);
|
||||
})
|
||||
.catch(() => {
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.personGrid.setTable(null);
|
||||
});
|
||||
}
|
||||
|
||||
private async openRelative(personId: string): Promise<void> {
|
||||
const staffing = this.staffing;
|
||||
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
||||
@@ -469,6 +563,7 @@ export class ManagementPanel {
|
||||
const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
|
||||
this.staffing = staffing;
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
await this.refreshTimetable();
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
@@ -492,6 +587,7 @@ export class ManagementPanel {
|
||||
try {
|
||||
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
await this.refreshTimetable();
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
@@ -514,6 +610,7 @@ export class ManagementPanel {
|
||||
try {
|
||||
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
|
||||
this.selection = { kind: 'staff', id: personId };
|
||||
await this.refreshTimetable();
|
||||
} catch (error) {
|
||||
this.showError(actionError(error));
|
||||
} finally {
|
||||
@@ -525,6 +622,21 @@ export class ManagementPanel {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshTimetable(): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
if (schoolId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.timetable = await fetchTimetable(schoolId, getLocale());
|
||||
this.syncClass(this.timetable);
|
||||
this.timetableGrid.setTable(this.timetable, this.classId);
|
||||
} catch {
|
||||
this.showError(t('timetableLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.error.hidden = false;
|
||||
this.error.textContent = message;
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
fetchPeople,
|
||||
fetchPerson,
|
||||
type PeoplePage,
|
||||
type PersonCard,
|
||||
type PersonRole,
|
||||
type PersonSort,
|
||||
} from '../net/api.ts';
|
||||
import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||
{ sort: 'surname', label: 'peopleColName' },
|
||||
@@ -55,6 +49,8 @@ export class PeoplePanel {
|
||||
private readonly prevButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
private readonly nextButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
private readonly card = el('aside', { class: 'panel__body people__card' });
|
||||
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
|
||||
private readonly personGrid = new TimetableGrid({ editable: false, showClass: true });
|
||||
|
||||
private schoolId: number | null = null;
|
||||
private sort: PersonSort = 'surname';
|
||||
@@ -125,6 +121,8 @@ export class PeoplePanel {
|
||||
this.prevButton.textContent = t('peoplePrev');
|
||||
this.nextButton.textContent = t('peopleNext');
|
||||
this.empty.textContent = t('peopleEmpty');
|
||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||
this.personGrid.localize();
|
||||
fillFixedSelect(this.roleSelect, [
|
||||
{ value: '', label: t('peopleRoleAll') },
|
||||
{ value: 'student', label: t('peopleRoleStudent') },
|
||||
@@ -357,6 +355,34 @@ export class PeoplePanel {
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id));
|
||||
this.mountPersonTimetable(card);
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
.then((table) => {
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.personGrid.setTable(table);
|
||||
})
|
||||
.catch(() => {
|
||||
if (token !== this.cardToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.personGrid.setTable(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
int? ClassYear,
|
||||
string? ClassLetter,
|
||||
string? ClassId,
|
||||
string? Position,
|
||||
string? PositionLabel,
|
||||
IReadOnlyList<LabeledStatResponse> Body,
|
||||
|
||||
@@ -111,10 +111,18 @@ internal static class TimetableEndpoints
|
||||
var roster = published.Roster ?? new Roster([], [], []);
|
||||
if (published.Catalog is null)
|
||||
{
|
||||
return new TimetableResponse(weekDays, 0, [], []);
|
||||
return new TimetableResponse(weekDays, 0, [], [], [], []);
|
||||
}
|
||||
|
||||
return TimetableMapper.From(table, roster, published.Catalog, weekDays, locale, classId, personId);
|
||||
return TimetableMapper.From(
|
||||
table,
|
||||
roster,
|
||||
published.Catalog,
|
||||
published.Map,
|
||||
weekDays,
|
||||
locale,
|
||||
classId,
|
||||
personId);
|
||||
}
|
||||
|
||||
private static IResult TimetableResult(
|
||||
|
||||
@@ -29,7 +29,9 @@ internal sealed record TimetableResponse(
|
||||
int WeekDays,
|
||||
int LessonCount,
|
||||
IReadOnlyList<TimetableLessonResponse> Lessons,
|
||||
IReadOnlyList<UncoveredLessonResponse> Uncovered);
|
||||
IReadOnlyList<UncoveredLessonResponse> Uncovered,
|
||||
IReadOnlyList<TimetableClassResponse> Classes,
|
||||
IReadOnlyList<TimetableRoomResponse> Rooms);
|
||||
|
||||
internal sealed record TimetableLessonResponse(
|
||||
string ClassId,
|
||||
@@ -40,6 +42,7 @@ internal sealed record TimetableLessonResponse(
|
||||
string TeacherId,
|
||||
string TeacherName,
|
||||
string RoomId,
|
||||
string RoomLabel,
|
||||
int Day,
|
||||
int Period,
|
||||
bool Locked);
|
||||
@@ -52,12 +55,17 @@ internal sealed record UncoveredLessonResponse(
|
||||
string SubjectLabel,
|
||||
int Hours);
|
||||
|
||||
internal sealed record TimetableClassResponse(string Id, int Year, string Letter);
|
||||
|
||||
internal sealed record TimetableRoomResponse(string Id, string Label);
|
||||
|
||||
internal static class TimetableMapper
|
||||
{
|
||||
public static TimetableResponse From(
|
||||
Timetable table,
|
||||
Roster roster,
|
||||
DefCatalog catalog,
|
||||
MapLayout? map,
|
||||
int weekDays,
|
||||
string locale,
|
||||
string? classId,
|
||||
@@ -65,6 +73,7 @@ internal static class TimetableMapper
|
||||
{
|
||||
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
||||
var classes = roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||
var roomNames = RoomNames(catalog, map, locale);
|
||||
var lessons = table.Lessons.AsEnumerable();
|
||||
var uncovered = table.Uncovered.AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(classId))
|
||||
@@ -81,8 +90,15 @@ internal static class TimetableMapper
|
||||
return new TimetableResponse(
|
||||
weekDays,
|
||||
catalog.DayFrame?.LessonCount ?? 0,
|
||||
lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale)).ToArray(),
|
||||
uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray());
|
||||
lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale, roomNames)).ToArray(),
|
||||
uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray(),
|
||||
roster.Classes
|
||||
.OrderBy(item => item.Year)
|
||||
.ThenBy(item => item.Letter, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||
.Select(item => new TimetableClassResponse(item.Id, item.Year, item.Letter))
|
||||
.ToArray(),
|
||||
roomNames.Select(pair => new TimetableRoomResponse(pair.Key, pair.Value)).ToArray());
|
||||
}
|
||||
|
||||
private static TimetableLessonResponse MapLesson(
|
||||
@@ -90,7 +106,8 @@ internal static class TimetableMapper
|
||||
IReadOnlyDictionary<string, SchoolClass> classes,
|
||||
IReadOnlyDictionary<string, Person> people,
|
||||
DefCatalog catalog,
|
||||
string locale)
|
||||
string locale,
|
||||
IReadOnlyDictionary<string, string> roomNames)
|
||||
{
|
||||
classes.TryGetValue(lesson.ClassId, out var schoolClass);
|
||||
people.TryGetValue(lesson.TeacherId, out var teacher);
|
||||
@@ -107,6 +124,7 @@ internal static class TimetableMapper
|
||||
lesson.TeacherId,
|
||||
teacher?.Name.Full ?? lesson.TeacherId,
|
||||
lesson.RoomId,
|
||||
roomNames.GetValueOrDefault(lesson.RoomId, lesson.RoomId),
|
||||
lesson.Day,
|
||||
lesson.Period,
|
||||
lesson.Locked);
|
||||
@@ -131,4 +149,18 @@ internal static class TimetableMapper
|
||||
subjectLabel,
|
||||
row.Hours);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> RoomNames(DefCatalog catalog, MapLayout? map, string locale)
|
||||
{
|
||||
if (map is null)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return MapView.Build(catalog, map, locale)
|
||||
.Where(node => node.Kind == MapNodeKind.Room)
|
||||
.OrderBy(node => node.Name, StringComparer.Ordinal)
|
||||
.ThenBy(node => node.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(node => node.Id, node => node.Name, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ internal sealed class GameLoopService(
|
||||
worker.RosterSnapshot,
|
||||
worker.ApplicantSnapshot,
|
||||
worker.CatalogSnapshot,
|
||||
worker.TimetableSnapshot);
|
||||
worker.TimetableSnapshot,
|
||||
worker.MapSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,4 +644,5 @@ internal sealed record PublishedSchoolPeople(
|
||||
Roster? Roster,
|
||||
ApplicantPool? Applicants,
|
||||
DefCatalog? Catalog,
|
||||
Timetable? Timetable);
|
||||
Timetable? Timetable,
|
||||
MapLayout? Map);
|
||||
|
||||
@@ -54,6 +54,7 @@ internal static class PersonCardReader
|
||||
PeopleListMapper.RolesOf(person),
|
||||
year,
|
||||
letter,
|
||||
person.ClassId,
|
||||
person.Position,
|
||||
PeopleListMapper.PositionLabel(catalog, locale, person.Position),
|
||||
Body(person, catalog, locale),
|
||||
|
||||
@@ -46,6 +46,7 @@ internal sealed class SchoolWorker
|
||||
private ApplicantPool? _applicantSnapshot;
|
||||
private DefCatalog? _catalogSnapshot;
|
||||
private Timetable? _timetableSnapshot;
|
||||
private MapLayout? _mapSnapshot;
|
||||
private OccupancyKey _occupancyKey;
|
||||
private School? _school;
|
||||
private Task? _run;
|
||||
@@ -109,6 +110,9 @@ internal sealed class SchoolWorker
|
||||
/// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
|
||||
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot);
|
||||
|
||||
/// <summary>Frozen map instance. Safe to read from HTTP with the catalog; it does not mutate.</summary>
|
||||
public MapLayout? MapSnapshot => Volatile.Read(ref _mapSnapshot);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_run = Task.Factory.StartNew(
|
||||
@@ -205,6 +209,7 @@ internal sealed class SchoolWorker
|
||||
var catalog = _mods.LoadCatalog(packIds, _logger);
|
||||
Volatile.Write(ref _catalogSnapshot, catalog);
|
||||
var map = _mods.LoadMap(packIds, _savedMap);
|
||||
Volatile.Write(ref _mapSnapshot, map);
|
||||
try
|
||||
{
|
||||
MapValidator.Validate(map, catalog);
|
||||
@@ -565,6 +570,7 @@ internal sealed class SchoolWorker
|
||||
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
||||
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
||||
Volatile.Write(ref _mapSnapshot, school.Map);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user