import Foundation import UserNotifications // MARK: - Reine Planung (netzfrei, testbar) /// Eine fertig berechnete Meldung: an welchem Tag, welcher Art, mit welchen /// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen /// laesst. struct PlannedNotification: Equatable { enum Kind { case upcoming // Charge laeuft bald ab case expired // Charge laeuft heute ab case loanDue // verliehenes Stueck soll zurueck case loanOverdue // Rueckgabe ist ueberfaellig /// Was am selben Tag zuerst gemeldet wird. Versaeumtes vor Anstehendem: /// „ist ueberfaellig" ist dringender als „wird faellig". var dringlichkeit: Int { switch self { case .expired: return 0 case .loanOverdue: return 1 case .upcoming: return 2 case .loanDue: return 3 } } } let profileID: UUID let serverName: String /// Tagesbeginn des Meldetags. let day: Date let kind: Kind /// Produktnamen fuer diesen Tag, ohne Dopplung, in stabiler Reihenfolge. let products: [String] } /// Rechnet aus den Ablaufdaten eines Servers die Meldungen aus. Alle /// Entscheidungen des Nutzers stecken hier - deshalb strikt getrennt vom /// Netz- und iOS-Teil. enum ExpiryPlanner { static func plan( profileID: UUID, serverName: String, config: ServerNotificationConfig, snapshot: ServerFetch.Snapshot, today: Date, horizonDays: Int, calendar: Calendar = .current ) -> [PlannedNotification] { guard config.hasSomethingToSchedule else { return [] } let heute = calendar.startOfDay(for: today) guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else { return [] } // Produkt -> Kategorie, und der Kategorie-Baum fuer die Nachfahren. let kategorieVon = Dictionary( snapshot.products.map { ($0.id, $0.categoryId) }, uniquingKeysWith: { first, _ in first } ) let nachfahren = descendantMap(snapshot.categories) let allgemeine = config.rules.filter { $0.categoryId == nil } let kategorieRegeln = config.rules.filter { $0.categoryId != nil } // Tag -> Produktnamen, getrennt nach bald-ablaufend und abgelaufen. var bald: [Date: [String]] = [:] var abgelaufen: [Date: [String]] = [:] func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) { var liste = eimer[tag] ?? [] if !liste.contains(name) { liste.append(name) } eimer[tag] = liste } for item in snapshot.expiring { guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue } let mhdTag = calendar.startOfDay(for: mhd) // Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine: // greift eine Kategorie-Regel (Produktkategorie im Teilbaum), zaehlen // nur diese, sonst die allgemeinen. let produktKategorie = kategorieVon[item.productId] ?? nil let passendeKategorie = kategorieRegeln.filter { regel in guard let ziel = regel.categoryId, let pk = produktKategorie else { return false } return pk == ziel || (nachfahren[ziel]?.contains(pk) ?? false) } let regeln = passendeKategorie.isEmpty ? allgemeine : passendeKategorie for regel in regeln { guard let fensterStart = calendar.date( byAdding: .day, value: -regel.leadDays, to: mhdTag) else { continue } // Erinnerungen liegen vor dem Ablauftag (der Ablauftag gehoert der // letzten Warnung). Einmalig: nur der Starttag; wiederkehrend: jeder // Tag im Fenster bis zum Vortag des Ablaufs. let letzterTag = regel.recurring ? (calendar.date(byAdding: .day, value: -1, to: mhdTag) ?? fensterStart) : fensterStart var tag = fensterStart while tag <= letzterTag { if tag >= heute && tag <= horizont && tag < mhdTag { ergaenze(&bald, tag, item.productName) } guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break } tag = naechster } } // Letzte Warnung: am Ablauftag selbst, fuer alle Produkte, danach nie // wieder (bereits abgelaufene Chargen lassen wir bewusst ruhen). if config.lastWarningOnExpiry && mhdTag >= heute && mhdTag <= horizont { ergaenze(&abgelaufen, mhdTag, item.productName) } } var result: [PlannedNotification] = [] for (tag, namen) in bald { result.append(PlannedNotification( profileID: profileID, serverName: serverName, day: tag, kind: .upcoming, products: namen)) } for (tag, namen) in abgelaufen { result.append(PlannedNotification( profileID: profileID, serverName: serverName, day: tag, kind: .expired, products: namen)) } // Nach Datum, dann nach Dringlichkeit (abgelaufen vor bald-ablaufend). return result.sorted { $0.day != $1.day ? $0.day < $1.day : $0.kind.dringlichkeit < $1.kind.dringlichkeit } } /// Kategorie-Id -> alle darunterliegenden Kategorie-Ids (ohne die eigene). static func descendantMap(_ categories: [CategoryItem]) -> [Int: Set] { var kinder: [Int: [Int]] = [:] for c in categories { if let p = c.parentId { kinder[p, default: []].append(c.id) } } var ergebnis: [Int: Set] = [:] for c in categories { var alle: Set = [] var stapel = kinder[c.id] ?? [] while let n = stapel.popLast() { if alle.insert(n).inserted { stapel.append(contentsOf: kinder[n] ?? []) } } ergebnis[c.id] = alle } return ergebnis } /// „yyyy-MM-dd" -> Date (Tagesbeginn). Monatsangaben liefert der Server bereits /// als Monatsletzten, daher reicht das Tagesdatum. static func parseTag(_ raw: String, calendar: Calendar) -> Date? { let teile = raw.prefix(10).split(separator: "-") guard teile.count == 3, let y = Int(teile[0]), let m = Int(teile[1]), let d = Int(teile[2]) else { return nil } return calendar.date(from: DateComponents(year: y, month: m, day: d)) } } /// Rechnet aus den faelligen Verleihen die Rueckgabe-Erinnerungen aus. /// /// Gleiche Bauweise wie der `ExpiryPlanner` - netzfrei und ohne iOS-Typen, /// damit sich die Entscheidungen ohne Geraet nachrechnen lassen - und gleiche /// Buendelung: **ein Meldetag ist eine Meldung**, alle an diesem Tag faelligen /// Stuecke stehen darin. Ein Verleih kostet also keinen eigenen Platz in den 60, /// die iOS je App zulaesst; ein Tag kostet einen. enum LoanPlanner { static func plan( profileID: UUID, serverName: String, config: ServerNotificationConfig, loans: [Loan], today: Date, horizonDays: Int, calendar: Calendar = .current ) -> [PlannedNotification] { guard config.enabled, config.loansEnabled else { return [] } let heute = calendar.startOfDay(for: today) guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else { return [] } // Tag -> Bezeichnungen der betroffenen Stuecke, getrennt nach faellig // und ueberfaellig. var faellig: [Date: [String]] = [:] var ueberfaellig: [Date: [String]] = [:] func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) { var liste = eimer[tag] ?? [] if !liste.contains(name) { liste.append(name) } eimer[tag] = liste } for loan in loans { // Ohne Rueckgabedatum ist nichts faellig, Zurueckgegebenes ohnehin nicht. guard loan.istOffen, let rohDatum = loan.dueOn, let due = ExpiryPlanner.parseTag(rohDatum, calendar: calendar) else { continue } let dueTag = calendar.startOfDay(for: due) let name = bezeichnung(loan) // 1. Vorlauf: „X vorher" - nur wenn dieser Tag noch bevorsteht. if let vorher = calendar.date(byAdding: .day, value: -config.loanLeadDays, to: dueTag), vorher >= heute, vorher <= horizont, vorher < dueTag { ergaenze(&faellig, vorher, name) } // 2. Am Rueckgabetag selbst. if config.loanOnDueDate, dueTag >= heute, dueTag <= horizont { ergaenze(&faellig, dueTag, name) } // 3. Danach taeglich weiter, solange das Stueck draussen bleibt - // durch den ganzen Horizont. Das kostet nicht pro Verleih einen // Platz, sondern pro Tag einen: Alle an einem Tag ueberfaelligen // Stuecke stehen in derselben Meldung. So kommt die Erinnerung // auch, wenn die App wochenlang nicht geoeffnet wird. if config.loanOverdue { let start = max(calendar.date(byAdding: .day, value: 1, to: dueTag) ?? dueTag, heute) var tag = start while tag <= horizont { ergaenze(&ueberfaellig, tag, name) guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break } tag = naechster } } } var result: [PlannedNotification] = [] for (tag, namen) in faellig { result.append(PlannedNotification( profileID: profileID, serverName: serverName, day: tag, kind: .loanDue, products: namen)) } for (tag, namen) in ueberfaellig { result.append(PlannedNotification( profileID: profileID, serverName: serverName, day: tag, kind: .loanOverdue, products: namen)) } return result.sorted { $0.day != $1.day ? $0.day < $1.day : $0.kind.dringlichkeit < $1.kind.dringlichkeit } } /// „Kamera (Markus)" – der Entleiher gehoert dazu, sonst weiss man beim /// Lesen der Meldung nicht, wen man fragen muss. static func bezeichnung(_ loan: Loan) -> String { let stueck = loan.productName ?? loan.itemUid ?? "Einzelstück" return "\(stueck) (\(loan.borrower))" } } // MARK: - iOS-Planung /// Plant die berechneten Meldungen als lokale Benachrichtigungen ein. @MainActor final class NotificationScheduler { static let shared = NotificationScheduler() /// Wie weit im Voraus geplant wird - und wie viele Meldungen hoechstens. /// /// iOS erlaubt 64 ausstehende Meldungen je App, ueber alle Server zusammen. /// Ein Platz ist dabei **ein Meldetag je Server**, nicht ein Produkt und /// nicht ein Verleih: Alles, was an einem Tag zu melden ist, steht in /// derselben Meldung. /// /// Der Horizont ist bewusst ein Monat. Er ist die Zusage, wie lange die App /// ungeoeffnet bleiben darf, ohne dass Erinnerungen ausfallen - denn der /// Hintergrundlauf (`BGAppRefreshTask`) ist keine Garantie, iOS fuehrt ihn /// nur aus, wenn es will. 30 Tage × mehrere Server passen in die 64; ein /// laengerer Horizont wuerde nur die spaeteren Tage einplanen und dafuer die /// naechsten verdraengen. Was weiter als 30 Tage weg ist, wird beim /// naechsten Start (oder Hintergrundlauf) geplant. /// /// Ein Server passt damit mit dem vollen Monat hinein (31 Meldetage, heute /// mitgezaehlt). Wer mehrere Server eingerichtet hat, bekommt die naechsten /// 60 Meldetage - gekappt wird immer am fernen Ende, nie an den naechsten /// Tagen, und bis dahin ist laengst neu geplant. static let horizonDays = 30 static let maxScheduled = 60 private var laeuftGerade = false /// Fragt die Erlaubnis, falls noch nicht geschehen. Gibt zurueck, ob erlaubt. @discardableResult func requestAuthorization() async -> Bool { let center = UNUserNotificationCenter.current() let status = await center.notificationSettings().authorizationStatus switch status { case .authorized, .provisional, .ephemeral: return true case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false default: return false } } /// Rechnet alle Server neu durch und ersetzt den geplanten Bestand. func rescheduleAll() async { guard !laeuftGerade else { return } laeuftGerade = true defer { laeuftGerade = false } let profile = Session.shared.profiles let relevant = profile.filter { NotificationStore.config(for: $0.id).hasSomethingToSchedule } guard !relevant.isEmpty else { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() return } guard await requestAuthorization() else { return } var geplant: [PlannedNotification] = [] let heute = Date() for prof in relevant { guard let url = prof.url, let token = Keychain.read(account: Session.keychainAccount(for: prof.id)) else { continue } let config = NotificationStore.config(for: prof.id) // snapshot ist widerstandsfaehig: unerreichbare Server (Heimnetz von // unterwegs) liefern leere Listen -> es wird nichts geplant, die // anderen Server trotzdem. let snapshot = await ServerFetch.snapshot( baseURL: url, token: token, horizonDays: Self.horizonDays) geplant += ExpiryPlanner.plan( profileID: prof.id, serverName: prof.name, config: config, snapshot: snapshot, today: heute, horizonDays: Self.horizonDays) geplant += LoanPlanner.plan( profileID: prof.id, serverName: prof.name, config: config, loans: snapshot.loans, today: heute, horizonDays: Self.horizonDays) } // Alles, was an einem Tag auf demselben Server zu melden ist, wird EINE // Meldung. Das ist nicht nur weniger Laerm: Ein Platz je Tag statt einer // je Meldungsart ist der Grund, warum der ganze Monat in die 64 passt - // sonst koennten vier Arten den Monat auf eine Woche zusammenschrumpfen. var gruppen: [Gruppenschluessel: [PlannedNotification]] = [:] for meldung in geplant { gruppen[Gruppenschluessel(profileID: meldung.profileID, day: meldung.day), default: []].append(meldung) } // Die naechsten Tage zuerst - gekappt wird also am fernen Ende, und das // wird bis dahin ohnehin neu geplant. let sortiert = gruppen.sorted { $0.key.day < $1.key.day } let center = UNUserNotificationCenter.current() center.removeAllPendingNotificationRequests() for (schluessel, teile) in sortiert.prefix(Self.maxScheduled) { guard let anfrage = request(profileID: schluessel.profileID, day: schluessel.day, teile: teile) else { continue } center.add(anfrage, withCompletionHandler: nil) } } private struct Gruppenschluessel: Hashable { let profileID: UUID let day: Date } /// Eine Meldung fuer einen Tag und Server, zusammengesetzt aus allem, was /// an diesem Tag anliegt: ablaufende Chargen und faellige Rueckgaben. private func request(profileID: UUID, day: Date, teile: [PlannedNotification]) -> UNNotificationRequest? { guard let erstes = teile.first else { return nil } // Dringendes zuerst - es bestimmt auch den Titel. let sortiert = teile.sorted { $0.kind.dringlichkeit < $1.kind.dringlichkeit } let serverName = erstes.serverName let dringend = sortiert.contains { $0.kind == .expired || $0.kind == .loanOverdue } let inhalt = UNMutableNotificationContent() inhalt.title = dringend ? "ACHTUNG – \(serverName)" : serverName inhalt.body = sortiert.map(Self.satz).joined(separator: "\n") inhalt.sound = .default inhalt.userInfo = ["profileID": profileID.uuidString] let config = NotificationStore.config(for: profileID) var wann = Calendar.current.dateComponents([.year, .month, .day], from: day) wann.hour = config.hour wann.minute = config.minute // Ein Tag je Server ist eine Meldung – die Kennung braucht die Art // deshalb nicht mehr (und kann sich so auch nicht selbst ueberschreiben). let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)" let id = "vorrania.\(profileID.uuidString).\(tag)" return UNNotificationRequest( identifier: id, content: inhalt, trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false)) } /// Ein Satz je Meldungsart, mit hoechstens vier Namen – danach „u. a.". private static func satz(_ meldung: PlannedNotification) -> String { let namen = meldung.products let anzahl = namen.count let liste = namen.prefix(4).joined(separator: ", ") let mehr = anzahl > 4 ? " u. a." : "" switch meldung.kind { case .upcoming: return anzahl == 1 ? "\(liste) läuft bald ab." : "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)" case .expired: return anzahl == 1 ? "\(liste) ist heute abgelaufen." : "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)" case .loanDue: return anzahl == 1 ? "\(liste) sollte zurückkommen." : "\(anzahl) verliehene Stücke sollten zurückkommen: \(liste)\(mehr)" case .loanOverdue: return anzahl == 1 ? "\(liste) ist überfällig." : "\(anzahl) verliehene Stücke sind überfällig: \(liste)\(mehr)" } } }