// 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н.", "1-4н.;6-12н.", "с 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; "1-4н.;6-12н." and "2-5 н., 10-17 н." as one list. text = text .replace(/^(?:с|со)\s+/, "") .replace(/\s*н(?:ед)?[.\s]*$/, "") .replace(/\s*н(?:ед)?\.?\s*(?=[;,])/g, "") .replace(/\s*;\s*/g, ",") .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`. */ /** * A list of numeric dates, "10.09;15.10;19.11;17.12" or "03.10, 17.10": at least two, so a * time such as "10.05" on its own is never taken for 10 May. */ export const DATE_LIST = /(? 31 || month < 1 || month > 12) continue; const y = m[3] ? Number(m[3].length === 2 ? `20${m[3]}` : m[3]) : month >= 8 ? Number(year) : Number(year) + 1; dates.push(`${y}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`); } 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, }; }