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

@@ -60,6 +60,7 @@ import {
personalFilter,
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";
@@ -197,10 +198,10 @@ function LessonCard({
{lesson.teacher}
</span>
)}
{lesson.room && (
{(lesson.room || lesson.place) && (
<span>
<Icon name="location_on" size={20} />
Ауд. {lesson.room}
{whereLabel(lesson, "Ауд.")}
</span>
)}
{subgroup && (
@@ -248,6 +249,29 @@ function LessonCard({
</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,
@@ -434,7 +458,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
<small>{whenLabel(l)}</small>
<b>{l.subject}</b>
<span className="change-field">
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition]
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean)
.join(" · ")}
</span>
@@ -452,7 +476,7 @@ function ChangesList({ record }: { record: ChangeRecord }) {
<small>{whenLabel(l)}</small>
<b>{l.subject}</b>
<span className="change-field">
{[l.type, l.teacher, l.room && `ауд. ${l.room}`, l.condition]
{[l.type, l.teacher, whereLabel(l, "ауд."), l.condition]
.filter(Boolean)
.join(" · ")}
</span>
@@ -998,6 +1022,15 @@ export default function App() {
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,
@@ -1638,10 +1671,13 @@ export default function App() {
}}
>
{dayLessons.length ? (
<ScheduleList
lessons={dayLessons}
ctx={listContext(dayIsToday)}
/>
<>
<DayPlaces places={placesOfDay(day, dayLessons)} />
<ScheduleList
lessons={dayLessons}
ctx={listContext(dayIsToday)}
/>
</>
) : (
<div className="empty-state">
<Icon
@@ -1714,6 +1750,7 @@ export default function App() {
{name}
{iso && <small>{longDate(iso)}</small>}
</h2>
<DayPlaces places={placesOfDay(d, lessons)} />
<ScheduleList
lessons={lessons}
ctx={listContext(
@@ -2091,7 +2128,7 @@ export default function App() {
</small>
<b>{l.subject}</b>
<span>
{l.condition} {l.teacher} {l.room && `· ауд. ${l.room}`}
{l.condition} {l.teacher} {(l.room || l.place) && `· ${whereLabel(l, "ауд.")}`}
</span>
</div>
))}
@@ -2169,6 +2206,13 @@ export default function App() {
<small>Аудитория</small>
<b>{details.room || "Не указана"}</b>
</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="Недели занятия">

View File

@@ -35,6 +35,8 @@ interface DayLesson {
teachers: string[];
rooms: string[];
moved: boolean;
/** "Полоцк", when the document names the town (API 14). */
place?: string | null;
distance: boolean;
groups: string[];
documents: { id: string; name: string }[];
@@ -460,10 +462,10 @@ export default function Search({
{l.groups.join(", ")}
</span>
{target.kind === "teacher" ? (
l.rooms.length > 0 && (
(l.rooms.length > 0 || l.place) && (
<span>
<Icon name="location_on" size={20} />
Ауд. {l.rooms.join(", ")}
{[l.rooms.length > 0 && `Ауд. ${l.rooms.join(", ")}`, l.place].filter(Boolean).join(" · ")}
</span>
)
) : (

View File

@@ -18,6 +18,7 @@ import Groups from "@material-symbols/svg-400/rounded/groups.svg?react";
import GroupsFill from "@material-symbols/svg-400/rounded/groups-fill.svg?react";
import Info from "@material-symbols/svg-400/rounded/info.svg?react";
import KeyboardArrowDown from "@material-symbols/svg-400/rounded/keyboard_arrow_down.svg?react";
import LocationCity from "@material-symbols/svg-400/rounded/location_city.svg?react";
import LocationOn from "@material-symbols/svg-400/rounded/location_on.svg?react";
import MobileArrowDown from "@material-symbols/svg-400/rounded/mobile_arrow_down.svg?react";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg?react";
@@ -66,6 +67,7 @@ const icons = {
groups: Groups,
info: Info,
keyboard_arrow_down: KeyboardArrowDown,
location_city: LocationCity,
location_on: LocationOn,
mobile_arrow_down: MobileArrowDown,
more_horiz: MoreHoriz,

View File

@@ -610,6 +610,39 @@ a {
display: grid;
gap: 32px;
}
/* "Занятия в Полоцке": the day's town above its lessons, tonal like the update banners. */
.day-places {
display: grid;
gap: 12px;
margin-bottom: 24px;
padding: 16px 20px;
border-radius: var(--md-sys-shape-corner-large-increased);
background: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
}
.day-places p {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
}
.day-places span {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.day-places strong {
font-size: var(--md-sys-typescale-title-medium-size);
font-weight: 600;
line-height: 1.3;
}
.day-places small {
font-size: var(--md-sys-typescale-body-medium-size);
}
.week-day > .day-places {
margin-top: 16px;
}
.day-pane[data-slide] {
animation: day-enter var(--md-sys-motion-duration-medium)
var(--md-sys-motion-easing-emphasized-decelerate);

View File

@@ -1,4 +1,5 @@
import type { WeekRange } from "../shared/weeks";
import type { PlaceNote } from "../shared/places";
export interface Lesson {
id: string;
day: number;
@@ -9,6 +10,8 @@ export interface Lesson {
subject: string;
teacher: string;
room: string;
/** "Полоцк": the town, when the cell or the day's note names one (parser 14). */
place?: string;
type: string;
condition: string;
groups: string[];
@@ -24,6 +27,8 @@ export interface Schedule {
id: string;
groups: string[];
lessons: Lesson[];
/** "Занятия в Полоцке": days of a group or subgroup in another town, not lessons. */
places?: PlaceNote[];
notes: string[];
warnings: string[];
source: {