Files
psu-schedule/server/timetable.js
Zakhar Timoshenko 67bb2ea6fb feat(parser): update group parsing to handle special cases and improve regex patterns
feat(parser): enhance details extraction for lessons, including handling of subgroups and room moves

feat(timetable): integrate room and teacher retrieval based on week context

test(parser): add tests for group parsing with special characters and room move scenarios

test(formats): implement tests for various lesson formats and their expected outputs

feat(rooms): introduce room management functions for handling moves and week-based plans
2026-10-21 00:34:06 +03:00

158 lines
6.5 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.
// 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 { 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)),
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));
// 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));
out.push({
date,
weekday: weekday(date),
week: week ? { n: week.n, color: week.color } : null,
lessons: dayLessons,
});
}
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);
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,
};
}