feat: Enhance session handling in timetable and UI
- 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.
This commit is contained in:
@@ -350,7 +350,32 @@ export async function directoryCalendar() {
|
||||
}
|
||||
export { todayIso };
|
||||
|
||||
export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация"];
|
||||
export const EXAM_TYPES = ["Экзамен", "Зачёт", "Консультация", "Защита курсовой работы"];
|
||||
|
||||
/** The index as it is now, without waiting for a first build (which this starts). */
|
||||
export function readyIndex() {
|
||||
getIndex().catch(() => {});
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dated session events of a group (exams, credits, consultations, coursework defences) from
|
||||
* every catalog document except `exceptDocId`, as index entries, so a timetable can show the
|
||||
* group's credits even when the university publishes them in a separate "Расписание зачетов".
|
||||
*/
|
||||
export function sessionEntriesOf(idx, group, faculty, exceptDocId) {
|
||||
const seen = new Map();
|
||||
for (const entry of idx?.entries || []) {
|
||||
const { doc, lesson } = entry;
|
||||
if (doc.id === exceptDocId || !EXAM_TYPES.includes(lesson.type) || !lesson.groups.includes(group)) continue;
|
||||
if (!lesson.date && !lesson.dates?.length) continue;
|
||||
if (faculty && doc.schedule.source.faculty && doc.schedule.source.faculty !== faculty) continue;
|
||||
const subgroup = lesson.subgroups?.find((s) => s.group === group)?.label || "";
|
||||
const key = [lesson.date || lesson.dates.join(","), lesson.start, normalize(lesson.subject), lesson.type, subgroup].join("|");
|
||||
if (!seen.has(key)) seen.set(key, entry);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
/** Dated exams, credits and consultations of a group across every document. */
|
||||
export function examsOf(idx, group, faculty) {
|
||||
const seen = new Map();
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
freeRooms,
|
||||
examsOf,
|
||||
findGroups,
|
||||
readyIndex,
|
||||
sessionEntriesOf,
|
||||
directoryCalendar,
|
||||
todayIso,
|
||||
} from "./directory.js";
|
||||
@@ -42,8 +44,9 @@ app.use("/api", (_req, res, next) => {
|
||||
// Raised whenever an endpoint is added or changed, so a deployment is easy to verify:
|
||||
// 2 — /api/timetable for the Android client; 3 — its range=semester;
|
||||
// 4 — compression, ETag revalidation, throttled refresh and import limits;
|
||||
// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work.
|
||||
const API_VERSION = 5;
|
||||
// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work;
|
||||
// 6 — the timetable carries the group's exams and credits from other catalog documents.
|
||||
const API_VERSION = 6;
|
||||
|
||||
// Liveness plus what the server currently reads from the university, without network calls.
|
||||
app.get("/api/health", async (_req, res) => {
|
||||
@@ -118,25 +121,30 @@ app.get("/api/timetable", async (req, res, next) => {
|
||||
const calendar = await directoryCalendar();
|
||||
const range = req.query.range === "semester" ? "semester" : undefined;
|
||||
const today = todayIso();
|
||||
// The group's exams and credits from other documents ("Расписание зачетов"); a server
|
||||
// still building its first index answers without them rather than wait.
|
||||
const idx = readyIndex();
|
||||
// The answer follows only from these: the document's version, the parser and the server
|
||||
// that read it, the week table, the request and the date. An unchanged one comes back
|
||||
// as an empty 304 before the document is opened or a single day is computed.
|
||||
const etag = 'W/"tt-' + createHash("sha1")
|
||||
.update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, group, from, req.query.days, range, today].join("|"))
|
||||
.update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, idx?.builtAt, group, from, req.query.days, range, today].join("|"))
|
||||
.digest("base64url")
|
||||
.slice(0, 22) + '"';
|
||||
res.set("ETag", etag);
|
||||
if (req.fresh) return res.status(304).end();
|
||||
const schedule = await scheduleFor(file);
|
||||
res.json(
|
||||
timetable({
|
||||
file,
|
||||
schedule: await scheduleFor(file),
|
||||
schedule,
|
||||
calendar,
|
||||
group,
|
||||
from,
|
||||
days: req.query.days,
|
||||
range,
|
||||
today,
|
||||
session: sessionEntriesOf(idx, group, file.faculty || schedule.source.faculty, file.id),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,9 +6,9 @@ import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseWeekRanges, parseTextDates } from "../shared/weeks.js";
|
||||
import { parseWeekRanges, parseTextDates, DATE_LIST } from "../shared/weeks.js";
|
||||
const run = promisify(execFile);
|
||||
export const PARSER_VERSION = 10;
|
||||
export const PARSER_VERSION = 11;
|
||||
const days = [
|
||||
"понедельник",
|
||||
"вторник",
|
||||
@@ -86,6 +86,8 @@ export function details(raw) {
|
||||
.trim()
|
||||
.replace(/[–—,\-]+$/, "")
|
||||
.trim();
|
||||
// A typo'd date before the subject ("19,11, 03.12 Физика") leaves a stray comma behind.
|
||||
subject = subject.replace(/^[\s,;.]+/, "");
|
||||
if (!subject) subject = rest;
|
||||
const defence = /^защита/i.test(leading?.[1] || "");
|
||||
const token =
|
||||
@@ -408,7 +410,8 @@ export function parseDocx(buffer, source = {}) {
|
||||
cell.merge === "restart" &&
|
||||
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
|
||||
for (const alternative of alternatives(raw)) {
|
||||
const data = details(alternative);
|
||||
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject.
|
||||
const data = details(alternative.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
|
||||
if (credits && data.type === "Занятие") data.type = "Зачёт";
|
||||
const dateCandidate = alternative.match(
|
||||
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
|
||||
|
||||
@@ -37,7 +37,7 @@ function lessonOut(lesson, group, people, date, status, calendar) {
|
||||
* 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.
|
||||
*/
|
||||
export function timetable({ file, schedule, calendar, group, from, days, range, today }) {
|
||||
export function timetable({ file, schedule, calendar, group, from, days, range, today, session: extra = [] }) {
|
||||
if (!schedule.groups.includes(group)) {
|
||||
throw apiError(404, "group_not_found", `В документе нет группы ${group}.`);
|
||||
}
|
||||
@@ -58,13 +58,19 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
: 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.
|
||||
// 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 length = Math.round((Date.parse(span.end) - Date.parse(from)) / DAY_MS) + 1;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +92,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
if (status === "yes" || status === "maybe")
|
||||
dayLessons.push(lessonOut(entry.lesson, group, entry, date, status, calendar));
|
||||
}
|
||||
// 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,
|
||||
@@ -117,7 +127,12 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
|
||||
note: view || session
|
||||
? null
|
||||
: "Семестр этого расписания не найден в таблице недель: даты не рассчитаны, смотрите строки Word (weekly).",
|
||||
subgroups: subgroupsOf(schedule, group),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user