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

@@ -51,11 +51,39 @@ struct Day: Codable, Hashable {
var weekday: Int
var week: Week?
var lessons: [Lesson] = []
var places: [DayPlace] = []
}
struct WeeklyDay: Codable, Hashable {
var day: Int
var lessons: [Lesson] = []
var places: [DayPlace] = []
}
/**
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
* when `subgroup` is set) are in another town (server/timetable.js, shared/places.js).
*/
struct DayPlace: Codable, Hashable {
var place: String
var text: String
var subgroup: String?
}
extension Array where Element == DayPlace {
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
func forSubgroup(_ subgroup: String?) -> [DayPlace] {
var seen = Set<DayPlace>()
return filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
.map { subgroup == nil ? $0 : DayPlace(place: $0.place, text: $0.text, subgroup: nil) }
.filter { seen.insert($0).inserted }
}
}
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
func withPlace(_ room: String?, _ place: String?) -> String? {
let parts = [room, place].compactMap { $0 }.filter { !$0.isEmpty }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
struct Lesson: Codable, Hashable {
@@ -68,6 +96,8 @@ struct Lesson: Codable, Hashable {
var teachers: [String] = []
var rooms: [String] = []
var roomMoved: Bool = false
/** The town, when the cell or the day's note names one: "Полоцк". */
var place: String?
var distance: Bool = false
var condition: String = ""
var variant: String = "all"
@@ -139,6 +169,7 @@ struct DirectoryLesson: Codable, Hashable {
var teachers: [String] = []
var rooms: [String] = []
var moved: Bool = false
var place: String?
var distance: Bool = false
var groups: [String] = []
var condition: String = ""
@@ -266,6 +297,7 @@ extension Day {
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
week = try c.decodeIfPresent(Week.self, forKey: .week)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
}
}
@@ -274,6 +306,16 @@ extension WeeklyDay {
let c = try decoder.container(keyedBy: CodingKeys.self)
day = try c.decode(Int.self, forKey: .day)
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
}
}
extension DayPlace {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
place = try c.decodeIfPresent(String.self, forKey: .place) ?? ""
text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
}
}
@@ -289,6 +331,7 @@ extension Lesson {
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
place = try c.decodeIfPresent(String.self, forKey: .place)
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
@@ -375,6 +418,7 @@ extension DirectoryLesson {
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
place = try c.decodeIfPresent(String.self, forKey: .place)
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""