Schulcloud holds the material for a lesson but not the lesson: this school's course `times` are empty and it publishes the schedule in WebUntis. So "what do I have today, and has anything been cancelled" was unanswerable, and the timetable cannot be typed into a prompt either — it changes daily. core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes, the notes on each period, inline homework, the period id), untis_homework (the class register's list, which is not Schulcloud's tasks) and untis_lesson_topics (what earlier lessons of a series actually covered, which is what says where a subject got to). Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so every call is a POST, reads included. READ_METHODS is the guarantee instead, enforced at the single choke point and asserted by a test. It matters because the key can do what the app can — the live account holds W_OWN_ABSENCE, so the same key could report the user absent. What the live instance taught us, all recorded in docs/API.md: - `startDateTime` ends in Z and is local time. The 08:00 lesson reports 08:00Z, so new Date() would move every lesson by an hour or two. - A substitution is two periods, the original CANCELLED and the replacement IRREGULAR beside it, not one period with a changed teacher. - Announced tests live in the period's info text. The exam module is unused here, so getExams2017 is always empty and that field carries the tests. - A day with no lessons is not a holiday: the weeks this account spends in the company simply have no periods. - `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998. Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and -8524 a drifting clock; the tools name both, because no retry fixes either. Configuration is all four UNTIS_* values or none — three are identifiers and the fourth is a credential, so a half-filled block is a paste that went wrong. Without them the tools are not registered at all, since a tool that can only fail is worse than a missing one. whoami reports the WebUntis identity and survives a dead Schulcloud session, so "is the server reachable" no longer gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture run: that key belongs to the real school. 224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the Schulcloud checks in those runs answer 401 because this machine's session is logged out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
556 lines
18 KiB
TypeScript
556 lines
18 KiB
TypeScript
/**
|
|
* WebUntis: the timetable, its changes, homework and what was taught.
|
|
*
|
|
* The school keeps its timetable in WebUntis, not in Schulcloud — Schulcloud's
|
|
* course `times` are empty here — so "what do I have today, and has anything
|
|
* been cancelled" is a question only this API can answer. It is the other half
|
|
* of a school day: Schulcloud holds the material, WebUntis holds the schedule.
|
|
*
|
|
* **Read-only, but not by the Schulcloud client's rule.** This is JSON-RPC:
|
|
* every call is a POST, reads included, so "GET only" cannot be the guarantee.
|
|
* `READ_METHODS` is: `call` refuses any method outside it. That matters because
|
|
* the key is the mobile app's credential and can do what the app can — this
|
|
* account holds `W_OWN_ABSENCE`, so the same key could report the user absent.
|
|
*
|
|
* Authentication is a one-time code derived from the base32 key behind the QR
|
|
* code in WebUntis → Profil → Freigaben (see core/totp.ts). Each request signs
|
|
* itself, so unlike the Schulcloud session there is nothing to hold open and
|
|
* nothing to refresh — but the server's clock has to be right, which is what
|
|
* error -8524 means.
|
|
*/
|
|
|
|
import { compactDate } from './dates.ts';
|
|
import { totp } from './totp.ts';
|
|
|
|
export interface UntisConfig {
|
|
/** Bare host from the QR dialog's "Url" field, e.g. `ags-erfurt.webuntis.com`. */
|
|
server: string;
|
|
/** The school's login name, e.g. `ags-erfurt`. */
|
|
school: string;
|
|
user: string;
|
|
/** The base32 key from the QR dialog. A credential: never log it. */
|
|
secret: string;
|
|
}
|
|
|
|
/**
|
|
* The methods this client may call. Everything here reads; nothing writes.
|
|
*
|
|
* Verified against the live instance — `getClassregEvents2017` and
|
|
* `getSchoolyears2017` answer "Method not found" and are deliberately absent.
|
|
*/
|
|
const READ_METHODS = new Set([
|
|
'getUserData2017',
|
|
'getTimetable2017',
|
|
'getLessonTopic2017',
|
|
'getHomeWork2017',
|
|
'getMessagesOfDay2017',
|
|
]);
|
|
|
|
/**
|
|
* The read-only guarantee for this API, at its single choke point.
|
|
*
|
|
* Exported so a test can hold it to it: this is the line that keeps a key which
|
|
* *can* write from being used to write.
|
|
*/
|
|
export function assertReadMethod(method: string): void {
|
|
if (!READ_METHODS.has(method)) {
|
|
throw new Error(`Refusing to call WebUntis method ${method}: not in the read-only allowlist.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The client version the mobile API expects as `?v=`.
|
|
*
|
|
* Not optional: `jsonrpc_intern.do` reads the parameter without checking it,
|
|
* so omitting it fails with a Java NullPointerException reported as -8998.
|
|
*/
|
|
const API_VERSION = 'i3.2';
|
|
|
|
/** How long master data (subjects, teachers, rooms, holidays) is reused. */
|
|
const MASTER_DATA_TTL_MS = 6 * 60 * 60_000;
|
|
|
|
/** A JSON-RPC error from WebUntis. They arrive with HTTP 200 and an `error` body. */
|
|
export class UntisApiError extends Error {
|
|
readonly code: number;
|
|
readonly method: string;
|
|
|
|
constructor(code: number, method: string, message: string) {
|
|
super(`WebUntis ${method} failed (${code}): ${message}`);
|
|
this.name = 'UntisApiError';
|
|
this.code = code;
|
|
this.method = method;
|
|
}
|
|
|
|
/** -8504: the key or user name is wrong, or the key has been regenerated. */
|
|
get isAuthFailure(): boolean {
|
|
return this.code === -8504;
|
|
}
|
|
|
|
/** -8524: our clock is too far from the school server's. */
|
|
get isClockSkew(): boolean {
|
|
return this.code === -8524;
|
|
}
|
|
}
|
|
|
|
// --- what callers get ----------------------------------------------------
|
|
|
|
/** A subject, teacher, room or class: a short code plus, where known, a full name. */
|
|
export interface UntisName {
|
|
name: string;
|
|
longName?: string;
|
|
}
|
|
|
|
export interface UntisHomework {
|
|
id: number;
|
|
lessonId: number;
|
|
/** When it was set, `YYYY-MM-DD`. */
|
|
assigned: string;
|
|
/** When it is due, `YYYY-MM-DD`. */
|
|
due: string;
|
|
text: string;
|
|
remark?: string;
|
|
completed: boolean;
|
|
subject?: UntisName;
|
|
attachments: number;
|
|
}
|
|
|
|
export interface UntisLesson {
|
|
/** The period id, which `getLessonTopic2017` takes. */
|
|
periodId: number;
|
|
/** The lesson (series) id: the same weekly slot shares it. */
|
|
lessonId: number;
|
|
date: string;
|
|
/** `HH:MM` in the school's local time. */
|
|
start: string;
|
|
end: string;
|
|
/** Raw status words, e.g. `REGULAR`, `CANCELLED`, `IRREGULAR`. */
|
|
statuses: string[];
|
|
cancelled: boolean;
|
|
/** A substitution, a moved lesson or anything else Untis calls irregular. */
|
|
changed: boolean;
|
|
subjects: UntisName[];
|
|
teachers: UntisName[];
|
|
rooms: UntisName[];
|
|
classes: UntisName[];
|
|
/** What each kind of element replaced, when Untis says so (its `orgId`). */
|
|
replaced: { subjects: UntisName[]; teachers: UntisName[]; rooms: UntisName[] };
|
|
/** The three free-text fields a teacher can attach to a period. */
|
|
notes: { lesson?: string; substitution?: string; info?: string };
|
|
homework: UntisHomework[];
|
|
/** The exam module's title, when the school uses it. */
|
|
exam?: string;
|
|
online: boolean;
|
|
}
|
|
|
|
export interface UntisHoliday {
|
|
name: string;
|
|
longName: string;
|
|
start: string;
|
|
end: string;
|
|
}
|
|
|
|
export interface UntisDay {
|
|
date: string;
|
|
lessons: UntisLesson[];
|
|
/** Holidays and single free days covering this date. */
|
|
holidays: UntisHoliday[];
|
|
}
|
|
|
|
export interface UntisTimetable {
|
|
from: string;
|
|
to: string;
|
|
/** Every date in the range, including the ones without lessons. */
|
|
days: UntisDay[];
|
|
}
|
|
|
|
export interface UntisIdentity {
|
|
displayName: string;
|
|
elementId: number;
|
|
elementType: string;
|
|
schoolName: string;
|
|
/** Untis' own permission words, e.g. `R_MY_ABSENCES`, `W_OWN_ABSENCE`. */
|
|
rights: string[];
|
|
}
|
|
|
|
/** One class-register entry: what was taught in a lesson of this series. */
|
|
export interface UntisTopic {
|
|
text: string;
|
|
periodId: number;
|
|
date: string;
|
|
start: string;
|
|
end: string;
|
|
}
|
|
|
|
export interface UntisMessage {
|
|
subject: string;
|
|
text: string;
|
|
}
|
|
|
|
// --- raw shapes ----------------------------------------------------------
|
|
|
|
interface RawElement {
|
|
type: string;
|
|
id: number;
|
|
orgId?: number;
|
|
}
|
|
|
|
interface RawPeriod {
|
|
id: number;
|
|
lessonId: number;
|
|
startDateTime: string;
|
|
endDateTime: string;
|
|
text?: { lesson?: string; substitution?: string; info?: string; attachments?: unknown[] };
|
|
elements?: RawElement[];
|
|
is?: string[];
|
|
homeWorks?: RawHomework[];
|
|
exam?: { name?: string; text?: string } | null;
|
|
isOnlinePeriod?: boolean;
|
|
}
|
|
|
|
interface RawHomework {
|
|
id: number;
|
|
lessonId: number;
|
|
startDate: string;
|
|
endDate: string;
|
|
text: string;
|
|
remark?: string | null;
|
|
completed?: boolean;
|
|
attachments?: unknown[];
|
|
}
|
|
|
|
interface RawNamed {
|
|
id: number;
|
|
name: string;
|
|
longName?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
}
|
|
|
|
interface RawMasterData {
|
|
timeStamp?: number;
|
|
subjects?: RawNamed[];
|
|
teachers?: RawNamed[];
|
|
rooms?: RawNamed[];
|
|
klassen?: RawNamed[];
|
|
holidays?: { name: string; longName: string; startDate: string; endDate: string }[];
|
|
}
|
|
|
|
interface RawUserData {
|
|
userData?: {
|
|
displayName?: string;
|
|
elemId?: number;
|
|
elemType?: string;
|
|
schoolName?: string;
|
|
rights?: string[];
|
|
};
|
|
masterData?: RawMasterData;
|
|
}
|
|
|
|
interface RawTimetable {
|
|
timetable?: { periods?: RawPeriod[] };
|
|
masterData?: RawMasterData;
|
|
}
|
|
|
|
// --- client --------------------------------------------------------------
|
|
|
|
export class UntisClient {
|
|
private readonly config: UntisConfig;
|
|
private readonly timeoutMs: number;
|
|
private identityPromise: Promise<UntisIdentity> | undefined;
|
|
private masterData: { data: RawMasterData; at: number } | undefined;
|
|
|
|
constructor(config: UntisConfig, timeoutMs = 30_000) {
|
|
this.config = config;
|
|
this.timeoutMs = timeoutMs;
|
|
}
|
|
|
|
/** For status lines: where this client is pointed, never how it authenticates. */
|
|
get origin(): string {
|
|
return `${this.config.server}/${this.config.school}`;
|
|
}
|
|
|
|
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
|
// The allowlist is the read-only guarantee for this API; widening it is a
|
|
// deliberate act, not something a caller can do by passing a string.
|
|
assertReadMethod(method);
|
|
const url =
|
|
`https://${this.config.server}/WebUntis/jsonrpc_intern.do` +
|
|
`?m=${encodeURIComponent(method)}&school=${encodeURIComponent(this.config.school)}&v=${API_VERSION}`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
// Honest, and accepted: the endpoint does not check for the app's own
|
|
// user agent.
|
|
'user-agent': 'schulcloud-mcp',
|
|
},
|
|
body: JSON.stringify({
|
|
id: 'schulcloud-mcp',
|
|
jsonrpc: '2.0',
|
|
method,
|
|
params: [
|
|
{
|
|
...params,
|
|
// The code is a string: one in ten starts with a zero, which a
|
|
// JSON number would drop.
|
|
auth: { user: this.config.user, otp: totp(this.config.secret), clientTime: Date.now() },
|
|
},
|
|
],
|
|
}),
|
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
});
|
|
|
|
const body = (await response.json().catch(() => undefined)) as
|
|
| { result?: T; error?: { code?: number; message?: string } }
|
|
| undefined;
|
|
// Failures come back as HTTP 200 with an `error` member, so the body is
|
|
// the thing to check first.
|
|
if (body?.error) {
|
|
throw new UntisApiError(body.error.code ?? 0, method, body.error.message ?? 'no message');
|
|
}
|
|
if (!response.ok) throw new UntisApiError(0, method, `HTTP ${response.status}`);
|
|
if (body?.result === undefined) throw new UntisApiError(0, method, 'response carried no result');
|
|
return body.result;
|
|
}
|
|
|
|
/** Who the key belongs to. Cached; a failure is not, so a fixed key recovers. */
|
|
identity(): Promise<UntisIdentity> {
|
|
this.identityPromise ??= this.call<RawUserData>('getUserData2017', {})
|
|
.then((raw) => {
|
|
if (raw.masterData) this.masterData = { data: raw.masterData, at: Date.now() };
|
|
const user = raw.userData ?? {};
|
|
if (user.elemId === undefined || !user.elemType) {
|
|
throw new UntisApiError(0, 'getUserData2017', 'response carried no user element');
|
|
}
|
|
return {
|
|
displayName: user.displayName ?? '(unnamed)',
|
|
elementId: user.elemId,
|
|
elementType: user.elemType,
|
|
schoolName: user.schoolName ?? this.config.school,
|
|
rights: user.rights ?? [],
|
|
};
|
|
})
|
|
.catch((error: unknown) => {
|
|
this.identityPromise = undefined;
|
|
throw error;
|
|
});
|
|
return this.identityPromise;
|
|
}
|
|
|
|
/**
|
|
* The timetable for a date range, with every day in it — including the ones
|
|
* with no lessons, because "no school today" is an answer and an empty list
|
|
* is not.
|
|
*/
|
|
async timetable(from: string, to: string): Promise<UntisTimetable> {
|
|
const me = await this.identity();
|
|
const raw = await this.call<RawTimetable>('getTimetable2017', {
|
|
id: me.elementId,
|
|
type: me.elementType,
|
|
startDate: compactDate(from),
|
|
endDate: compactDate(to),
|
|
// Always ask for full master data rather than a delta against a cached
|
|
// timestamp: the delta's removal semantics are unverified, and the whole
|
|
// set is one payload of a few hundred kilobytes a handful of times a day.
|
|
masterDataTimestamp: 0,
|
|
timetableTimestamp: 0,
|
|
timetableTimestamps: [],
|
|
});
|
|
if (raw.masterData?.subjects) this.masterData = { data: raw.masterData, at: Date.now() };
|
|
const master = raw.masterData ?? (await this.master());
|
|
|
|
const lessons = (raw.timetable?.periods ?? []).map((period) => this.toLesson(period, master));
|
|
const days: UntisDay[] = [];
|
|
for (let date = from; date <= to; date = nextDate(date)) {
|
|
days.push({
|
|
date,
|
|
lessons: lessons.filter((lesson) => lesson.date === date).sort(byStart),
|
|
holidays: holidaysOn(master, date),
|
|
});
|
|
}
|
|
return { from, to, days };
|
|
}
|
|
|
|
/**
|
|
* Homework set for a date range.
|
|
*
|
|
* The range filters by the homework's own dates, not by when it was set, so
|
|
* a window that ends today shows nothing that is due tomorrow.
|
|
*/
|
|
async homework(from: string, to: string): Promise<UntisHomework[]> {
|
|
const me = await this.identity();
|
|
const raw = await this.call<{
|
|
homeWorks?: RawHomework[];
|
|
lessonsById?: Record<string, { subjectId?: number }>;
|
|
}>('getHomeWork2017', {
|
|
id: me.elementId,
|
|
type: me.elementType,
|
|
startDate: compactDate(from),
|
|
endDate: compactDate(to),
|
|
});
|
|
const master = await this.master();
|
|
const subjects = index(master.subjects);
|
|
return (raw.homeWorks ?? [])
|
|
.map((item) => {
|
|
const subjectId = raw.lessonsById?.[String(item.lessonId)]?.subjectId;
|
|
return toHomework(item, subjectId === undefined ? undefined : named(subjects.get(subjectId)));
|
|
})
|
|
.sort((a, b) => a.due.localeCompare(b.due));
|
|
}
|
|
|
|
/**
|
|
* What was taught in the previous lessons of a period's series — the class
|
|
* register's "Unterrichtsinhalt", newest first.
|
|
*
|
|
* The parameter is a single `periodId`; a list is rejected as "period 0 not
|
|
* found".
|
|
*/
|
|
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
|
|
const raw = await this.call<{
|
|
previousTopics?: { text?: string; periodId?: number; startDateTime?: string; endDateTime?: string }[];
|
|
}>('getLessonTopic2017', { periodId });
|
|
return (raw.previousTopics ?? [])
|
|
.filter((topic) => topic.text?.trim())
|
|
.map((topic) => {
|
|
const start = splitLocal(topic.startDateTime ?? '');
|
|
const end = splitLocal(topic.endDateTime ?? '');
|
|
return {
|
|
text: topic.text!.trim(),
|
|
periodId: topic.periodId ?? periodId,
|
|
date: start.date,
|
|
start: start.time,
|
|
end: end.time,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** The school's "Nachrichten des Tages" for one date. Often empty. */
|
|
async messagesOfDay(date: string): Promise<UntisMessage[]> {
|
|
const raw = await this.call<{ messages?: { subject?: string; text?: string }[] }>('getMessagesOfDay2017', {
|
|
date: compactDate(date),
|
|
});
|
|
return (raw.messages ?? []).map((message) => ({
|
|
subject: message.subject?.trim() ?? '',
|
|
text: message.text?.trim() ?? '',
|
|
}));
|
|
}
|
|
|
|
/** Master data, refreshed at most every few hours: it changes with the school year. */
|
|
private async master(): Promise<RawMasterData> {
|
|
if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data;
|
|
const raw = await this.call<RawUserData>('getUserData2017', {});
|
|
const data = raw.masterData ?? {};
|
|
this.masterData = { data, at: Date.now() };
|
|
return data;
|
|
}
|
|
|
|
private toLesson(period: RawPeriod, master: RawMasterData): UntisLesson {
|
|
const start = splitLocal(period.startDateTime);
|
|
const end = splitLocal(period.endDateTime);
|
|
const maps = {
|
|
SUBJECT: index(master.subjects),
|
|
TEACHER: index(master.teachers),
|
|
ROOM: index(master.rooms),
|
|
CLASS: index(master.klassen),
|
|
};
|
|
const of = (type: keyof typeof maps): RawElement[] => (period.elements ?? []).filter((e) => e.type === type);
|
|
const resolve = (type: keyof typeof maps): UntisName[] =>
|
|
of(type).map((element) => named(maps[type].get(element.id)) ?? { name: `${type.toLowerCase()} #${element.id}` });
|
|
// Untis expresses "X instead of Y" by keeping the original in orgId.
|
|
const replacedBy = (type: keyof typeof maps): UntisName[] =>
|
|
of(type)
|
|
.filter((element) => element.orgId !== undefined && element.orgId !== element.id)
|
|
.map((element) => named(maps[type].get(element.orgId!)) ?? { name: `${type.toLowerCase()} #${element.orgId}` });
|
|
|
|
const statuses = period.is ?? [];
|
|
const note = (value: string | undefined): string | undefined => value?.trim() || undefined;
|
|
return {
|
|
periodId: period.id,
|
|
lessonId: period.lessonId,
|
|
date: start.date,
|
|
start: start.time,
|
|
end: end.time,
|
|
statuses,
|
|
cancelled: statuses.includes('CANCELLED'),
|
|
changed: statuses.includes('IRREGULAR') || statuses.includes('SUBSTITUTION'),
|
|
subjects: resolve('SUBJECT'),
|
|
teachers: resolve('TEACHER'),
|
|
rooms: resolve('ROOM'),
|
|
classes: resolve('CLASS'),
|
|
replaced: { subjects: replacedBy('SUBJECT'), teachers: replacedBy('TEACHER'), rooms: replacedBy('ROOM') },
|
|
notes: {
|
|
lesson: note(period.text?.lesson),
|
|
substitution: note(period.text?.substitution),
|
|
info: note(period.text?.info),
|
|
},
|
|
homework: (period.homeWorks ?? []).map((item) => toHomework(item, undefined)),
|
|
exam: note(period.exam?.name) ?? note(period.exam?.text),
|
|
online: period.isOnlinePeriod === true,
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- helpers -------------------------------------------------------------
|
|
|
|
/**
|
|
* Splits `2026-09-21T11:45Z` into date and time — **as local school time**.
|
|
*
|
|
* The `Z` is a lie: the school's time grid starts lessons at 08:00 and the API
|
|
* reports exactly `08:00Z` for them. Parsing these as UTC would shift every
|
|
* lesson by an hour or two, so the string is taken apart rather than given to
|
|
* `new Date`.
|
|
*/
|
|
export function splitLocal(value: string): { date: string; time: string } {
|
|
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
|
if (!match) {
|
|
// Dropping an unparsable lesson would silently shorten a school day; a
|
|
// changed format has to be visible.
|
|
throw new Error(`WebUntis returned a timestamp in an unexpected format: ${value}`);
|
|
}
|
|
return { date: match[1]!, time: match[2]! };
|
|
}
|
|
|
|
function index(list: RawNamed[] | undefined): Map<number, RawNamed> {
|
|
return new Map((list ?? []).map((entry) => [entry.id, entry]));
|
|
}
|
|
|
|
function named(entry: RawNamed | undefined): UntisName | undefined {
|
|
if (!entry) return undefined;
|
|
const full = [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim();
|
|
const longName = entry.longName?.trim() || full || undefined;
|
|
return { name: entry.name, ...(longName ? { longName } : {}) };
|
|
}
|
|
|
|
function toHomework(item: RawHomework, subject: UntisName | undefined): UntisHomework {
|
|
return {
|
|
id: item.id,
|
|
lessonId: item.lessonId,
|
|
assigned: item.startDate,
|
|
due: item.endDate,
|
|
text: item.text?.trim() ?? '',
|
|
...(item.remark?.trim() ? { remark: item.remark.trim() } : {}),
|
|
completed: item.completed === true,
|
|
...(subject ? { subject } : {}),
|
|
attachments: item.attachments?.length ?? 0,
|
|
};
|
|
}
|
|
|
|
function holidaysOn(master: RawMasterData, date: string): UntisHoliday[] {
|
|
return (master.holidays ?? [])
|
|
.filter((holiday) => holiday.startDate <= date && date <= holiday.endDate)
|
|
.map((holiday) => ({
|
|
name: holiday.name.trim(),
|
|
longName: holiday.longName.trim(),
|
|
start: holiday.startDate,
|
|
end: holiday.endDate,
|
|
}));
|
|
}
|
|
|
|
function nextDate(date: string): string {
|
|
return new Date(Date.parse(`${date}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function byStart(a: UntisLesson, b: UntisLesson): number {
|
|
return a.start.localeCompare(b.start) || a.periodId - b.periodId;
|
|
}
|