- 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>
59 lines
3.2 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|