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:
62
src/App.tsx
62
src/App.tsx
@@ -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="Недели занятия">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user