Files
psu-schedule/ios/Shared/Schedule.swift
Zakhar Timoshenko 10c85e3bb9 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>
2026-10-21 01:26:54 +03:00

110 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}