Files
psu-schedule/shared/weeks.js
2026-10-16 22:08:45 +03:00

224 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}