initial commit

This commit is contained in:
2026-10-16 22:08:45 +03:00
commit 19ee41411b
97 changed files with 15141 additions and 0 deletions

45
shared/lessons.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
interface LessonLike {
day: number;
date: string | null;
start: string;
end: string | null;
subject: string;
teacher: string;
room: string;
type: string;
condition: string;
variant: "all" | "upper" | "lower";
groups: string[];
subgroups: { group: string; label: string }[];
}
export interface FieldChange {
field: string;
label: string;
from: string | number;
to: string | number;
}
export interface ScheduleDiff<L> {
added: L[];
removed: L[];
changed: { before: L; after: L; fields: FieldChange[] }[];
}
export interface PersonalPrefs {
subgroup?: string | null;
hidden?: string[];
}
export function lessonKey(lesson: LessonLike, group: string): string;
export function diffSchedules<L extends LessonLike>(
before: { lessons: L[] } | null | undefined,
after: { lessons: L[] } | null | undefined,
group: string,
): ScheduleDiff<L>;
export function changeCount(diff: ScheduleDiff<unknown> | null | undefined): number;
export function personalFilter(
lesson: LessonLike,
group: string,
prefs: PersonalPrefs | null | undefined,
): "shown" | "hidden" | "other-subgroup";
export function subgroupsOf(
schedule: { lessons: LessonLike[] } | null | undefined,
group: string,
): string[];

105
shared/lessons.js Normal file
View File

@@ -0,0 +1,105 @@
// Lesson identity and schedule comparison, shared by the browser and the tests.
// Lesson ids depend on the table row, so they change whenever the university
// republishes a file; these keys describe what a student would call "the same lesson".
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
const subgroupOf = (lesson, group) =>
lesson.subgroups?.find((s) => s.group === group)?.label || "";
/** Same day, time, subject, row colour and subgroup: survives republishing. */
export function lessonKey(lesson, group) {
return [
lesson.date || lesson.day,
lesson.start,
norm(lesson.subject),
lesson.variant,
subgroupOf(lesson, group),
norm(lesson.condition),
].join("|");
}
// Looser identity for pairing a lesson whose time or week mark changed.
const looseKey = (lesson, group) =>
[norm(lesson.subject), lesson.variant, subgroupOf(lesson, group), norm(lesson.type)].join("|");
const FIELDS = [
["start", "Начало"],
["end", "Окончание"],
["day", "День"],
["date", "Дата"],
["room", "Аудитория"],
["teacher", "Преподаватель"],
["condition", "Недели"],
["type", "Вид занятия"],
];
/**
* What changed for one group between two versions of the same document.
* Returns { added, removed, changed } where changed items list field differences.
*/
export function diffSchedules(before, after, group) {
const pick = (s) => (s?.lessons || []).filter((l) => l.groups.includes(group));
const removed = [],
added = [],
changed = [];
const old = new Map();
for (const lesson of pick(before)) {
const key = lessonKey(lesson, group);
old.set(key, [...(old.get(key) || []), lesson]);
}
const unmatched = [];
for (const lesson of pick(after)) {
const key = lessonKey(lesson, group),
list = old.get(key);
if (list?.length) {
const was = list.shift();
const fields = differences(was, lesson);
if (fields.length) changed.push({ before: was, after: lesson, fields });
} else unmatched.push(lesson);
}
const left = [...old.values()].flat();
// Pair the rest by subject on the same day first, then by subject alone (a lesson moved to another day).
for (const sameDay of [true, false])
for (const lesson of [...unmatched]) {
const index = left.findIndex(
(l) =>
looseKey(l, group) === looseKey(lesson, group) &&
(!sameDay || (l.date || l.day) === (lesson.date || lesson.day)),
);
if (index < 0) continue;
const [was] = left.splice(index, 1);
unmatched.splice(unmatched.indexOf(lesson), 1);
changed.push({ before: was, after: lesson, fields: differences(was, lesson) });
}
added.push(...unmatched);
removed.push(...left);
const order = (l) => `${l.date || ""}${l.day}${l.start}`;
added.sort((a, b) => order(a).localeCompare(order(b)));
removed.sort((a, b) => order(a).localeCompare(order(b)));
changed.sort((a, b) => order(a.after).localeCompare(order(b.after)));
return { added, removed, changed };
}
function differences(before, after) {
return FIELDS.filter(([field]) => (before[field] ?? "") !== (after[field] ?? "")).map(
([field, label]) => ({ field, label, from: before[field] ?? "", to: after[field] ?? "" }),
);
}
export const changeCount = (diff) =>
diff ? diff.added.length + diff.removed.length + diff.changed.length : 0;
/** Applies the student's own choices: their subgroup and the lessons they hid. */
export function personalFilter(lesson, group, prefs) {
const own = subgroupOf(lesson, group);
if (prefs?.subgroup && own && own !== prefs.subgroup) return "other-subgroup";
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
return "shown";
}
/** Subgroup labels present for a group, in column order. */
export function subgroupsOf(schedule, group) {
const labels = new Set();
for (const lesson of schedule?.lessons || [])
if (lesson.groups.includes(group)) {
const label = subgroupOf(lesson, group);
if (label) labels.add(label);
}
return [...labels].sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
}

