2504 lines
95 KiB
TypeScript
2504 lines
95 KiB
TypeScript
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>
|
||
);
|
||
}
|