import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "./icons"; import type { Catalog, Schedule, Lesson, SourceFile } from "./types"; import { readSaved, saveSchedule, readPreference, savePreference, readCalendar, saveCalendar, readDismissed, dismissUpdate, readPersonal, savePersonal, documentKey, readChanges, saveChanges, type ChangeRecord, readExams, saveExams, type Exam, readRecentGroups, rememberGroup, readTheme, saveTheme, type Theme, } from "./storage"; import { switchTheme } from "./theme"; import { type AcademicCalendar, dateInWeek, lessonDates, semesterView, todayIso, weekIndexOf, weekday, } from "../shared/weeks"; import { bellFor, hasWeekLimit, lessonStatus, longDate, minutes, pairLabel, shortDate, dayNumber, weekSpan, clockMinutes, countdown, daysUntil, } from "./calendar"; import Search from "./Search"; import Groups, { type RecentGroup } from "./Groups"; import { facultyShort } from "./labels"; import { scheduleUpdate } from "../shared/updates"; import { changeCount, diffSchedules, lessonKey, personalFilter, planFor, subgroupsOf, } from "../shared/lessons"; import { placesFor, placesOn, type DayPlace } from "../shared/places"; import qrcode from "qrcode-generator"; const SOURCE = "https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ?dir=/2026-2027"; const DAYS = [ "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье", ]; const SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const dateLabel = (s: string) => new Date(s).toLocaleDateString("ru-RU", { day: "numeric", month: "long", timeZone: "Europe/Minsk", }); const countLabel = (n: number, forms: [string, string, string]) => { const tail = n % 100; return `${n} ${tail >= 11 && tail <= 14 ? forms[2] : n % 10 === 1 ? forms[0] : n % 10 >= 2 && n % 10 <= 4 ? forms[1] : forms[2]}`; }; type Mode = "calendar" | "all" | "upper" | "lower"; /** * Where the week view opens: today's week (on Sunday, the next Monday); for a * session schedule, the week of its next date. `usable` is false once the * semester is over, so an outdated schedule falls back to the Word rows. */ function startingPoint( calendar: AcademicCalendar | null, schedule?: Schedule | null, ) { const lessons = schedule?.lessons || []; if (calendar?.weeks.length && lessons.length && lessons.every((l) => l.date)) { const dates = lessons.map((l) => l.date!).sort(); const target = dates.find((d) => d >= todayIso()) || dates[0]; const index = weekIndexOf(calendar, target); if (index >= 0) return { day: weekday(target), index, inside: true, usable: true }; } const iso = todayIso(); let day = weekday(iso), index = weekIndexOf(calendar, iso); const inside = index >= 0; const usable = Boolean( calendar?.weeks.length && iso <= calendar.weeks.at(-1)!.end, ); if (calendar && inside && day === 7 && index + 1 < calendar.weeks.length) { index++; day = 1; } if (calendar && !inside && calendar.weeks.length) index = iso < calendar.weeks[0].start ? 0 : calendar.weeks.length - 1; if (calendar && index === 0 && !dateInWeek(calendar.weeks[0], day)) day = weekday(calendar.weeks[0].start); return { day, index, inside, usable }; } async function api(url: string, options?: RequestInit): Promise { const r = await fetch(url, options).catch(() => { throw new Error( "Сервер недоступен. Проверьте подключение и попробуйте ещё раз.", ); }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "Не удалось загрузить данные."); return body; } const lessonTheme = (type: string) => type === "Лекция" ? "lecture" : type === "Практика" ? "practice" : type === "Лабораторная" ? "lab" : "other"; interface ListContext { group: string; onOpen: (lesson: Lesson) => void; /** Weeks of the schedule's semester; null when dates cannot be calculated. */ calendar: AcademicCalendar | null; /** The whole table: bells apply whatever the semester. */ bells: AcademicCalendar | null; datesOf: (lesson: Lesson) => string[] | null; today: string; /** Minutes since midnight when the list shows today; drives the live pair chip. */ clock: number | null; isHidden: (lesson: Lesson) => boolean; /** The week shown, or only its colour for white or green Word rows; null for all rows. */ week: { n: number | null; color: string } | null; } const unique = (list: string[]) => [...new Set(list)]; const weeksLabel = (ranges: [number, number | null][]) => "недели " + ranges.map(([a, b]) => (b === null ? `с ${a}` : a === b ? `${a}` : `${a}–${b}`)).join(", "); /** * Room and teacher of a lesson in a week, when Word names them by week ("1-9н. 502н", * "ауд.533н-б.н.,420н-з.н."). For all Word rows at once: the rooms of each week colour. */ function inWeek(lesson: Lesson, week: ListContext["week"]) { const planned = planFor(lesson.plan, week); const rooms = unique(planned.flatMap((p) => p.rooms)), teachers = unique(planned.flatMap((p) => p.teachers)); const byColor = !week && !lesson.plan?.some((p) => p.weeks) ? (lesson.plan || []).filter((p) => p.color && p.rooms.length).map((p) => `${p.rooms.join(", ")} (${p.color === "white" ? "бел." : "зел."} н.)`) : []; return { room: rooms.join(", ") || byColor.join(", ") || lesson.room, teacher: teachers.join(", ") || lesson.teacher, }; } /** "533Н — белые недели", "502Н — недели 1–9": the plan for the details. */ const planLine = (p: NonNullable[number]) => [p.rooms.join(", "), p.teachers.join(", ")].filter(Boolean).join(", ") + " — " + [p.weeks && weeksLabel(p.weeks), p.color && (p.color === "white" ? "белые недели" : "зелёные недели")].filter(Boolean).join(", "); const duration = (m: number) => m < 60 ? `${m} мин` : `${Math.floor(m / 60)} ч${m % 60 ? ` ${m % 60} мин` : ""}`; function LessonCard({ lesson, ctx, muted = false, }: { lesson: Lesson; ctx: ListContext; muted?: boolean; }) { const theme = lessonTheme(lesson.type); const subgroup = lesson.subgroups.find((item) => item.group === ctx.group); const { room, teacher } = inWeek(lesson, ctx.week); const dates = ctx.calendar && hasWeekLimit(lesson) ? ctx.datesOf(lesson) : undefined; const hidden = ctx.isHidden(lesson); return (