22
shared/updates.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
interface CatalogFileLike {
id: string;
name: string;
faculty: string;
modified: string;
}
interface SavedLike {
schemaVersion?: number;
source: {
fileId?: string;
modified?: string;
importedAt: string;
faculty?: string;
};
}
export function namesGroup(fileName: string, group: string): boolean;
export function scheduleUpdate<F extends CatalogFileLike>(
saved: SavedLike | null | undefined,
catalog: { files: F[]; stale?: boolean } | null | undefined,
group: string,
options?: { dismissed?: string[]; schemaVersion?: number },
): { changed: F | null; newer: F | null; missing: boolean } | null;

47
shared/updates.js Normal file
View File

@@ -0,0 +1,47 @@
// Decides what a saved schedule needs after the catalog was read again.
// Runs in the browser; kept free of DOM code so the rules are testable in Node.
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const time = (value) => Date.parse(value || "") || 0;
/** "23-БА" matches "23-ЛГ_23-БА.docx" but not "23-БАз" or "123-БА". */
export function namesGroup(fileName, group) {
if (!group) return false;
const base = fileName.replace(/\.docx?$/i, "");
return new RegExp(
`(^|[^0-9A-Za-zА-ЯЁа-яё])${escape(group)}(?![0-9A-Za-zА-ЯЁа-яё])`,
"iu",
).test(base);
}
/**
* changed: the same document was republished (or parsed by an older version) — reload it quietly.
* newer: another document for this group appeared later, e.g. the next semester — offer it.
* missing: the document left the catalog, e.g. a new academic year began.
*/
export function scheduleUpdate(saved, catalog, group, options = {}) {
const { dismissed = [], schemaVersion = 0 } = options;
const fileId = saved?.source?.fileId;
if (!fileId || !catalog?.files || catalog.stale) return null;
const current = catalog.files.find((f) => f.id === fileId) || null;
const savedTime = time(saved.source.modified) || time(saved.source.importedAt);
const newer =
catalog.files
.filter(
(f) =>
f.id !== fileId &&
!dismissed.includes(f.id) &&
namesGroup(f.name, group) &&
time(f.modified) > savedTime &&
(!saved.source.faculty || f.faculty === saved.source.faculty),
)
.sort((a, b) => time(b.modified) - time(a.modified))[0] || null;
const changed =
current &&
(current.modified !== saved.source.modified ||
(saved.schemaVersion || 0) < schemaVersion)
? current
: null;
if (!changed && !newer && current) return null;
return { changed, newer, missing: !current };
}

84
shared/weeks.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
export type WeekRange = [number, number | null];
export interface CalendarWeek {
n: number;
start: string;
end: string;
color: "white" | "green";
semester?: string;
}
export interface Semester {
id: string;
kind: "autumn" | "spring";
year: number;
label: string;
start: string;
end: string;
}
export interface Bell {
pair: number;
start: string;
end: string;
halves: [string, string][];
}
export interface AcademicCalendar {
weeks: CalendarWeek[];
semesters?: Semester[];
bells: Bell[];
source: {
weeks?: string | null;
bells?: string | null;
weeksFiles?: string[];
bellsFile?: string | null;
};
updatedAt: string | null;
stale?: boolean;
error?: string;
}
/** A calendar narrowed to the semester a schedule belongs to. */
export interface SemesterCalendar extends AcademicCalendar {
semester: Semester;
}
interface ScheduleLike {
lessons: { date: string | null }[];
period?: string;
academicYear?: string | number | null;
}
interface WeekLesson {
day: number;
date: string | null;
dates?: string[];
condition: string;
variant: "all" | "upper" | "lower";
weekRanges?: WeekRange[] | null;
}
export function parseWeekRanges(condition: string): WeekRange[] | null;
export function parseTextDates(raw: string, year: string | number): string[];
export function isoDate(time: number): string;
export function addDays(iso: string, days: number): string;
export function weekday(iso: string): number;
export function todayIso(now?: Date): string;
export function weekIndexOf(
calendar: AcademicCalendar | null | undefined,
iso: string,
): number;
export function dateInWeek(week: CalendarWeek, day: number): string | null;
export function lessonWeeks(lesson: WeekLesson, lastWeek: number): number[] | null;
export function lessonDates(
lesson: WeekLesson,
calendar: AcademicCalendar | null | undefined,
): string[] | null;
export function academicStartYear(iso: string): number;
export function assignSemesters(weeks: CalendarWeek[]): {
weeks: CalendarWeek[];
semesters: Semester[];
};
export function semesterFor(
calendar: AcademicCalendar | null | undefined,
schedule: ScheduleLike | null | undefined,
today: string,
): Semester | null;
export function semesterView(
calendar: AcademicCalendar | null | undefined,
schedule: ScheduleLike | null | undefined,
today: string,
): SemesterCalendar | null;

