initial commit
This commit is contained in:
223
shared/weeks.js
Normal file
223
shared/weeks.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user