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

@@ -38,8 +38,11 @@ struct LessonCard: View {
}
FlowLayout(spacing: 14) {
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty {
Meta(symbol: "mappin.and.ellipse", text: lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : ""))
else if let room = withPlace(
show.rooms && !lesson.rooms.isEmpty ? lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "") : nil,
lesson.place
) {
Meta(symbol: "mappin.and.ellipse", text: room)
}
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
@@ -123,7 +126,7 @@ struct CompactLesson: View {
if let session = lesson.session { parts.insert(session.label, at: 0) }
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
if lesson.distance { parts.append("дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { parts.append("ауд. " + lesson.rooms.joined(separator: ", ")) }
else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) { parts.append(room) }
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
return parts.joined(separator: " · ")
}
@@ -163,6 +166,9 @@ struct LessonDetails: View {
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
)
if let place = lesson.place {
DetailRow(symbol: "building.2", label: "Город", value: place, extra: "Так указано в Word")
}
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
}
.card()