223
shared/weeks.js Normal file
View File

@@ -0,0 +1,223 @@
// Week rules shared by the Word parser (server) and the PWA (browser).
// The university publishes "Расписание белых и зеленых недель": week 1 starts on
// 1 September, odd weeks are white and even weeks are green. A week mark inside a
// white or green table row only applies to weeks of that row's colour.
const MONTHS = [
"январ",
"феврал",
"март",
"апрел",
"ма[яй]",
"июн",
"июл",
"август",
"сентябр",
"октябр",
"ноябр",
"декабр",
];
const DAY_MS = 86400000;
/**
* "6-13н", "1-4,6-11н.", "с 3н.", "со 2 нед.", "1-8,10-17" → [[6, 13]], [[1, 4], [6, 11]], [[3, null]]…
* Returns null when the text is empty or not a pure week list.
*/
export function parseWeekRanges(condition) {
let text = (condition || "").trim().toLowerCase();
if (!text) return null;
const from = text.match(/^(?:с|со)\s*(\d{1,2})\s*(?:н(?:ед)?)?[.\s]*$/);
if (from) return [[Number(from[1]), null]];
// "с 4-16н" reads as an ordinary range.
text = text
.replace(/^(?:с|со)\s+/, "")
.replace(/\s*н(?:ед)?[.\s]*$/, "")
.trim();
if (!/^\d{1,2}(?:\s*[-–,]\s*\d{1,2})*$/.test(text)) return null;
const ranges = [];
for (const part of text.split(/\s*,\s*/)) {
const bounds = part.split(/\s*[-–]\s*/).map(Number);
// "6-12-16" is a typo in the source; leave it for the student to check.
if (bounds.length > 2) return null;
const [a, b = a] = bounds;
if (!a || !b || a > b || b > 60) return null;
ranges.push([a, b]);
}
return ranges;
}
/** "10 сентября, 15 октября" → ISO dates inside the academic year that starts in `year`. */
export function parseTextDates(raw, year) {
if (!raw || !year) return [];
const pattern = new RegExp(`(\\d{1,2})\\s+(${MONTHS.join("|")})[а-я]*`, "giu");
const dates = [];
for (const m of raw.matchAll(pattern)) {
const month =
MONTHS.findIndex((stem) => new RegExp(`^${stem}`, "iu").test(m[2])) + 1;
const day = Number(m[1]);
if (!month || day < 1 || day > 31) continue;
const y = month >= 8 ? Number(year) : Number(year) + 1;
dates.push(
`${y}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
);
}
return [...new Set(dates)].sort();
}
const toTime = (iso) => Date.parse(`${iso}T00:00:00Z`);
export const isoDate = (time) => new Date(time).toISOString().slice(0, 10);
export const addDays = (iso, days) => isoDate(toTime(iso) + days * DAY_MS);
/** 1 = Monday … 7 = Sunday */
export const weekday = (iso) => ((new Date(toTime(iso)).getUTCDay() + 6) % 7) + 1;
/** Today in Europe/Minsk as YYYY-MM-DD. */
export function todayIso(now = new Date()) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Minsk",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
/** Index of the calendar week that contains the date, or -1. */
export function weekIndexOf(calendar, iso) {
return calendar?.weeks.findIndex((w) => w.start <= iso && iso <= w.end) ?? -1;
}
/** Date of a weekday inside a calendar week; null when it falls outside the week (week 1 starts on Tuesday). */
export function dateInWeek(week, day) {
const monday = addDays(week.start, 1 - weekday(week.start));
const date = addDays(monday, day - 1);
return date < week.start || date > week.end ? null : date;
}
/** Week numbers in which a weekly lesson takes place, limited by the calendar length. */
export function lessonWeeks(lesson, lastWeek) {
const ranges =
lesson.weekRanges !== undefined
? lesson.weekRanges
: parseWeekRanges(lesson.condition);
const parity =
lesson.variant === "upper" ? 1 : lesson.variant === "lower" ? 0 : null;
if (lesson.condition && !ranges) return null;
const all = [];
for (const [a, b] of ranges || [[1, lastWeek]])
for (let n = a; n <= Math.min(b ?? lastWeek, lastWeek); n++) all.push(n);
const unique = [...new Set(all)].sort((x, y) => x - y);
const matching =
parity === null ? unique : unique.filter((n) => n % 2 === parity);
// A mark that contradicts its row colour is kept literally rather than dropped.
return matching.length ? matching : unique;
}
/** Concrete dates of a lesson; null when its weeks cannot be read from the condition. */
export function lessonDates(lesson, calendar) {
if (!calendar?.weeks.length) return null;
if (lesson.date) return [lesson.date];
if (lesson.dates?.length) return lesson.dates;
const numbers = lessonWeeks(lesson, calendar.weeks.at(-1).n);
if (!numbers) return null;
return numbers
.map((n) => calendar.weeks.find((w) => w.n === n))
.filter(Boolean)
.map((w) => dateInWeek(w, lesson.day))
.filter(Boolean);
}
/** The academic year starts on 1 September: 2026-08-31 → 2025, 2026-09-01 → 2026. */
export function academicStartYear(iso) {
const [year, month] = iso.split("-").map(Number);
return month >= 9 ? year : year - 1;
}
const daysBetween = (a, b) => Math.round((toTime(b) - toTime(a)) / DAY_MS);
/**
* Splits a week table into semesters. A new semester starts when numbering
* restarts or when more than three weeks separate two rows.
*/
export function assignSemesters(weeks) {
const sorted = [...weeks].sort((a, b) => a.start.localeCompare(b.start));
const groups = [];
for (const week of sorted) {
const group = groups.at(-1),
previous = group?.at(-1);
if (!previous || week.n <= previous.n || daysBetween(previous.end, week.start) > 21)
groups.push([week]);
else group.push(week);
}
const semesters = [];
const result = [];
for (const group of groups) {
const first = group[0],
month = Number(first.start.slice(5, 7)),
kind = month >= 7 ? "autumn" : "spring",
year = Number(first.start.slice(0, 4)) - (kind === "spring" ? 1 : 0);
let id = `${year}-${kind}`;
if (semesters.some((s) => s.id === id)) id += `-${semesters.length + 1}`;
semesters.push({
id,
kind,
year,
label: `${kind === "autumn" ? "Осенний" : "Весенний"} семестр ${year}/${String(year + 1).slice(2)}`,
start: first.start,
end: group.at(-1).end,
});
for (const week of group) result.push({ ...week, semester: id });
}
return { weeks: result, semesters };
}
/**
* The part of the week table that a schedule refers to. Week marks restart in
* every semester, so mixing them would give confidently wrong dates.
* Returns null when the semester cannot be determined unambiguously.
*/
export function semesterFor(calendar, schedule, today) {
if (!calendar?.weeks?.length) return null;
const { semesters } = calendar.semesters
? calendar
: assignSemesters(calendar.weeks);
const lessons = schedule?.lessons || [];
const dated = lessons.length > 0 && lessons.every((l) => l.date);
if (dated) {
const dates = lessons.map((l) => l.date).sort();
const target = dates.find((d) => d >= today) || dates[0];
return semesters.find((s) => s.start <= target && target <= s.end) || null;
}
const text = `${schedule?.period || ""}`;
const kind = /весенн/i.test(text) ? "spring" : /осенн/i.test(text) ? "autumn" : null;
const year = Number(schedule?.academicYear) || null;
if (kind) {
const matching = semesters.filter(
(s) => s.kind === kind && (!year || s.year === year),
);
if (matching.length === 1) return matching[0];
if (!year && matching.length > 1)
return (
matching.find((s) => s.start <= today && today <= s.end) ||
[...matching].sort(
(a, b) =>
Math.abs(daysBetween(today, a.start)) -
Math.abs(daysBetween(today, b.start)),
)[0]
);
return null;
}
return semesters.find((s) => s.start <= today && today <= s.end) || null;
}
/** Calendar narrowed to one semester; null when the schedule's semester is not in the table. */
export function semesterView(calendar, schedule, today) {
const semester = semesterFor(calendar, schedule, today);
if (!semester) return null;
const weeks = calendar.semesters
? calendar.weeks
: assignSemesters(calendar.weeks).weeks;
return {
...calendar,
weeks: weeks.filter((w) => w.semester === semester.id),
semester,
};
}