Files
psu-schedule/src/App.tsx

2504 lines
95 KiB
TypeScript
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.
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<T>(url: string, options?: RequestInit): Promise<T> {
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<Lesson["plan"]>[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 (
<button
className={`lesson-card ${theme} ${muted || hidden ? "muted" : ""}`}
onClick={() => ctx.onOpen(lesson)}
aria-label={`${lesson.start}, ${lesson.subject}. Открыть детали`}
>
<span className="lesson-body">
<span className="lesson-tags">
<span className={`type-tag ${theme}`}>{lesson.type}</span>
{lesson.variant !== "all" && (
<span className={`variant-tag ${lesson.variant}`}>
<i aria-hidden="true" />
{lesson.variant === "upper" ? "Белые недели" : "Зелёные недели"}
</span>
)}
{lesson.groups.length > 1 && (
<span className="shared-tag">Общее занятие</span>
)}
{hidden && (
<span className="hidden-tag">
<Icon name="visibility_off" size={18} />
Скрыто
</span>
)}
</span>
<strong>{lesson.subject}</strong>
<span className="lesson-meta">
{teacher && (
<span>
<Icon name="person" size={20} />
{teacher}
</span>
)}
{(room || lesson.place) && (
<span>
<Icon name="location_on" size={20} />
{whereLabel({ ...lesson, room }, "Ауд.")}
</span>
)}
{subgroup && (
<span>
<Icon name="groups" size={20} />
{subgroup.label}
</span>
)}
</span>
{dates === null ? (
<span className="condition unknown">
<Icon name="error" size={20} />
<span>
Пометка «{lesson.condition}» не распознана — сверьте с Word
</span>
</span>
) : dates ? (
<span className="condition">
<Icon
name={dates.length === 1 ? "calendar_month" : "event_repeat"}
size={20}
/>
<span>
{lessonStatus(dates, ctx.today)}
{lesson.condition && dates.length > 1 && (
<span className="condition-raw"> · {lesson.condition}</span>
)}
</span>
</span>
) : (
(lesson.condition || lesson.date) && (
<span className="condition">
<Icon
name={lesson.date ? "calendar_month" : "event_repeat"}
size={20}
/>
{lesson.date
? dateLabel(lesson.date)
: `Недели: ${lesson.condition}`}
</span>
)
)}
</span>
<Icon name="chevron_right" size={24} className="lesson-chevron" />
</button>
);
}
/** "Ауд. 249Ж · Полоцк": the room with the town the document names, or the town alone. */
const whereLabel = (l: Lesson, prefix: string) =>
[l.room && `${prefix} ${l.room}`, l.place].filter(Boolean).join(" · ");
/**
* "Занятия в Полоцке" over the day's lessons: Word marks the days a group spends in another
* town, and every lesson of that day is there.
*/
function DayPlaces({ places }: { places: DayPlace[] }) {
if (!places.length) return null;
return (
<div className="day-places" role="note">
{places.map((p) => (
<p key={`${p.text}|${p.subgroup}`}>
<Icon name="location_city" />
<span>
<strong>{p.text}</strong>
<small>{p.subgroup ? `${p.subgroup} · пометка в Word на этот день` : "Пометка в Word на этот день"}</small>
</span>
</p>
))}
</div>
);
}
function ScheduleList({
lessons,
ctx,
muted = false,
}: {
lessons: Lesson[];
ctx: ListContext;
muted?: boolean;
}) {
const slots = new Map<string, Lesson[]>();
for (const lesson of lessons) {
const key = `${lesson.date || "weekly"}-${lesson.start}`;
const items = slots.get(key) || [];
items.push(lesson);
slots.set(key, items);
}
const entries = [...slots].map(([key, items]) => {
const bell = bellFor(ctx.bells, items[0].start);
const end = items[0].end || bell?.end || null;
return { key, items, bell, end };
});
const clock = muted ? null : ctx.clock;
const current =
clock === null
? -1
: entries.findIndex(
(e) =>
e.end &&
minutes(e.items[0].start) <= clock &&
clock < minutes(e.end),
);
const next =
clock === null || current >= 0
? -1
: entries.findIndex((e) => minutes(e.items[0].start) > clock);
return (
<div className="lesson-list">
{entries.map(({ key, items, bell, end }, index) => {
const label = pairLabel(bell, items[0]);
const live =
index === current && end
? `Идёт · ещё ${duration(minutes(end) - clock!)}`
: index === next && minutes(items[0].start) - clock! <= 180
? `Через ${duration(minutes(items[0].start) - clock!)}`
: "";
return (
<section
className={`time-slot ${index === current ? "now" : ""}`}
key={key}
aria-label={`${label ? label + ", " : ""}занятия с ${items[0].start}`}
>
<h3 className="slot-time">
<time>{items[0].start}</time>
<span>{end ? `— ${end}` : "конец не указан"}</span>
{label && <span className="slot-pair">{label}</span>}
{live && (
<span className={`live-chip ${index === current ? "on" : ""}`}>
{live}
</span>
)}
</h3>
<div className="slot-lessons">
{items.map((lesson, i) => (
<LessonCard
key={`${lesson.id}-${i}`}
lesson={lesson}
ctx={ctx}
muted={muted}
/>
))}
</div>
</section>
);
})}
</div>
);
}
function WeekGrid({
calendar,
dates,
today,
}: {
calendar: AcademicCalendar;
dates: string[];
today: string;
}) {
const on = new Set(dates.map((d) => weekIndexOf(calendar, d)));
return (
<div className="week-grid-wrap">
<ol className="week-grid" aria-label="Недели семестра">
{calendar.weeks.map((w, i) => {
const current = w.start <= today && today <= w.end;
const past = w.end < today;
return (
<li
key={`${w.n}-${w.start}`}
className={`${w.color} ${on.has(i) ? "on" : ""} ${past ? "past" : ""} ${current ? "current" : ""}`}
title={`Неделя ${w.n}: ${weekSpan(w.start, w.end)}`}
aria-label={`Неделя ${w.n}, ${w.color === "white" ? "белая" : "зелёная"}${on.has(i) ? ", есть занятие" : ""}${current ? ", текущая" : ""}`}
>
{w.n}
</li>
);
})}
</ol>
<p className="week-legend">
<span>
<i className="swatch on" aria-hidden="true" />
занятие
</span>
<span>
<i className="swatch white" aria-hidden="true" />
белая
</span>
<span>
<i className="swatch green" aria-hidden="true" />
зелёная
</span>
<span>
<i className="swatch current" aria-hidden="true" />
сейчас
</span>
</p>
</div>
);
}
const recentOf = (s: Schedule, group: string): RecentGroup => ({
group,
document: documentKey(s),
fileId: s.source.fileId,
faculty: s.source.faculty,
course: s.source.course,
});
/** "Подгруппа 2" → "подгруппа 2" for running text. */
const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1);
const whenLabel = (l: Lesson) =>
`${l.date ? longDate(l.date) : DAYS[l.day - 1]} · ${l.start}`;
const fieldValue = (field: string, value: string | number) =>
value === "" || value === null
? "не указано"
: field === "day"
? DAYS[Number(value) - 1]
: field === "date"
? longDate(String(value))
: String(value);
function ChangesList({ record }: { record: ChangeRecord }) {
const { added, removed, changed } = record.diff;
return (
<div className="changes">
<p className="modal-intro">
{record.group} · {record.name.replace(/\.docx?$/i, "")}
{record.to ? ` · файл от ${dateLabel(record.to)}` : ""}. Сравнение с
прошлой версией, сохранённой на этом устройстве.
</p>
{changed.length > 0 && (
<section>
<h3>Изменено · {changed.length}</h3>
<ul>
{changed.map(({ before, after, fields }, i) => (
<li key={`c${i}`} className="change changed">
<small>{whenLabel(after)}</small>
<b>{after.subject}</b>
{fields.map((f) => (
<span key={f.field} className="change-field">
{f.label}: <del>{fieldValue(f.field, f.from)}</del>
<Icon name="arrow_forward" size={18} />
<ins>{fieldValue(f.field, f.to)}</ins>
</span>
))}
{!fields.length && before.raw !== after.raw && (
<span className="change-field">Изменился текст ячейки Word</span>
)}
</li>
))}
</ul>
</section>
)}
{added.length > 0 && (
<section>
<h3>Добавлено · {added.length}</h3>
<ul>
{added.map((l, i) => (
<li key={`a${i}`} className="change added">
<small>{whenLabel(l)}</small>
<b>{l.subject}</b>
<span className="change-field">
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean)
.join(" · ")}
</span>
</li>
))}
</ul>
</section>
)}
{removed.length > 0 && (
<section>
<h3>Удалено · {removed.length}</h3>
<ul>
{removed.map((l, i) => (
<li key={`r${i}`} className="change removed">
<small>{whenLabel(l)}</small>
<b>{l.subject}</b>
<span className="change-field">
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean)
.join(" · ")}
</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
function QrCode({ text }: { text: string }) {
const svg = useMemo(() => {
const qr = qrcode(0, "M");
qr.addData(text);
qr.make();
const size = qr.getModuleCount();
let path = "";
for (let r = 0; r < size; r++)
for (let c = 0; c < size; c++)
if (qr.isDark(r, c)) path += `M${c + 4} ${r + 4}h1v1h-1z`;
return { size: size + 8, path };
}, [text]);
return (
<svg
className="qr"
viewBox={`0 0 ${svg.size} ${svg.size}`}
role="img"
aria-label="QR-код ссылки на расписание"
shapeRendering="crispEdges"
>
<rect width={svg.size} height={svg.size} fill="#fff" />
<path d={svg.path} fill="#1d1b20" />
</svg>
);
}
/** ?doc=<id>&group=<name> opens a group's schedule from a shared link. */
const sharedLink = (() => {
const params = new URLSearchParams(location.search);
const doc = params.get("doc");
return doc ? { doc, group: params.get("group") || "" } : null;
})();
export default function App() {
const [saved, setSaved] = useState(readSaved),
[schedule, setSchedule] = useState<Schedule | null>(() => {
const saved = readSaved(),
p = readPreference();
return (
(sharedLink && saved.find((s) => s.source.fileId === sharedLink.doc)) ||
saved.find((s) => s.id === p?.id) ||
saved[0] ||
null
);
}),
[group, setGroup] = useState<string>(
() => sharedLink?.group || readPreference()?.group || "",
);
const [catalog, setCatalog] = useState<Catalog | null>(null),
[loading, setLoading] = useState(false),
[catalogLoading, setCatalogLoading] = useState(false),
[error, setError] = useState(""),
[notice, setNotice] = useState(""),
[storageError, setStorageError] = useState(false);
const [fullCalendar, setFullCalendar] = useState(readCalendar),
[start] = useState(() =>
startingPoint(semesterView(readCalendar(), schedule, todayIso()), schedule),
),
[weekIdx, setWeekIdx] = useState(start.index),
[mode, setMode] = useState<Mode>(start.usable ? "calendar" : "all"),
[now, setNow] = useState(() => Date.now());
const [online, setOnline] = useState(navigator.onLine),
[day, setDay] = useState(start.day),
[view, setView] = useState<"day" | "week">("day"),
[search, setSearch] = useState("");
const [importer, setImporter] = useState(false),
[details, setDetails] = useState<Lesson | null>(null),
[help, setHelp] = useState(false),
[draft, setDraft] = useState<Schedule | null>(null),
[draftGroup, setDraftGroup] = useState(""),
[importing, setImporting] = useState(false),
[importError, setImportError] = useState("");
const [installPrompt, setInstallPrompt] = useState<any>(null),
[installed, setInstalled] = useState(
matchMedia("(display-mode: standalone)").matches,
);
const [menu, setMenu] = useState(false),
[about, setAbout] = useState(false),
[scrolled, setScrolled] = useState(false),
[slide, setSlide] = useState<"next" | "prev" | "">("");
const today = todayIso(new Date(now)),
todayDay = weekday(today);
// Week marks restart every semester: only the schedule's own semester is used.
const calendar = useMemo(
() => semesterView(fullCalendar, schedule, today),
[fullCalendar, schedule, today],
);
const todayInside = weekIndexOf(calendar, today) >= 0;
const semesterKey = `${calendar?.semester.id ?? "none"}|${schedule?.id ?? ""}`;
const openedFor = useRef(semesterKey);
useEffect(() => {
if (openedFor.current === semesterKey) return;
openedFor.current = semesterKey;
const s = startingPoint(calendar, schedule);
setWeekIdx(s.index);
setDay(s.day);
setMode((m) =>
s.usable ? (m === "all" ? "all" : "calendar") : m === "calendar" ? "all" : m,
);
}, [semesterKey]);
const week = calendar && weekIdx >= 0 ? calendar.weeks[weekIdx] || null : null;
const calendarMode = mode === "calendar" && !!week;
const dayIso = calendarMode ? dateInWeek(week!, day) : null;
const [update, setUpdate] = useState<
{ kind: "newer"; file: SourceFile } | { kind: "missing"; year: string } | null
>(null);
const [changes, setChanges] = useState<ChangeRecord | null>(null),
[changesOpen, setChangesOpen] = useState(false),
[share, setShare] = useState(false),
[copied, setCopied] = useState(false),
[personal, setPersonal] = useState(readPersonal),
[screen, setScreen] = useState<"schedule" | "search" | "groups">(() =>
readSaved().length || sharedLink ? "schedule" : "groups",
),
[groupQuery, setGroupQuery] = useState(""),
[theme, setTheme] = useState<Theme>(readTheme),
[recentGroups, setRecentGroups] = useState(readRecentGroups),
[exams, setExams] = useState<Exam[]>([]),
[examsOpen, setExamsOpen] = useState(false),
[showHidden, setShowHidden] = useState(false);
const lastCheck = useRef(0);
const requestId = useRef(0),
touch = useRef<{ x: number; y: number } | null>(null),
init = useRef(false),
fileInput = useRef<HTMLInputElement>(null),
dialogRef = useRef<HTMLDialogElement>(null);
function activate(s: Schedule, g?: string) {
const selected =
g && s.groups.includes(g)
? g
: s.groups.includes(group)
? group
: s.groups[0];
setSchedule(s);
setGroup(selected);
if (mode === "upper" || mode === "lower") setMode("calendar");
setSearch("");
setError("");
setNotice("");
setUpdate(null);
setChanges(null);
setShowHidden(false);
// Same document, new version: tell the student what changed for their group.
const previous = readSaved().find(
(x) => documentKey(x) === documentKey(s),
);
if (previous && previous.id !== s.id) {
const diff = diffSchedules(previous, s, selected);
if (changeCount(diff)) {
const record: ChangeRecord = {
document: documentKey(s),
group: selected,
name: s.source.name,
at: new Date().toISOString(),
from: previous.source.modified,
to: s.source.modified,
diff,
};
saveChanges(record);
setChanges(record);
} else if (previous.source.modified !== s.source.modified)
setNotice(
`Расписание обновлено (файл от ${dateLabel(s.source.modified || s.source.importedAt)}). Для ${selected} ничего не изменилось.`,
);
}
// Without a matching semester, a session schedule still opens on a day that has lessons.
if (
s.lessons.length &&
s.lessons.every((l) => l.date) &&
!s.lessons.some((l) => l.day === day)
)
setDay(s.lessons[0].day);
try {
setSaved(saveSchedule(s));
setStorageError(false);
} catch {
setStorageError(true);
}
savePreference(s.id, selected);
setRecentGroups(rememberGroup(recentOf(s, selected)));
}
async function loadCalendar(refresh = false) {
try {
const c = await api<AcademicCalendar>(
`/api/calendar${refresh ? "?refresh=1" : ""}`,
);
if (!c.weeks?.length) return;
saveCalendar(c);
// A new semester key opens today's week through the effect above.
setFullCalendar(c);
} catch {
/* Without the week table the app shows every row from Word. */
}
}
async function loadCatalog(refresh = false) {
setCatalogLoading(true);
try {
const c = await api<Catalog>(
`/api/catalog${refresh ? "?refresh=1" : ""}`,
);
setCatalog(c);
return c;
} catch (e) {
if (schedule)
setNotice("Каталог недоступен. Открыто сохранённое расписание.");
else setError((e as Error).message);
return null;
} finally {
setCatalogLoading(false);
}
}
async function loadFile(file: SourceFile, refresh = false, g?: string) {
const id = ++requestId.current;
setLoading(true);
setError("");
setNotice("");
try {
const s = await api<Schedule>(
`/api/schedule/${file.id}${refresh ? "?refresh=1" : ""}`,
);
if (id !== requestId.current) return false;
activate(s, g);
setScreen("schedule");
return true;
} catch (e) {
if (id !== requestId.current) return false;
const cached = readSaved().find((s) => s.source.fileId === file.id);
if (cached) {
activate(cached, g);
setNotice(
"Не удалось проверить обновления. Показано сохранённое расписание.",
);
} else setError((e as Error).message);
return false;
} finally {
if (id === requestId.current) setLoading(false);
}
}
/**
* Compares the saved schedule with the catalog: a republished file is reloaded
* quietly, a newer document for the group (next semester or year) is offered.
*/
async function checkForUpdates(known?: Catalog | null) {
lastCheck.current = Date.now();
const c = known === undefined ? await loadCatalog() : known;
if (!c || !schedule) return;
// A schedule read by an older parser than the server's (c.parserVersion) is re-read once.
const found = scheduleUpdate(schedule, c, group, { dismissed: readDismissed() });
if (!found) return setUpdate(null);
// activate() reports what changed for the group.
if (found.changed) await loadFile(found.changed, false, group);
setUpdate(
found.newer
? { kind: "newer", file: found.newer }
: found.missing
? { kind: "missing", year: c.year }
: null,
);
}
const checkLatest = useRef(checkForUpdates);
checkLatest.current = checkForUpdates;
useEffect(() => {
if (init.current) return;
init.current = true;
(async () => {
const c = await loadCatalog();
if (sharedLink) {
history.replaceState(null, "", location.pathname);
const file = c?.files.find((f) => f.id === sharedLink.doc);
const saved = readSaved().find((s) => s.source.fileId === sharedLink.doc);
if (file) {
if (await loadFile(file, false, sharedLink.group || undefined))
setNotice(`Открыто расписание ${sharedLink.group || file.name} по ссылке.`);
} else if (saved) {
activate(saved, sharedLink.group || undefined);
setNotice("Каталог недоступен: открыта сохранённая копия расписания по ссылке.");
} else {
setError(
c
? "Ссылка устарела: этого документа больше нет в каталоге университета. Найдите группу заново."
: "Не удалось открыть ссылку: каталог университета недоступен. Попробуйте позже.",
);
if (c) openGroups(sharedLink.group);
}
return;
}
// First launch: the student finds their own group (the groups screen is already open).
if (schedule) await checkForUpdates(c);
})();
}, []);
useEffect(() => {
const yes = () => setOnline(true),
no = () => setOnline(false),
install = (e: Event) => {
e.preventDefault();
setInstallPrompt(e);
},
done = () => {
setInstalled(true);
setInstallPrompt(null);
};
window.addEventListener("online", yes);
window.addEventListener("offline", no);
window.addEventListener("beforeinstallprompt", install);
window.addEventListener("appinstalled", done);
return () => {
window.removeEventListener("online", yes);
window.removeEventListener("offline", no);
window.removeEventListener("beforeinstallprompt", install);
window.removeEventListener("appinstalled", done);
};
}, []);
useEffect(() => {
loadCalendar();
const tick = window.setInterval(() => setNow(Date.now()), 30000);
// An installed PWA can stay open for days: re-check the catalog hourly when shown again.
const wake = () => {
if (document.visibilityState !== "visible") return;
setNow(Date.now());
if (navigator.onLine && Date.now() - lastCheck.current > 60 * 60 * 1000)
checkLatest.current();
};
document.addEventListener("visibilitychange", wake);
return () => {
window.clearInterval(tick);
document.removeEventListener("visibilitychange", wake);
};
}, []);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 4);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
function goToDay(next: number) {
if (next === day && view === "day") return;
setSlide(view === "week" ? "" : next > day ? "next" : "prev");
setDay(next);
setView("day");
}
// In calendar mode the day arrows and swipes continue into the next or previous week.
const stepDay = (delta: 1 | -1) => {
let next = day + delta,
index = weekIdx;
if (calendarMode && calendar) {
if (next > 7) {
if (index + 1 >= calendar.weeks.length) return;
index++;
next = 1;
} else if (next < 1) {
if (index === 0) return;
index--;
next = 7;
}
if (!dateInWeek(calendar.weeks[index], next)) {
if (delta > 0 || index === 0) return;
index--;
next = 7;
}
} else next = ((next - 1 + 7) % 7) + 1;
setSlide(delta > 0 ? "next" : "prev");
setWeekIdx(index);
setDay(next);
};
function stepWeek(delta: 1 | -1) {
if (!calendar) return;
const index = weekIdx + delta;
if (index < 0 || index >= calendar.weeks.length) return;
setSlide(delta > 0 ? "next" : "prev");
setWeekIdx(index);
if (!dateInWeek(calendar.weeks[index], day))
setDay(weekday(calendar.weeks[index].start));
}
function goToToday() {
const s = startingPoint(calendar);
setSlide(s.index > weekIdx || (s.index === weekIdx && s.day > day) ? "next" : "prev");
setWeekIdx(s.index);
setDay(s.day);
setView("day");
}
const modalOpen =
importer ||
!!details ||
help ||
menu ||
about ||
changesOpen ||
share ||
examsOpen;
useEffect(() => {
const dialog = dialogRef.current;
if (modalOpen) {
if (!dialog?.open) dialog?.showModal();
// Focus the heading, not the close button: screen readers announce the
// sheet and no focus ring appears on a control nobody has reached yet.
dialog
?.querySelector<HTMLElement>("#modal-heading")
?.focus({ preventScroll: true });
dialog?.scrollTo({ top: 0 });
} else if (dialog?.open) dialog.close();
}, [modalOpen, importer, details, help, menu, about, changesOpen, share, examsOpen]);
function closeModal() {
setImporter(false);
setDetails(null);
setHelp(false);
setMenu(false);
setAbout(false);
setChangesOpen(false);
setExamsOpen(false);
setShare(false);
setCopied(false);
}
async function refresh() {
loadCalendar(true);
const c = await loadCatalog(true);
if (schedule?.source.fileId) {
const file = c?.files.find((f) => f.id === schedule.source.fileId);
if (file) await loadFile(file, true, group);
} else if (c) setNotice("Каталог университета обновлён.");
}
async function importFile(file?: File) {
if (!file) return;
setImportError("");
setDraft(null);
if (file.size > 10 * 1024 * 1024) {
setImportError("Максимальный размер — 10 МБ.");
return;
}
if (!/\.docx?$/i.test(file.name)) {
setImportError("Выберите файл .docx или .doc.");
return;
}
setImporting(true);
try {
const body = new FormData();
body.append("file", file);
const s = await api<Schedule>("/api/import", { method: "POST", body });
setDraft(s);
setDraftGroup(s.groups.includes(group) ? group : s.groups[0]);
} catch (e) {
setImportError((e as Error).message);
} finally {
setImporting(false);
if (fileInput.current) fileInput.current.value = "";
}
}
const personalKey = schedule ? `${documentKey(schedule)}|${group}` : "";
const prefs = personal[personalKey];
function updatePrefs(change: (p: NonNullable<typeof prefs>) => typeof prefs) {
const next = { ...personal, [personalKey]: change(prefs || {}) };
setPersonal(next);
savePersonal(next);
}
const subgroups = subgroupsOf(schedule, group);
const examKey = schedule ? `${schedule.source.faculty || ""}|${group}` : "";
useEffect(() => {
if (!examKey) return setExams([]);
setExams(readExams(examKey));
if (!online) return;
let current = true;
const [faculty] = examKey.split("|");
api<{ exams: Exam[] }>(
`/api/exams?${new URLSearchParams({ group, faculty })}`,
)
.then((r) => {
if (!current) return;
setExams(r.exams);
saveExams(examKey, r.exams);
})
.catch(() => {
/* The saved list stays until the server answers. */
});
return () => {
current = false;
};
}, [examKey, online]);
const upcomingExams = exams.filter((e) => e.date >= today);
// The countdown is about the next exam or credit; consultations stay in the list.
const nextExam =
upcomingExams.find((e) => e.type !== "Консультация") || upcomingExams[0];
const lastChanges = schedule
? readChanges().find(
(r) => r.document === documentKey(schedule) && r.group === group,
) || null
: null;
const shareUrl = schedule?.source.fileId
? `${location.origin}/?${new URLSearchParams({ doc: schedule.source.fileId, group })}`
: "";
const allGroupLessons =
schedule?.lessons.filter((l) => l.groups.includes(group)) || [];
// Another subgroup's lessons never show; hidden ones only on request.
const groupLessons = allGroupLessons.filter(
(l) => personalFilter(l, group, prefs) === "shown",
);
const hiddenLessons = allGroupLessons.filter(
(l) => personalFilter(l, group, prefs) === "hidden",
);
const isHidden = (l: Lesson) => personalFilter(l, group, prefs) === "hidden";
const visible = allGroupLessons.filter(
(l) => !isHidden(l) || showHidden,
).filter((l) => personalFilter(l, group, prefs) !== "other-subgroup").filter(
(l) =>
(mode === "calendar" ||
mode === "all" ||
l.variant === "all" ||
l.variant === mode) &&
`${l.subject} ${l.teacher} ${l.room}`
.toLowerCase()
.includes(search.toLowerCase()),
);
const datesCache = useMemo(
() => new Map<Lesson, string[] | null>(),
[calendar, schedule],
);
const datesOf = (lesson: Lesson) => {
if (!datesCache.has(lesson))
datesCache.set(lesson, lessonDates(lesson, calendar));
return datesCache.get(lesson)!;
};
/** true / false for a concrete date; null when the week mark is unreadable. */
const occursOn = (lesson: Lesson, iso: string | null) => {
if (!iso) return false;
const dates = datesOf(lesson);
if (dates) return dates.includes(iso);
// An unreadable mark still belongs to its row colour: a green row never runs in a white week.
const w = calendar?.weeks[weekIndexOf(calendar, iso)];
if (w && lesson.variant !== "all")
return (w.color === "white") === (lesson.variant === "upper") ? null : false;
return null;
};
const lessonsOn = (list: Lesson[], d: number) => {
if (!calendarMode) return list.filter((l) => l.day === d);
const iso = dateInWeek(week!, d);
return list.filter((l) => l.day === d && occursOn(l, iso) !== false);
};
const dayLessons = lessonsOn(visible, day);
const subgroupLabels = schedule ? subgroupsOf(schedule, group) : [];
/** "Занятия в Полоцке" for the shown lessons of a weekday; none on a day without them. */
const placesOfDay = (d: number, lessons: Lesson[]) =>
lessons.length
? placesFor(
placesOn(schedule?.places, group, subgroupLabels, d, calendarMode ? dateInWeek(week!, d) : null),
prefs?.subgroup,
)
: [];
const hiddenToday = lessonsOn(hiddenLessons, day).length;
const hiddenThisWeek = DAYS.reduce(
(sum, _, i) => sum + lessonsOn(hiddenLessons, i + 1).length,
0,
);
const hiddenToggle = (count: number) =>
count > 0 && (
<button
className="hidden-toggle"
aria-pressed={showHidden}
onClick={() => setShowHidden((v) => !v)}
>
<Icon name={showHidden ? "visibility_off" : "visibility"} size={20} />
{showHidden
? "Не показывать скрытые"
: `Скрыто: ${countLabel(count, ["занятие", "занятия", "занятий"])} · показать`}
</button>
);
const otherLessons = calendarMode
? visible.filter((l) => l.day === day && occursOn(l, dayIso) === false)
: [];
const clock = clockMinutes(now);
const listContext = (showsToday: boolean): ListContext => ({
group,
onOpen: setDetails,
calendar,
bells: fullCalendar,
datesOf,
today,
clock: showsToday ? clock : null,
isHidden,
week: calendarMode
? { n: week!.n, color: week!.color }
: mode === "upper" || mode === "lower"
? { n: null, color: mode === "upper" ? "white" : "green" }
: null,
});
const scheduleDated =
!!schedule?.lessons.length && schedule.lessons.every((l) => l.date);
const calendarNotice = !schedule || !fullCalendar
? ""
: !calendar
? scheduleDated
? ""
: /весенн|осенн/i.test(schedule.period || "")
? `Таблица недель на ${(schedule.period || "").toLowerCase()}${schedule.academicYear ? ` ${schedule.academicYear}/${String(Number(schedule.academicYear) + 1).slice(2)}` : ""} ещё не опубликована — показаны все строки Word.`
: "Не удалось определить семестр этого расписания по таблице недель — показаны все строки Word."
: today > calendar.semester.end
? `${calendar.semester.label} закончился ${longDate(calendar.semester.end)}. Новое расписание откройте в разделе «Группы», когда университет его опубликует.`
: today < calendar.semester.start && !scheduleDated
? `${calendar.semester.label} начинается ${longDate(calendar.semester.start)}.`
: fullCalendar.stale && mode === "calendar"
? "Таблица недель не обновилась: показана сохранённая копия."
: "";
const dayIsToday = calendarMode ? dayIso === today : day === todayDay;
const detailDates = details && calendar ? datesOf(details) : undefined;
const detailBell = details ? bellFor(fullCalendar, details.start) : null;
const activeSaved = saved.some((s) => s.id === schedule?.id) && !storageError;
async function installApp() {
if (installPrompt) {
await installPrompt.prompt();
setInstallPrompt(null);
} else {
closeModal();
setHelp(true);
}
}
const openGroups = (query = "") => {
closeModal();
setGroupQuery(query);
setScreen("groups");
window.scrollTo({ top: 0 });
};
const openMenu = () => {
closeModal();
setMenu(true);
};
const menuActive = menu || about || help || importer;
const onSchedule = screen === "schedule" && !menuActive;
const onGroups = screen === "groups" && !menuActive;
const onSearch = screen === "search" && !menuActive;
const showScreen = (next: "schedule" | "search" | "groups") => {
closeModal();
setScreen(next);
window.scrollTo({ top: 0 });
};
const navigation = (className: string) => (
<nav
className={className}
aria-label={
className === "navigation-rail"
? "Основная навигация"
: "Мобильная навигация"
}
>
<button
className={onSchedule ? "active" : ""}
aria-current={onSchedule ? "page" : undefined}
onClick={() => showScreen("schedule")}
>
<span className="nav-indicator">
<Icon name="calendar_month" fill={onSchedule} />
</span>
<span>Расписание</span>
</button>
<button
className={onGroups ? "active" : ""}
aria-current={onGroups ? "page" : undefined}
onClick={() => openGroups()}
>
<span className="nav-indicator">
<Icon name="groups" fill={onGroups} />
</span>
<span>Группы</span>
</button>
<button
className={onSearch ? "active" : ""}
aria-current={onSearch ? "page" : undefined}
onClick={() => showScreen("search")}
>
<span className="nav-indicator">
<Icon
name="travel_explore"
fill={onSearch}
/>
</span>
<span>Поиск</span>
</button>
<button
className={menuActive ? "active" : ""}
aria-current={menuActive ? "page" : undefined}
onClick={openMenu}
>
<span className="nav-indicator">
<Icon name="more_horiz" />
</span>
<span>Меню</span>
</button>
</nav>
);
return (
<div className="app-shell">
<a className="skip-link" href="#schedule-content">
К расписанию
</a>
{navigation("navigation-rail")}
<main className="main" id="schedule-content">
<header className={`app-bar ${scrolled ? "scrolled" : ""}`}>
<h1>
{screen === "search"
? "Поиск по университету"
: screen === "groups"
? "Группы"
: "Расписание"}
</h1>
<div className="app-actions">
<button
className="icon-button"
hidden={screen !== "schedule"}
onClick={refresh}
disabled={loading || catalogLoading || !online}
aria-label="Обновить расписание"
title="Обновить расписание"
>
<Icon
name="refresh"
className={loading || catalogLoading ? "spin" : ""}
/>
</button>
<button
className="icon-button menu-trigger"
onClick={openMenu}
aria-label="Открыть меню"
>
<Icon name="more_vert" />
</button>
</div>
</header>
<div className="main-content">
{screen === "search" ? (
<Search calendar={fullCalendar} online={online} now={now} />
) : screen === "groups" ? (
<Groups
current={
schedule && group
? {
group,
faculty: schedule.source.faculty,
course: schedule.source.course,
name: schedule.source.name,
}
: null
}
siblings={schedule ? schedule.groups.filter((g) => g !== group) : []}
subgroups={subgroups}
subgroup={prefs?.subgroup || null}
onSubgroup={(label) => updatePrefs((p) => ({ ...p, subgroup: label }))}
onSibling={(g) => {
if (!schedule) return;
setGroup(g);
savePreference(schedule.id, g);
setRecentGroups(rememberGroup(recentOf(schedule, g)));
showScreen("schedule");
}}
// Groups of the open document are already in the current card.
recent={recentGroups.filter(
(r) => !(schedule && r.document === documentKey(schedule)),
)}
onRecent={async (r) => {
const savedCopy = readSaved().find((x) => documentKey(x) === r.document);
const file = r.fileId && catalog?.files.find((f) => f.id === r.fileId);
if (file && online) await loadFile(file, false, r.group);
else if (savedCopy) {
activate(savedCopy, r.group);
showScreen("schedule");
} else setError("Эта группа не сохранена на устройстве. Найдите её в каталоге.");
}}
onOpen={async (fileId, g) => {
const file =
catalog?.files.find((f) => f.id === fileId) ||
(await loadCatalog())?.files.find((f) => f.id === fileId);
if (!file) {
setError("Документ не найден в каталоге. Обновите страницу.");
return false;
}
return loadFile(file, false, g);
}}
onImport={() => {
closeModal();
setImporter(true);
}}
online={online}
initialQuery={groupQuery}
firstRun={!schedule}
/>
) : (
<>
<button
className="group-selector"
onClick={() => openGroups()}
aria-label={`Сменить группу, сейчас ${group}`}
>
<span>
<strong>{group || "Выберите группу"}</strong>
<span className="group-context">
{schedule?.source.faculty
? facultyShort(schedule.source.faculty)
: schedule
? "Загруженный Word"
: "ФЭФ"}
{schedule?.source.course
? ` · ${schedule.source.course}`
: schedule
? ""
: " · 4 курс"}
{prefs?.subgroup ? ` · ${lower(prefs.subgroup)}` : ""}
</span>
</span>
<span className="group-selector-arrow">
<Icon name="keyboard_arrow_down" />
</span>
</button>
{(error ||
notice ||
!online ||
catalog?.stale ||
storageError ||
calendarNotice) && (
<div
role="status"
className={`status-message ${error || storageError ? "error" : ""}`}
>
<Icon
name={
error || storageError
? "error"
: !online
? "cloud_off"
: "info"
}
/>
<span>
{error ||
notice ||
(storageError
? "Не удалось сохранить данные в браузере. Освободите память и повторите сохранение."
: !online
? "Нет сети. Открыто сохранённое расписание."
: catalog?.stale
? catalog.error || "Открыта сохранённая копия каталога."
: calendarNotice)}
</span>
{error && <button onClick={refresh}>Повторить</button>}
</div>
)}
{changes && (
<div className="update-banner changes-banner" role="status">
<span className="update-icon" aria-hidden="true">
<Icon name="difference" />
</span>
<div className="update-text">
<strong>
Расписание обновлено ·{" "}
{countLabel(changeCount(changes.diff), [
"изменение",
"изменения",
"изменений",
])}
</strong>
<span>
Для {changes.group}
{changes.to ? ` · файл от ${dateLabel(changes.to)}` : ""}
</span>
</div>
<div className="update-actions">
<button
className="primary"
onClick={() => {
closeModal();
setChangesOpen(true);
}}
>
Показать
</button>
<button
className="icon-button"
aria-label="Скрыть сообщение об изменениях"
onClick={() => setChanges(null)}
>
<Icon name="close" />
</button>
</div>
</div>
)}
{nextExam && (
<button
className="exam-card"
onClick={() => {
closeModal();
setExamsOpen(true);
}}
>
<span className="exam-countdown" aria-hidden="true">
{daysUntil(today, nextExam.date) <= 1 ? (
<b className="exam-soon">{countdown(daysUntil(today, nextExam.date))}</b>
) : (
<>
<b>{daysUntil(today, nextExam.date)}</b>
<small>
{countdown(daysUntil(today, nextExam.date)).split(" ").at(-1)}
</small>
</>
)}
</span>
<span className="exam-text">
<small>
Сессия · {nextExam.type.toLowerCase()}{" "}
{countdown(daysUntil(today, nextExam.date))}
</small>
<strong>{nextExam.subject}</strong>
<span>
{DAYS[weekday(nextExam.date) - 1]}, {longDate(nextExam.date)} ·{" "}
{nextExam.start}
{nextExam.rooms.length
? ` · ауд. ${nextExam.rooms.join(", ")}`
: ""}
</span>
</span>
<span className="exam-more">
{upcomingExams.length > 1 && <small>ещё {upcomingExams.length - 1}</small>}
<Icon name="chevron_right" />
</span>
</button>
)}
{update && (
<div className="update-banner" role="status">
<span className="update-icon" aria-hidden="true">
<Icon name={update.kind === "newer" ? "description" : "folder_open"} />
</span>
<div className="update-text">
{update.kind === "newer" ? (
<>
<strong>Опубликовано новое расписание {group}</strong>
<span>
{update.file.name.replace(/\.docx?$/i, "")} ·{" "}
{facultyShort(update.file.faculty)}
{update.file.year ? ` · ${update.file.year}` : ""} · файл от{" "}
{dateLabel(update.file.modified)}
</span>
</>
) : (
<>
<strong>Документа больше нет в каталоге</strong>
<span>
«{schedule?.source.name}» не найден в каталоге{" "}
{update.year}. Сохранённое расписание могло устареть.
</span>
</>
)}
</div>
<div className="update-actions">
{update.kind === "newer" ? (
<button
className="primary"
disabled={loading}
onClick={async () => {
dismissUpdate(update.file.id);
if (await loadFile(update.file, false, group))
setNotice(`Открыто новое расписание: ${update.file.name}.`);
}}
>
Открыть
</button>
) : (
<button
className="primary"
onClick={() => openGroups(group)}
>
Найти группу
</button>
)}
<button
className="icon-button"
aria-label={
update.kind === "newer"
? "Не переключаться на новое расписание"
: "Скрыть сообщение"
}
onClick={() => {
if (update.kind === "newer") dismissUpdate(update.file.id);
setUpdate(null);
}}
>
<Icon name="close" />
</button>
</div>
</div>
)}
<section
className="timetable"
aria-label="Расписание выбранной группы"
>
<div className="schedule-toolbar">
<div className="view-switch" aria-label="Вид расписания">
<button
className={view === "day" ? "selected" : ""}
aria-pressed={view === "day"}
onClick={() => setView("day")}
>
{view === "day" && <Icon name="check" size={20} />}
День
</button>
<button
className={view === "week" ? "selected" : ""}
aria-pressed={view === "week"}
onClick={() => setView("week")}
>
{view === "week" && <Icon name="check" size={20} />}
Неделя
</button>
</div>
<label className="variant-select">
<Icon name="filter_list" size={20} />
<select
aria-label="Какие занятия показывать"
value={mode}
onChange={(e) => setMode(e.target.value as Mode)}
>
{calendar && <option value="calendar">По неделям</option>}
<option value="all">Все строки Word</option>
<option value="upper">Белые строки</option>
<option value="lower">Зелёные строки</option>
</select>
<Icon name="keyboard_arrow_down" size={20} />
</label>
</div>
{calendarMode && (
<div className="week-switcher" aria-label="Учебная неделя">
<button
className="icon-button"
aria-label="Предыдущая неделя"
disabled={weekIdx === 0}
onClick={() => stepWeek(-1)}
>
<Icon name="chevron_left" />
</button>
<div className="week-label" aria-live="polite">
<strong>Неделя {week!.n}</strong>
<span className={`week-chip ${week!.color}`}>
{week!.color === "white" ? "белая" : "зелёная"}
</span>
<small>{weekSpan(week!.start, week!.end)}</small>
</div>
{!(week!.start <= today && today <= week!.end) &&
todayInside && (
<button className="today-button" onClick={goToToday}>
<Icon name="calendar_month" size={18} />
Сегодня
</button>
)}
<button
className="icon-button"
aria-label="Следующая неделя"
disabled={weekIdx === calendar!.weeks.length - 1}
onClick={() => stepWeek(1)}
>
<Icon name="chevron_right" />
</button>
</div>
)}
<div
className={`day-strip ${calendarMode ? "dated" : ""}`}
aria-label="Дни недели"
>
{DAYS.map((name, index) => {
const d = index + 1,
iso = calendarMode ? dateInWeek(week!, d) : null,
isToday = calendarMode ? iso === today : d === todayDay,
has = lessonsOn(groupLessons, d).length > 0;
return (
<button
key={name}
className={`${day === d && view === "day" ? "selected" : ""} ${isToday ? "today" : ""}`}
aria-label={`${name}${iso ? `, ${longDate(iso)}` : ""}${isToday ? ", сегодня" : ""}${has ? "" : ", нет занятий"}`}
aria-pressed={day === d && view === "day"}
disabled={calendarMode && !iso}
onClick={() => goToDay(d)}
>
<span>{SHORT[index]}</span>
{iso && <span className="day-number">{dayNumber(iso)}</span>}
<span
className={`day-dot ${has ? "has-lessons" : ""}`}
aria-hidden="true"
/>
</button>
);
})}
</div>
<div className="day-title">
<div>
<h2>{view === "day" ? DAYS[day - 1] : calendarMode ? `Неделя ${week!.n}` : "Вся неделя"}</h2>
{view === "day" && dayIso && (
<p className="day-date">
{longDate(dayIso)}
{dayIso === today ? " · сегодня" : ""}
</p>
)}
{view === "week" && calendarMode && (
<p className="day-date">{weekSpan(week!.start, week!.end)}</p>
)}
</div>
<div className="day-actions">
<button
className="icon-button"
aria-label="Условия расписания"
onClick={() => {
closeModal();
setAbout(true);
}}
>
<Icon name="info" />
</button>
{view === "day" && (
<>
<button
className="icon-button"
aria-label="Предыдущий день"
onClick={() => stepDay(-1)}
>
<Icon name="chevron_left" />
</button>
<button
className="icon-button"
aria-label="Следующий день"
onClick={() => stepDay(1)}
>
<Icon name="chevron_right" />
</button>
</>
)}
</div>
</div>
<label className="search-bar">
<Icon name="search" />
<input
type="search"
placeholder="Предмет, преподаватель, аудитория"
aria-label="Поиск по расписанию"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
className="icon-button"
aria-label="Очистить поиск"
onClick={() => setSearch("")}
>
<Icon name="close" />
</button>
)}
</label>
{!schedule && (loading || catalogLoading) ? (
<div
className="schedule-skeleton"
role="status"
aria-label="Загружаем расписание"
>
<span />
<span />
<span />
</div>
) : !schedule ? (
<div className="empty-state">
<Icon name="calendar_month" size={40} />
<h3>Выберите свою группу</h3>
<p>
Найдите расписание в каталоге университета или импортируйте
Word через меню.
</p>
<button className="primary" onClick={() => openGroups()}>
Найти группу
<Icon name="arrow_forward" size={20} />
</button>
</div>
) : view === "day" ? (
<div
key={`${weekIdx}-${day}`}
className="day-pane"
data-slide={slide || undefined}
onTouchStart={(e) => {
const t = e.touches[0];
touch.current = { x: t.clientX, y: t.clientY };
}}
onTouchEnd={(e) => {
const start = touch.current;
touch.current = null;
if (!start) return;
const t = e.changedTouches[0],
dx = t.clientX - start.x,
dy = t.clientY - start.y;
if (Math.abs(dx) > 64 && Math.abs(dx) > Math.abs(dy) * 1.5)
stepDay(dx < 0 ? 1 : -1);
}}
>
{dayLessons.length ? (
<>
<DayPlaces places={placesOfDay(day, dayLessons)} />
<ScheduleList
lessons={dayLessons}
ctx={listContext(dayIsToday)}
/>
</>
) : (
<div className="empty-state">
<Icon
name={search ? "search_off" : "event_busy"}
size={40}
/>
<h3>
{search
? "Ничего не найдено"
: calendarMode && otherLessons.length
? "На этой неделе занятий нет"
: "Занятий нет"}
</h3>
<p>
{search
? "Измените запрос или выберите другой день."
: calendarMode && !dayIso
? "Семестр начинается во вторник, 1 сентября."
: calendarMode && otherLessons.length
? "В другие недели в этот день есть занятия — они ниже."
: calendarMode
? "По таблице недель в этот день занятий нет."
: "В выбранных строках документа нет занятий на этот день."}
</p>
{search && (
<button
className="secondary"
onClick={() => setSearch("")}
>
Сбросить поиск
</button>
)}
</div>
)}
{otherLessons.length > 0 && (
<details className="other-weeks">
<summary>
<span>
Не на этой неделе ·{" "}
{countLabel(otherLessons.length, [
"занятие",
"занятия",
"занятий",
])}
</span>
<Icon name="keyboard_arrow_down" />
</summary>
<ScheduleList
lessons={otherLessons}
ctx={listContext(false)}
muted
/>
</details>
)}
{hiddenToggle(hiddenToday)}
</div>
) : (
<div
key={`week-${weekIdx}`}
className="week-list day-pane"
data-slide={slide || undefined}
>
{DAYS.map((name, index) => {
const d = index + 1,
lessons = lessonsOn(visible, d),
iso = calendarMode ? dateInWeek(week!, d) : null;
return lessons.length ? (
<section className="week-day" key={name}>
<h2>
{name}
{iso && <small>{longDate(iso)}</small>}
</h2>
<DayPlaces places={placesOfDay(d, lessons)} />
<ScheduleList
lessons={lessons}
ctx={listContext(
calendarMode ? iso === today : d === todayDay,
)}
/>
</section>
) : null;
})}
{hiddenToggle(hiddenThisWeek)}
{!DAYS.some((_, i) => lessonsOn(visible, i + 1).length) && (
<div className="empty-state">
<Icon
name={search ? "search_off" : "event_busy"}
size={40}
/>
<h3>{search ? "Ничего не найдено" : "Занятий нет"}</h3>
{search && (
<button
className="secondary"
onClick={() => setSearch("")}
>
Сбросить поиск
</button>
)}
</div>
)}
</div>
)}
</section>
</>
)}
</div>
</main>
{navigation("navigation-bar")}
<dialog
ref={dialogRef}
aria-labelledby="modal-heading"
className={`modal ${menu ? "menu-modal" : ""}`}
onCancel={closeModal}
onClick={(e) => {
if (e.target === dialogRef.current) closeModal();
}}
>
<span className="sheet-handle" aria-hidden="true" />
<div className="modal-header">
<div>
<h2 id="modal-heading" tabIndex={-1}>
{menu
? "Меню"
: about
? "О расписании"
: importer
? "Импорт из Word"
: details
? "О занятии"
: changesOpen
? "Что изменилось"
: share
? "Поделиться расписанием"
: examsOpen
? `Сессия ${group}`
: "Расписание на вашем телефоне"}
</h2>
</div>
<button
className="icon-button"
aria-label="Закрыть"
onClick={closeModal}
>
<Icon name="close" />
</button>
</div>
{menu && (
<div className="menu-list">
<div className="theme-row" role="group" aria-labelledby="theme-label">
<span id="theme-label">Тема</span>
<div className="view-switch theme-switch">
{(
[
["system", "Авто", "Как в системе"],
["light", "Светлая", "Светлая тема"],
["dark", "Тёмная", "Тёмная тема"],
] as const
).map(([value, label, hint]) => (
<button
key={value}
className={theme === value ? "selected" : ""}
aria-pressed={theme === value}
aria-label={hint}
title={hint}
onClick={() => {
setTheme(value);
saveTheme(value);
switchTheme(value);
}}
>
{theme === value && <Icon name="check" size={18} />}
{label}
</button>
))}
</div>
</div>
<button
disabled={!schedule?.source.fileId}
onClick={() => {
closeModal();
setShare(true);
}}
>
<span className="menu-icon">
<Icon name="share" />
</span>
<span>
Поделиться расписанием {schedule ? group : ""}
{schedule && !schedule.source.fileId && (
<small>Импортированный файл есть только на этом устройстве</small>
)}
</span>
{schedule?.source.fileId && <Icon name="chevron_right" />}
</button>
{lastChanges && (
<button
onClick={() => {
closeModal();
setChangesOpen(true);
}}
>
<span className="menu-icon">
<Icon name="difference" />
</span>
<span>
Последние изменения
<small>
{countLabel(changeCount(lastChanges.diff), [
"изменение",
"изменения",
"изменений",
])}{" "}
· {dateLabel(lastChanges.at)}
</small>
</span>
<Icon name="chevron_right" />
</button>
)}
<button
onClick={() => {
closeModal();
setImporter(true);
}}
>
<span className="menu-icon">
<Icon name="upload_file" />
</span>
<span>Импорт из Word</span>
<Icon name="chevron_right" />
</button>
<button
onClick={() => {
closeModal();
setAbout(true);
}}
>
<span className="menu-icon">
<Icon name="description" />
</span>
<span>О расписании и источнике</span>
<Icon name="chevron_right" />
</button>
<button disabled={installed} onClick={installApp}>
<span className="menu-icon">
<Icon name="mobile_arrow_down" />
</span>
<span>
{installed ? "Приложение установлено" : "Установить приложение"}
</span>
{!installed && <Icon name="chevron_right" />}
</button>
<a href={SOURCE} target="_blank" rel="noreferrer">
<span className="menu-icon">
<Icon name="folder_open" />
</span>
<span>Каталог университета</span>
<Icon name="open_in_new" />
</a>
</div>
)}
{about && (
<div className="about-schedule">
{schedule && (
<>
<div className="document-name">
<Icon name="description" size={32} />
<strong>{schedule.source.name}</strong>
</div>
<dl className="source-facts">
<div>
<dt>Группа</dt>
<dd>{group}</dd>
</div>
{schedule.source.faculty && (
<div>
<dt>Факультет</dt>
<dd>{schedule.source.faculty}</dd>
</div>
)}
<div>
<dt>Импортировано</dt>
<dd>{dateLabel(schedule.source.importedAt)}</dd>
</div>
{schedule.source.modified && (
<div>
<dt>Файл обновлён</dt>
<dd>{dateLabel(schedule.source.modified)}</dd>
</div>
)}
<div>
<dt>На устройстве</dt>
<dd>
{activeSaved
? "Сохранено для просмотра без сети"
: "Не сохранено"}
</dd>
</div>
</dl>
{schedule.source.fileId && (
<a
className="secondary"
href={`/api/original/${schedule.source.fileId}`}
download
>
<Icon name="download" size={20} />
Оригинал Word
</a>
)}
</>
)}
<h3>Учебные недели</h3>
{calendar ? (
<>
<p>
{calendar.semester.label}
{schedule?.academicYear ? "" : " (определён по текущей дате)"}.{" "}
{todayInside
? `Сейчас неделя ${calendar.weeks[weekIndexOf(calendar, today)].n}, ${calendar.weeks[weekIndexOf(calendar, today)].color === "white" ? "белая" : "зелёная"}. `
: ""}
Нечётные недели белые, чётные — зелёные: так строки Word
соответствуют календарю. Семестр по таблице университета:{" "}
{weekSpan(calendar.weeks[0].start, calendar.weeks.at(-1)!.end)}
{calendar.source.weeks
? `, файл обновлён ${dateLabel(calendar.source.weeks)}`
: ""}
.
</p>
<p>
Пометки вроде «6-13н» в белой или зелёной строке относятся
только к неделям этого цвета. Нераспознанные пометки
показываются с предупреждением.
</p>
</>
) : (
<p>
{fullCalendar
? calendarNotice ||
"Даты этого расписания не рассчитываются: показаны все строки Word."
: "Таблица недель ещё не загружена: показаны все варианты из Word. Подключитесь к сети, чтобы рассчитать даты занятий."}
</p>
)}
{fullCalendar && fullCalendar.bells.length > 0 && (
<>
<h3>Звонки</h3>
<table className="bells">
<tbody>
{fullCalendar.bells.map((b) => (
<tr key={b.pair}>
<th scope="row">{b.pair} пара</th>
{b.halves.map(([a, c]) => (
<td key={a}>
{a}–{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{schedule?.warnings.map((w) => (
<p key={w}>{w}</p>
))}
{schedule && schedule.notes.length > 0 && (
<>
<h3>Примечания из Word</h3>
{schedule.notes.map((note, index) => (
<p key={index}>{note}</p>
))}
</>
)}
</div>
)}
{importer && (
<>
<p className="modal-intro">
Перенесите расписание в карточки. Перед сохранением можно
проверить результат.
</p>
<input
ref={fileInput}
hidden
type="file"
accept=".doc,.docx"
onChange={(e) => importFile(e.target.files?.[0])}
/>
<button
className="upload-zone"
disabled={importing}
onClick={() => fileInput.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (!importing) importFile(e.dataTransfer.files[0]);
}}
>
<span>
{importing ? (
<Icon name="refresh" size={32} className="spin" />
) : (
<Icon name="upload_file" size={32} />
)}
</span>
<b>
{importing
? "Читаем документ…"
: "Выберите или перетащите Word"}
</b>
<small>.docx или .doc · до 10 МБ</small>
</button>
<p className="privacy-note">
Файл обрабатывается сервером этого приложения. Загруженные
документы не сохраняются на сервере; расписание хранится в вашем
браузере.
</p>
{importError && (
<p className="inline-error" role="alert">
{importError}
</p>
)}
{draft && (
<div className="import-preview">
<div className="import-success">
<Icon name="check" size={20} />
Распознано {draft.stats.lessons} записей ·{" "}
{draft.groups.length} групп
</div>
<label>
Ваша группа
<select
value={draftGroup}
onChange={(e) => setDraftGroup(e.target.value)}
>
{draft.groups.map((g) => (
<option key={g}>{g}</option>
))}
</select>
<Icon name="keyboard_arrow_down" />
</label>
<p>Первые занятия для проверки</p>
{draft.lessons
.filter((l) => l.groups.includes(draftGroup))
.slice(0, 3)
.map((l, i) => (
<div className="preview-line" key={`${l.id}-${i}`}>
<small>
{DAYS[l.day - 1]} · {l.start}
</small>
<b>{l.subject}</b>
<span>
{l.condition} {l.teacher} {(l.room || l.place) && `· ${whereLabel(l, "ауд.")}`}
</span>
</div>
))}
{draft.warnings.map((w) => (
<p className="parser-note" key={w}>
{w}
</p>
))}
<button
className="primary"
onClick={() => {
activate(draft, draftGroup);
setImporter(false);
setDraft(null);
}}
>
Сохранить расписание
<Icon name="check" size={20} />
</button>
</div>
)}
</>
)}
{details && (
<div className="lesson-details">
<div className="detail-tags">
<span className={`type-tag ${lessonTheme(details.type)}`}>
{details.type}
</span>
{details.variant !== "all" && (
<span className={`variant-tag ${details.variant}`}>
<i aria-hidden="true" />
{details.variant === "upper"
? "Белые недели"
: "Зелёные недели"}
</span>
)}
</div>
<h3>{details.subject}</h3>
<div className="detail-grid">
<span>
<Icon name="calendar_month" size={20} />
<small>День</small>
<b>
{DAYS[details.day - 1]}
{details.date ? `, ${dateLabel(details.date)}` : ""}
</b>
</span>
<span>
<Icon name="schedule" size={20} />
<small>
{pairLabel(detailBell, details) || "Время из таблицы"}
</small>
<b>
{details.start}
{details.end
? ` — ${details.end}`
: detailBell
? ` — ${detailBell.end}`
: " · конец не указан"}
</b>
{detailBell && detailBell.halves.length > 1 && (
<small className="detail-halves">
{detailBell.halves.map(([a, b]) => `${a}–${b}`).join(", ")}
</small>
)}
</span>
<span>
<Icon name="person" size={20} />
<small>Преподаватель</small>
<b>{details.teacher || "Не указан"}</b>
</span>
<span>
<Icon name="location_on" size={20} />
<small>Аудитория</small>
<b>{details.room || "Не указана"}</b>
{details.plan?.some((p) => p.rooms.length || p.teachers.length) && (
<small className="detail-halves">
{details.plan.filter((p) => p.rooms.length || p.teachers.length).map(planLine).join("; ")}
</small>
)}
</span>
{details.place && (
<span>
<Icon name="location_city" size={20} />
<small>Город</small>
<b>{details.place}</b>
</span>
)}
</div>
{calendar && detailDates && !details.date && hasWeekLimit(details) && (
<section className="detail-weeks" aria-label="Недели занятия">
<h4>Недели занятия</h4>
<p className="detail-status">
{lessonStatus(detailDates, today)}
{(() => {
const next = detailDates.find((d) => d >= today);
return next && detailDates.length > 1 && detailDates[0] < today
? ` · ближайшее: ${DAYS[weekday(next) - 1].toLowerCase()}, ${longDate(next)}`
: "";
})()}
</p>
<WeekGrid
calendar={calendar}
dates={detailDates}
today={today}
/>
</section>
)}
{calendar && detailDates === null && (
<div className="detail-condition unknown">
<Icon name="error" size={20} />
Пометку «{details.condition}» не удалось сопоставить с таблицей
недель. Сверьте её с Word.
</div>
)}
{details.condition && detailDates !== null && (
<div className="detail-condition">
<Icon name="event_repeat" size={20} />
Пометка в Word: {details.condition}
</div>
)}
<h4>Исходная запись</h4>
<blockquote>{details.raw}</blockquote>
<p className="privacy-note">
Группы: {details.groups.join(" · ")}.{" "}
{details.subgroups.find((s) => s.group === group)?.label || ""}{" "}
{calendar
? `Недели рассчитаны по таблице университета${calendar.source.weeks ? ` от ${dateLabel(calendar.source.weeks)}` : ""}: белые строки — нечётные недели, зелёные — чётные.`
: "Цветные строки и ограничения по неделям сохранены из документа."}
</p>
{schedule?.source.fileId && (
<a
className="text-link detail-original"
href={`/api/original/${schedule.source.fileId}`}
download
>
<Icon name="download" size={20} />
Скачать оригинал Word
</a>
)}
<div className="detail-actions">
<button
className="secondary"
onClick={() => {
const key = lessonKey(details, group);
updatePrefs((p) => ({
...p,
hidden: isHidden(details)
? (p.hidden || []).filter((k) => k !== key)
: [...(p.hidden || []), key],
}));
if (!isHidden(details))
setNotice(
`«${details.subject}» скрыто. Вернуть можно кнопкой «Скрыто · показать» под занятиями.`,
);
closeModal();
}}
>
<Icon
name={isHidden(details) ? "visibility" : "visibility_off"}
size={20}
/>
{isHidden(details) ? "Вернуть в расписание" : "Скрыть занятие"}
</button>
<button className="primary" onClick={closeModal}>
Понятно
<Icon name="check" size={18} />
</button>
</div>
</div>
)}
{examsOpen && (
<div className="exams">
<p className="modal-intro">
Экзамены, зачёты и консультации {group} из всех документов каталога.
Сверяйте с Word: время и аудитории могут уточняться.
</p>
<ul>
{exams
.filter((e) => e.date >= today)
.map((e, i) => (
<li key={`u${i}`} className="exam-item">
<span className={`exam-chip ${daysUntil(today, e.date) <= 3 ? "soon" : ""}`}>
{countdown(daysUntil(today, e.date))}
</span>
<span className="exam-body">
<span className="lesson-tags">
<span className={`type-tag ${e.type === "Экзамен" ? "lecture" : e.type === "Зачёт" ? "practice" : "other"}`}>
{e.type}
</span>
</span>
<strong>{e.subject}</strong>
<span className="lesson-meta">
<span>
<Icon name="calendar_month" size={20} />
{DAYS[weekday(e.date) - 1]}, {longDate(e.date)} · {e.start}
</span>
{e.rooms.length > 0 && (
<span>
<Icon name="location_on" size={20} />
Ауд. {e.rooms.join(", ")}
</span>
)}
{e.teachers.length > 0 && (
<span>
<Icon name="person" size={20} />
{e.teachers.join(", ")}
</span>
)}
</span>
<small className="exam-source">{e.document.name}</small>
</span>
</li>
))}
</ul>
{exams.some((e) => e.date < today) && (
<details className="other-weeks">
<summary>
<span>
Прошедшие · {exams.filter((e) => e.date < today).length}
</span>
<Icon name="keyboard_arrow_down" />
</summary>
<ul>
{exams
.filter((e) => e.date < today)
.map((e, i) => (
<li key={`p${i}`} className="exam-item past">
<span className="exam-chip">{shortDate(e.date)}</span>
<span className="exam-body">
<strong>{e.subject}</strong>
<small>
{e.type} · {e.start}
{e.rooms.length ? ` · ауд. ${e.rooms.join(", ")}` : ""}
</small>
</span>
</li>
))}
</ul>
</details>
)}
</div>
)}
{changesOpen && (changes || lastChanges) && (
<ChangesList record={(changes || lastChanges)!} />
)}
{share && schedule?.source.fileId && (
<div className="share">
<p className="modal-intro">
По ссылке откроется расписание {group} из каталога университета.
Ваша подгруппа и скрытые занятия не передаются.
</p>
<QrCode text={shareUrl} />
<label className="share-link">
<Icon name="link" size={20} />
<input
readOnly
value={shareUrl}
aria-label="Ссылка на расписание"
onFocus={(e) => e.target.select()}
/>
</label>
<div className="share-actions">
<button
className="secondary"
onClick={async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
} catch {
setCopied(false);
}
}}
>
<Icon name={copied ? "check" : "content_copy"} size={20} />
{copied ? "Скопировано" : "Копировать"}
</button>
{"share" in navigator && (
<button
className="primary"
onClick={() =>
navigator
.share({
title: `Расписание ${group}`,
text: `Расписание ${group} — ПГУ`,
url: shareUrl,
})
.catch(() => {})
}
>
<Icon name="share" size={20} />
Отправить
</button>
)}
</div>
</div>
)}
{help && (
<div className="install-help">
<p>
Установите приложение на главный экран: сохранённое расписание будет
доступно даже без интернета.
</p>
<ol>
<li>
<b>Android / Chrome</b>
<span>
Меню браузера → «Установить приложение» или «Добавить на
главный экран».
</span>
</li>
<li>
<b>iPhone / Safari</b>
<span>«Поделиться» → «На экран „Домой“».</span>
</li>
<li>
<b>Компьютер</b>
<span>
Нажмите значок установки в адресной строке поддерживаемого
браузера.
</span>
</li>
</ol>
<p className="privacy-note">
Установка доступна на HTTPS или localhost в собранной версии
приложения. Откройте расписание онлайн один раз, чтобы сохранить
его.
</p>
<button className="primary" onClick={closeModal}>
Хорошо
<Icon name="check" size={20} />
</button>
</div>
)}
</dialog>
</div>
);
}