feat: Add skeleton loading UI components for various screens

- Implemented `Skeleton.kt` for loading placeholders in the app, including lesson cards, schedule, overview, and subjects.
- Introduced `Undo.kt` to manage undo actions with a snackbar interface.
- Created `WeekWidget.kt` for a widget displaying the week's schedule, including dynamic updates based on the current timetable.
- Added various splash animations in the `animator` directory for a smooth launch experience.
- Designed drawable resources for the widget and splash screen, including icons for city and room moves.
- Defined XML resources for widget configuration and photo paths.
This commit is contained in:
2026-10-23 21:56:07 +03:00
parent a3df53713f
commit b9c1f63301
59 changed files with 194390 additions and 178421 deletions

View File

@@ -80,7 +80,9 @@ app.use("/api", (_req, res, next) => {
// 12 — a JSON log line per request;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions;
// 14 — a lesson's `place` and a day's `places` ("Занятия в Полоцке") in /api/timetable.
const API_VERSION = 14;
// 15 — a lesson's `option` and the group's `choices` (German or French for parts of a group),
// a lesson's `audience` (foreign students or the others) and the group's `audience` flag.
const API_VERSION = 15;
/**
* What the server still waits for before it answers from memory: the catalog, the week table

View File

@@ -10,7 +10,7 @@ import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/w
import { roomMoves, onlyPlace, weekPlan, partsByWeek } from "./rooms.js";
import { placeNote, lessonPlace, placeOf } from "./places.js";
const run = promisify(execFile);
export const PARSER_VERSION = 15;
export const PARSER_VERSION = 17;
const days = [
"понедельник",
"вторник",
@@ -206,17 +206,89 @@ function byKind(text) {
return [`${weeks(m[1])} ${at(m[2])}`, `${weeks(m[3])} ${at(m[4])}`];
}
// Who a lesson is for: "(для иностранных студентов)", "Русский язык как иностранный" are for
// foreign students only, "(кроме иностр.студ.)" for the others.
const FOR_FOREIGN = /\(\s*для\s+иностр[^)]*\)/iu;
const NOT_FOREIGN = /\(\s*кроме\s+иностр[^)]*\)/iu;
const FOREIGN_SUBJECT = /как\s+иностранн/iu;
export function audienceOf(text) {
if (NOT_FOREIGN.test(text)) return "local";
if (FOR_FOREIGN.test(text) || FOREIGN_SUBJECT.test(text)) return "foreign";
return null;
}
/**
* One cell, two lessons at once: Russian for foreign students while the others have another
* lesson. "Ф и з и ч е с к а я к у л ь т у р а\nРусский язык как иностранный (для иностранных
* студентов) Семченко Е.Д. ауд.313н" or "Русский язык как иностранный (п.з.) Немирская В.А. 331Е
* ФИЗИЧЕСКАЯ КУЛЬТУРА". A line with only the teacher below the lesson ("Хаткевич Т.В. ауд. 323н")
* is no lesson of its own.
*/
// "Новополоцк" or "17.00" before a lesson is its town or time, not another lesson.
const aLesson = (text) =>
/(?:[А-ЯЁа-яё]\s?){3}/u.test(lessonPlace(text).rest.replace(/\d{1,2}[.:]\d{2}|(?:г\.\s*)?(?:ново)?полоцк[а-яё]*/giu, ""));
export function byAudience(text) {
const lines = text.split("\n");
const at = lines.findIndex(
(line, i) => i > 0 && audienceOf(line) === "foreign" && !/^\s*[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u.test(line),
);
const head = lines.slice(0, at).join("\n");
if (at > 0 && audienceOf(head) !== "foreign" && aLesson(head))
return [
{ text: lines.slice(0, at).join("\n").trim(), audience: "local" },
{ text: lines.slice(at).join("\n").trim(), audience: "foreign" },
];
// "… Немирская В.А. 331Е со 2 нед. ФИЗИЧЕСКАЯ КУЛЬТУРА": the others' lesson in capitals after it.
const tail = text.match(
/^([\s\S]*?(?:\d{2,3}\s*[А-ЯЁа-яё]?|[А-ЯЁ]\.))\s+((?:(?:с|со)\s*)?(?:\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?\s+)?[А-ЯЁ]{3,}(?:\s+[А-ЯЁ]{3,})+)\s*$/u,
);
if (tail && audienceOf(tail[1]) === "foreign" && !audienceOf(tail[2]))
return [
{ text: tail[1].trim(), audience: "foreign" },
{ text: tail[2].trim(), audience: "local" },
];
// "Ф и з и ч е с к а я к у л ь т у р а 24-КФ: Русский язык как иностранный …" on one line.
const inline = text.match(/^((?:[А-ЯЁа-яё]\s?){3}[^\n]*?)\s+((?:\d{2}-[^\s:]+:\s*)?Русский\s+язык\s+как\s+иностранн[\s\S]*)$/u);
if (inline && !audienceOf(inline[1]) && aLesson(inline[1]))
return [
{ text: inline[1].trim(), audience: "local" },
{ text: inline[2].trim(), audience: "foreign" },
];
return null;
}
// "25-ДОи", "24-ТНГи-2": a group of foreign students.
const FOREIGN_GROUP = /[А-ЯЁа-яё]и(?:-\d+)?$/u;
// A line that is a whole lesson: a subject, its kind in brackets and a teacher.
const WHOLE_LESSON = /^\s*[А-ЯЁ][^\n]*?\((?:л|п\.?\s*з|л\.?\s*з|лаб|пр|сем)\.?\)[^\n]*?[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u;
/**
* "Немецкий язык (п.з) – Найверт Ю.О. 208А,\nФранцузский язык (п.з.) – Глобенко М.В. 336Е":
* one cell over the whole group, two lessons at once for the parts of the group that study one
* or the other. A line without a subject of its own ("321Д") stays with the lesson above.
*/
export function parallel(text) {
const parts = [];
for (const line of text.split("\n")) {
if (!parts.length || WHOLE_LESSON.test(line)) parts.push(line);
else parts[parts.length - 1] += "\n" + line;
}
if (parts.length < 2 || !parts.every((p) => WHOLE_LESSON.test(p))) return [text];
return parts.map((p) => p.trim().replace(/[,;]+$/, ""));
}
/**
* A cell's entries, each `{text, main, plan}`: the whole text, its first line (subject, kind,
* teacher and room are read from it) and the plan. A line that names only rooms or teachers by week
* ("1-9н. 502н", "с 10н. – 359", "2-9н.-Клос О.Ю., 10-14н.-Конторов В.Э.") belongs to the lesson
* above: its plan of rooms and teachers by week. A line with weeks and a kind but no subject
* ("7,9,11н. – (п.з.) Савицкая К.Д. ауд. А1-06 8,12,14н. – (п.з.) …") is the subject above in
* other weeks. A stray "." is no lesson.
* other weeks. A stray "." is no lesson. Lessons side by side for parts of the group ([parallel])
* also carry `choice`: the subjects to choose from.
*/
export function entries(raw) {
const out = [];
for (const alternative of alternatives(raw).flatMap(byKind)) {
for (const part of alternatives(raw).flatMap(byKind)) {
// "…Третьяк З.И. 331Е,\n2-13н. …": the comma before the next lesson is not part of the room.
const alternative = part.replace(/[,;]\s*$/, "");
if (!/[\p{L}\d]/u.test(alternative)) continue;
const prev = out.at(-1);
if (prev && onlyPlace(alternative)) {
@@ -238,6 +310,24 @@ export function entries(raw) {
}
continue;
}
const options = parallel(alternative);
// "Теория языка (кроме иностр.студ.) … / История языка (для иностр.студ.) …": not a choice,
// each lesson is for its own students.
const marks = options.map(audienceOf);
if (options.length > 1 && marks.every(Boolean)) {
options.forEach((o, i) => out.push({ text: o, main: o, plan: [], audience: marks[i] }));
continue;
}
const split = byAudience(alternative);
if (split) {
for (const p of split) out.push({ text: p.text, main: p.text, plan: weekPlan(p.text).filter((w) => w.color), audience: p.audience });
continue;
}
if (options.length > 1) {
const choice = options.map((o) => details(o).subject);
for (const o of options) out.push({ text: o, main: o, plan: [], choice });
continue;
}
// "бел.н.504н/зел.н.524н": a room for white weeks and one for green ones.
out.push({ text: alternative, main: alternative, plan: weekPlan(alternative).filter((p) => p.color) });
}
@@ -400,6 +490,8 @@ export function parseDocx(buffer, source = {}) {
const lessons = [],
// "Занятия в Полоцке": the days a group, or one of its subgroups, spends in another town.
places = [],
// Subjects that parts of a group choose between: the second foreign language, an elective.
choices = [],
groups = new Set(),
warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
@@ -576,11 +668,23 @@ export function parseDocx(buffer, source = {}) {
raw = note.rest;
if (!raw.trim()) return;
}
for (const { text: whole, main: alternative, plan } of entries(raw)) {
for (const { text: whole, main, plan, choice, audience } of entries(raw)) {
if (choice) choices.push({ groups: targetGroups, options: choice });
// "24-КФ: Русский язык как иностранный": one of the cell's groups.
let alternative = main;
let lessonGroups = targetGroups;
const named = main.match(/^\s*([^:\n]{4,24}):\s+(?=\S)/u);
const only = named ? groupsIn(named[1]) : [];
if (only.length && only.every((g) => targetGroups.includes(g))) {
lessonGroups = only;
alternative = main.slice(named[0].length);
}
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the
// subject, nor is a move to another room on a date ("412н 26.10.2026-333").
const read = (text) => {
const data = details(roomMoves(text).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
// "(для иностранных студентов)" is who the lesson is for, no part of its name or room.
const plain = text.replace(FOR_FOREIGN, " ").replace(NOT_FOREIGN, " ");
const data = details(roomMoves(plain).rest.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
if (credits && data.type === "Занятие") data.type = "Зачёт";
return data;
};
@@ -618,7 +722,7 @@ export function parseDocx(buffer, source = {}) {
...(start && { start, end: lead.end }),
...data,
...overrides,
groups: targetGroups,
groups: lessonGroups,
subgroups,
variant: spansBoth ? "all" : variant,
weekRanges:
@@ -633,6 +737,10 @@ export function parseDocx(buffer, source = {}) {
? cell.color
: null,
raw: whole,
...(choice && { option: data.subject }),
// Groups of foreign students alone ("25-ДОи") have Russian as everyone's lesson.
...((audience || audienceOf(whole)) &&
!lessonGroups.every(FOREIGN_GROUP.test.bind(FOREIGN_GROUP)) && { audience: audience || audienceOf(whole) }),
// Rooms and teachers by week, or by the week's colour.
...(plan.length && { plan }),
});
@@ -696,6 +804,19 @@ export function parseDocx(buffer, source = {}) {
const labels = new Map();
for (const l of lessons)
for (const s of l.subgroups) labels.set(s.group, new Set([...(labels.get(s.group) || []), s.label]));
// The chosen subject on its own in a cell ("Немецкий язык" on Thursday) is the same choice:
// only that part of the group has it.
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
const sets = [];
for (const c of choices) {
const key = c.groups.join("|") + "#" + c.options.map(norm).sort().join("|");
if (!sets.some((s) => s.key === key)) sets.push({ key, groups: c.groups, options: c.options });
}
for (const lesson of lessons) {
if (lesson.option || lesson.subgroups.length) continue;
const set = sets.find((s) => s.groups.some((g) => lesson.groups.includes(g)) && s.options.some((o) => norm(o) === norm(lesson.subject)));
if (set) lesson.option = set.options.find((o) => norm(o) === norm(lesson.subject));
}
for (const lesson of lessons) {
if (lesson.place || !places.length) continue;
const place = placeOf(lesson, places, (group) => [...(labels.get(group) || [])]);
@@ -708,6 +829,7 @@ export function parseDocx(buffer, source = {}) {
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
),
...(places.length && { places }),
...(sets.length && { choices: sets.map(({ groups, options }) => ({ groups, options })) }),
notes: document.notes,
warnings: [...warnings],
source: {

View File

@@ -31,6 +31,10 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
condition: lesson.condition,
variant: lesson.variant,
subgroup: lesson.subgroups.find((s) => s.group === group)?.label || null,
// "Немецкий язык" when part of the group studies German and part French (see `choices`).
option: lesson.option || null,
// "foreign": for foreign students only (Russian as a foreign language); "local": for the others.
audience: lesson.audience || null,
groups: lesson.groups,
uncertain: status === "maybe",
raw: lesson.raw,
@@ -159,6 +163,10 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
...extra.map((e) => e.lesson.subgroups?.find((s) => s.group === group)?.label).filter(Boolean),
]),
].sort((a, b) => a.localeCompare(b, "ru", { numeric: true })),
// What parts of the group choose between: [["Немецкий язык", "Французский язык"]].
choices: (schedule.choices || []).filter((c) => c.groups.includes(group)).map((c) => c.options),
// Some lessons are for foreign students only, or for the others: worth asking which one you are.
audience: lessons.some((l) => l.audience),
bells: calendar?.bells || [],
from,
days: out,