- 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>
110 lines
5.1 KiB
Swift
110 lines
5.1 KiB
Swift
import Foundation
|
||
|
||
/// One time slot of a day: parallel lessons of subgroups share it.
|
||
struct Slot: Hashable, Identifiable {
|
||
var date: CivilDate
|
||
var start: ClockTime
|
||
var end: ClockTime
|
||
var lessons: [Lesson]
|
||
var id: String { date.iso + "|" + start.description }
|
||
/// "13:40 → 15:05"
|
||
var timeLabel: String { "\(start) → \(end)" }
|
||
}
|
||
|
||
func subjectKey(_ subject: String) -> String { subject.trimmed.lowercased() }
|
||
|
||
/// End of a lesson: its own end, else the bell table's, else 80 minutes after the start.
|
||
func lessonEnd(_ lesson: Lesson, _ t: Timetable) -> ClockTime {
|
||
if let end = lesson.end.flatMap(ClockTime.init) { return end }
|
||
if let bell = t.bells.first(where: { $0.start == lesson.start }), let end = ClockTime(bell.end) { return end }
|
||
return (ClockTime(lesson.start) ?? ClockTime(minutes: 0)).adding(minutes: 80)
|
||
}
|
||
|
||
extension Timetable {
|
||
func day(_ date: CivilDate) -> Day? { days.first { $0.date == date.iso } }
|
||
|
||
func week(of date: CivilDate) -> Week? { day(date.monday)?.week ?? day(date)?.week }
|
||
|
||
var dates: [CivilDate] { days.compactMap { CivilDate($0.date) } }
|
||
|
||
/// Lessons of a date after the student's subgroup filter and the lessons they hid.
|
||
func lessons(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Lesson] {
|
||
(day(date)?.lessons ?? [])
|
||
.filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
|
||
.filter { !hidden.contains($0.hideKey) }
|
||
}
|
||
|
||
/// "Занятия в Полоцке" on the date for the student's subgroup, only on a day they have lessons.
|
||
func places(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [DayPlace] {
|
||
guard let day = day(date), !day.places.isEmpty,
|
||
!lessons(on: date, subgroup: subgroup, hidden: hidden).isEmpty else { return [] }
|
||
return day.places.forSubgroup(subgroup)
|
||
}
|
||
|
||
func slots(on date: CivilDate, subgroup: String?, hidden: Set<String>) -> [Slot] {
|
||
let lessons = lessons(on: date, subgroup: subgroup, hidden: hidden)
|
||
var order: [String] = []
|
||
var groups: [String: [Lesson]] = [:]
|
||
for l in lessons {
|
||
if groups[l.start] == nil { order.append(l.start) }
|
||
groups[l.start, default: []].append(l)
|
||
}
|
||
return order.compactMap { start in
|
||
guard let begin = ClockTime(start), let items = groups[start] else { return nil }
|
||
return Slot(date: date, start: begin, end: lessonEnd(items[0], self), lessons: items)
|
||
}
|
||
.sorted { $0.start < $1.start }
|
||
}
|
||
|
||
/// Palette slot of every subject: alphabetical, so a subject keeps its colour across refreshes.
|
||
var subjectSlots: [String: Int] {
|
||
let subjects = Set((days.flatMap(\.lessons) + weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).sorted()
|
||
return Dictionary(uniqueKeysWithValues: subjects.enumerated().map { ($1, $0) })
|
||
}
|
||
}
|
||
|
||
private let shortWords: Set<String> = ["и", "в", "во", "на", "по", "с", "со", "к", "о", "об", "для", "из", "от"]
|
||
|
||
/// "Экономика предприятия в машиностроении" → "ЭПМ"; short names stay as they are.
|
||
func abbreviate(_ subject: String) -> String {
|
||
let name = (subject.split(separator: ":").first.map(String.init) ?? subject).trimmed
|
||
if name.count <= 10 { return name }
|
||
let letters = name.split(whereSeparator: { " -(),".contains($0) })
|
||
.filter { !$0.isEmpty && !shortWords.contains($0.lowercased()) && ($0.first?.isLetter ?? false) }
|
||
.map { String($0.first!).uppercased() }
|
||
.joined()
|
||
return letters.count >= 2 ? String(letters.prefix(4)) : String(name.prefix(8))
|
||
}
|
||
|
||
/// Faculty names as the student knows them.
|
||
private let faculties = [
|
||
"Финансово-экономический факультет": "ФЭФ",
|
||
"Факультет информационных технологий": "ФИТ",
|
||
"Факультет компьютерных наук и электроники": "ФКНЭ",
|
||
"Гуманитарный факультет": "ГФ",
|
||
"Инженерно-строительный факультет": "ИСФ",
|
||
"Механико-технологический факультет": "МТФ",
|
||
"Юридический факультет": "ЮФ",
|
||
]
|
||
|
||
func facultyShort(_ name: String) -> String? { name.isEmpty ? nil : (faculties[name] ?? name) }
|
||
|
||
/// What a widget or reminder shows: today's slots, or the next study day's once today is over.
|
||
struct Upcoming {
|
||
var date: CivilDate
|
||
var slots: [Slot]
|
||
}
|
||
|
||
func upcoming(_ t: Timetable, settings: UserSettings, now: Date = Date()) -> Upcoming? {
|
||
let today = CivilDate.today(now)
|
||
let clock = ClockTime.now(now)
|
||
let todays = t.slots(on: today, subgroup: settings.subgroup, hidden: settings.hidden).filter { $0.end > clock }
|
||
if !todays.isEmpty { return Upcoming(date: today, slots: todays) }
|
||
for step in 1...7 {
|
||
let d = today.adding(days: step)
|
||
let slots = t.slots(on: d, subgroup: settings.subgroup, hidden: settings.hidden)
|
||
if !slots.isEmpty { return Upcoming(date: d, slots: slots) }
|
||
}
|
||
return nil
|
||
}
|