- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI. - Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately. - Modified CalendarView and PlansView to include session indicators in the calendar and plans. - Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence. - Added new SessionMark component for consistent session representation across Android and iOS. - Updated server logic to handle session entries from external documents, ensuring accurate timetable representation. - Improved date parsing to accommodate session-related dates in various formats. - Added tests to verify session handling and date parsing functionality.
238 lines
8.9 KiB
JavaScript
238 lines
8.9 KiB
JavaScript
// 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`. */
|
||
/**
|
||
* 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 = /(?<![\d.:])\d{1,2}\.\d{1,2}(?:\.(?:\d{4}|\d{2}))?(?:\s*[;,]\s*\d{1,2}\.\d{1,2}(?:\.(?:\d{4}|\d{2}))?)+\.?(?![\d:])/gu;
|
||
|
||
export function parseTextDates(raw, year) {
|
||
if (!raw || !year) return [];
|
||
const pattern = new RegExp(`(\\d{1,2})\\s+(${MONTHS.join("|")})[а-я]*`, "giu");
|
||
const dates = [];
|
||
for (const list of raw.matchAll(DATE_LIST))
|
||
for (const m of list[0].matchAll(/(\d{1,2})\.(\d{1,2})(?:\.(\d{4}|\d{2}))?/g)) {
|
||
const day = Number(m[1]),
|
||
month = Number(m[2]);
|
||
if (day < 1 || day > 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,
|
||
};
|
||
}
|