feat: read "Занятия в Полоцке" as a day's town, show it in all clients

- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 01:26:54 +03:00
parent 3d08d0cb70
commit 10c85e3bb9
50 changed files with 808 additions and 80 deletions

View File

@@ -2,6 +2,7 @@
// third-party clients use it instead of re-implementing the white/green week rules.
import { semesterView, addDays, weekday, weekIndexOf } from "../shared/weeks.js";
import { lessonKey, subgroupsOf } from "../shared/lessons.js";
import { placesOn } from "../shared/places.js";
import { occurrence, peopleAndRooms, roomsOn, teachersOn, movedOn } from "./directory.js";
import { apiError } from "./errors.js";
@@ -24,6 +25,8 @@ function lessonOut(lesson, group, people, date, status, calendar, week = null) {
teachers: date ? teachersOn(people, week) : people.teachers,
rooms,
roomMoved: Boolean(date && movedOn(people, date)),
// "Полоцк": the town, when the cell or the day's note names one.
place: lesson.place || null,
distance: people.distance,
condition: lesson.condition,
variant: lesson.variant,
@@ -58,6 +61,7 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
}
let count = Math.min(Math.max(Number(days) || 7, 1), MAX_DAYS);
const lessons = schedule.lessons.filter((l) => l.groups.includes(group));
const labels = subgroupsOf(schedule, group);
// A session schedule printed with a date on every day needs no week table: its own dates
// say when each lesson takes place, even in a year the table no longer covers.
const sessionDates = lessons.every((l) => l.date || l.dates?.length)
@@ -112,11 +116,14 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
if (entry.lesson.date === date || entry.lesson.dates?.includes(date))
dayLessons.push(lessonOut(entry.lesson, group, entry, date, "yes", calendar));
dayLessons.sort((a, b) => a.start.localeCompare(b.start));
// A day without lessons (a holiday, weeks of practice) is in no town.
const places = dayLessons.length ? placesOn(schedule.places, group, labels, weekday(date), date) : [];
out.push({
date,
weekday: weekday(date),
week: week ? { n: week.n, color: week.color } : null,
lessons: dayLessons,
...(places.length && { places }),
});
}
const weekly = [1, 2, 3, 4, 5, 6, 7]
@@ -127,7 +134,11 @@ export function timetable({ file, schedule, calendar, group, from, days, range,
.map((e) => lessonOut(e.lesson, group, e, null, "yes", calendar))
.sort((a, b) => a.start.localeCompare(b.start)),
}))
.filter((d) => d.lessons.length);
.filter((d) => d.lessons.length)
.map((d) => {
const places = placesOn(schedule.places, group, labels, d.day);
return places.length ? { ...d, places } : d;
});
return {
group,
document: {