Files
psu-schedule/server/timetable.js
Zakhar Timoshenko b9c1f63301 feat: Add skeleton loading UI components for various screens
- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects.
- Introduced `Undo.kt` to manage undo actions with a snackbar interface.
- Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable.
- Added various splash animations in the `animator` directory for a smooth launch experience.
- Designed drawable resources for the widget and splash screen, including icons for city and room moves.
- Defined XML resources for widget configuration and photo paths.
2026-10-23 21:56:07 +03:00

177 lines
7.7 KiB
JavaScript
Raw Permalink 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 { 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,
// "Немецкий язык" when part of the group studies German and part French (see `choices`).
option: lesson.option || null,
// "foreign": for foreign students only (Russian as a foreign language); "local": for the others.
audience: lesson.audience || 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 })),
// What parts of the group choose between: [["Немецкий язык", "Французский язык"]].
choices: (schedule.choices || []).filter((c) => c.groups.includes(group)).map((c) => c.options),
// Some lessons are for foreign students only, or for the others: worth asking which one you are.
audience: lessons.some((l) => l.audience),
bells: calendar?.bells || [],
from,
days: out,
weekly,
notes: schedule.notes,
};
}