// A group's timetable by date, with week numbers already applied. Native and // third-party clients use it instead of re-implementing the white/green week rules. import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js"; import { lessonKey, subgroupsOf } from "../shared/lessons.js"; import { placesOn } from "../shared/places.js"; import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js"; import { apiError } from "./errors.js"; const MAX_DAYS = 62; // A whole semester with its session fits easily; the cap only guards against abuse. const MAX_SEMESTER_DAYS = 200; const DAY_MS = 86_400_000; // On a date: the room and teacher of that day and week ("1-9н. 502н", a move on 26.10). function lessonOut(lesson, group, people, date, status, calendar, week = null) { const bell = calendar?.bells.find((b) => b.start === lesson.start); const rooms = date ? roomsOn(people, date, week) : people.rooms; return { key: lessonKey(lesson, group), start: lesson.start, end: lesson.end || bell?.end || null, pair: bell?.pair ?? null, subject: lesson.subject, type: lesson.type, teachers: date ? teachersOn(people, week) : people.teachers, rooms, roomMoved: Boolean(date && movedOn(people, date)), // "Полоцк": the town, when the cell or the day's note names one. place: lesson.place || null, distance: people.distance, condition: lesson.condition, variant: lesson.variant, subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null, groups: lesson.groups, uncertain: status === "maybe", raw: lesson.raw, }; } /** * days: one entry per date from `from`, with the lessons that take place that day. * weekly: the Word rows by weekday, for when dates cannot be calculated. */ const groupKey = (g) => g.toLowerCase().replace(/[\s\-–]/g, ""); export function timetable({ file, schedule, calendar, group, from, days, range, today, session: extra = [] }) { if (!schedule.groups.includes(group)) { // The same group written another way: "24-ИЯ(РА)и-1" for "24-ИЯ (РА)и-1". const same = schedule.groups.find((g) => groupKey(g) === groupKey(group)); // A name parser 13 split into groups: a client may still keep "24-ИЯ(РА)". const split = schedule.groups.filter((g) => groupKey(g).startsWith(groupKey(group))); if (same) group = same; else throw apiError( 404, "group_not_found", split.length ? `Группа ${group} теперь разделена: ${split.join(", ")}. Выберите свою в списке групп.` : `В документе нет группы ${group}.`, ); } let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS); const lessons = schedule.lessons.filter((l) => l.groups.includes(group)); const labels = subgroupsOf(schedule, group); // A session schedule printed with a date on every day needs no week table: its own dates // say when each lesson takes place, even in a year the table no longer covers. const sessionDates = lessons.every((l) => l.date || l.dates?.length) ? lessons.flatMap((l) => (l.date ? [l.date] : l.dates)).sort() : []; const session = sessionDates.length ? { id: `session-${sessionDates[0]}`, label: schedule.period || "Сессия", start: sessionDates[0], end: sessionDates.at(-1), } : null; // range=semester: every day of the semester `today` falls in, so a client can keep // the whole term offline; a session schedule keeps its own span. Without either it // keeps the plain from/days window. Exams and credits from other documents may follow // the last week of the semester: the span then runs on to the last of them (up to 60 days). if (range === "semester") { const current = semesterView(calendar, schedule, today || from); const span = current?.semester || session; if (span) { from = span.start; const limit = addDays(span.end, 60); const end = [span.end, ...extra.flatMap((e) => (e.lesson.date ? [e.lesson.date] : e.lesson.dates))] .filter((d) => d >= from && d <= limit) .sort() .at(-1); const length = Math.round((Date.parse(end) - Date.parse(from)) / DAY_MS) + 1; count = Math.min(Math.max(length, 1), MAX_SEMESTER_DAYS); } } const entries = lessons.map((lesson) => ({ doc: { id: file.id, schedule }, lesson, ...peopleAndRooms(lesson, schedule.academicYear), })); const view = semesterView(calendar, schedule, from); const views = new Map(); const out = []; for (let i = 0; i < count; i++) { const date = addDays(from, i); const week = view ? view.weeks[weekIndexOf(view, date)] || null : null; const dayLessons = []; if (view || session) for (const entry of entries) { const status = occurrence(entry, date, calendar, views); if (status === "yes" || status === "maybe") dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar, week)); } // Session events printed with their own date need no week table. for (const entry of extra) if (entry.lesson.date === date || entry.lesson.dates?.includes(date)) dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar)); dayLessons.sort((a, b) => a.start.localeCompare(b.start)); // A day without lessons (a holiday, weeks of practice) is in no town. const places = dayLessons.length ? placesOn(schedule.places, group, labels, weekday(date), date) : []; out.push({ date, weekday: weekday(date), week: week ? { n: week.n, color: week.color } : null, lessons: dayLessons, ...(places.length && { places }), }); } const weekly = [1, 2, 3, 4, 5, 6, 7] .map((day) => ({ day, lessons: entries .filter((e) => e.lesson.day === day && !e.lesson.date) .map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar)) .sort((a, b) => a.start.localeCompare(b.start)), })) .filter((d) => d.lessons.length) .map((d) => { const places = placesOn(schedule.places, group, labels, d.day); return places.length ? { ...d, places } : d; }); return { group, document: { id: file.id, name: file.name, faculty: file.faculty || schedule.source.faculty || "", course: file.course || schedule.source.course || "", modified: file.modified || schedule.source.modified || null, }, period: schedule.period, semester: view?.semester || session, note: view || session ? null : "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).", subgroups: [ ...new Set([ ...subgroupsOf(schedule, group), ...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean), ]), ].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })), bells: calendar?.bells || [], from, days: out, weekly, notes: schedule.notes, }; }