Files
psu-schedule/ios/PSUSchedule/Reminders.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

59 lines
3.2 KiB
Swift

import Foundation
import UserNotifications
/**
* Local reminders before each lesson of the coming week, from the saved timetable: they work
* offline, no push server is involved. iOS keeps at most 64 pending requests per app.
*/
enum Reminders {
private static let prefix = "lesson-"
private static let limit = 48
/** Asks for permission; false when the student declined. */
static func authorize() async -> Bool {
(try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])) ?? false
}
/** Replaces every scheduled reminder with the ones the timetable needs now. */
static func reschedule(timetable: Timetable?, settings: UserSettings) {
let center = UNUserNotificationCenter.current()
Task {
let pending = await center.pendingNotificationRequests().map(\.identifier).filter { $0.hasPrefix(prefix) }
center.removePendingNotificationRequests(withIdentifiers: pending)
guard settings.reminders, let t = timetable else { return }
let now = Date()
// A week ahead, so a Friday evening already holds Monday's reminders.
let horizon = now.addingTimeInterval(7 * 24 * 3600)
var count = 0
for day in t.days {
guard let date = CivilDate(day.date), count < limit else { continue }
// One reminder per start time: parallel subgroups share the notification.
for slot in t.slots(on: date, subgroup: settings.subgroup, hidden: settings.hidden) {
let begins = date.at(slot.start)
let at = begins.addingTimeInterval(-Double(settings.reminderMinutes) * 60)
guard at > now, begins < horizon, count < limit else { continue }
let content = UNMutableNotificationContent()
content.title = slot.lessons.map(\.subject).joined(separator: " / ")
let first = slot.lessons[0]
let rooms = Array(Set(slot.lessons.flatMap(\.rooms))).sorted().joined(separator: ", ")
let place = withPlace(rooms.isEmpty ? nil : "ауд. " + rooms, slot.lessons.compactMap(\.place).first)
var text = slot.start.description
if let pair = first.pair { text += " · \(pair) пара" }
if let place { text += " · \(place)" }
if slot.lessons.contains(where: \.uncertain) { text += " · сверьте недели" }
content.body = text
content.sound = .default
content.interruptionLevel = .timeSensitive
let parts = minskCalendar.dateComponents(in: minsk, from: at)
let trigger = UNCalendarNotificationTrigger(
dateMatching: DateComponents(timeZone: minsk, year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute),
repeats: false
)
try? await center.add(UNNotificationRequest(identifier: prefix + slot.id, content: content, trigger: trigger))
count += 1
}
}
}
}
}