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

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,
};
}