Beim Zusammenstellen der Testliste fuer den naechsten iOS-Build
aufgefallen, bevor es jemandem begegnet ist: Seit den angebrochenen
Packungen liefert /expiring auch Chargen OHNE aufgedrucktes MHD - eine
geoeffnete Packung hat nur ein Verbrauchsdatum, und genau die gehoert in
diese Liste. `best_before` ist dort seither null.
Im Swift-Modell stand `let bestBefore: String`, nicht optional. Eine
einzige solche Charge haette das Entschluesseln der GANZEN Antwort
scheitern lassen: keine Ablaufliste, keine Ablaufkarte - und keine
Erinnerungen, denn der Planer laedt dieselbe Antwort.
Gegen den laufenden Server geprueft: Eine angebrochene Packung ohne MHD
kommt als {"best_before": null, "consume_by": "2026-10-05",
"warn_date": "2026-10-05", "warn_source": "consume_by"}.
Mitgenommen, weil es ohnehin an derselben Stelle sitzt: Das Modell kennt
jetzt opened_on, consume_by, warn_date und warn_source. Die Erinnerungen
haengen am Warndatum statt am MHD - eine angebrochene Packung ohne
aufgedrucktes Datum braucht sie am dringendsten und bekam bisher keine.
Liste und Dashboard-Karte schreiben "verbrauchen bis 05.10." statt
"MHD ohne MHD", denn das sind zwei verschiedene Aussagen.
Nicht kompiliert: Auf dieser Maschine gibt es kein Xcode. Geprueft sind
Klammerbalance, Anfuehrungszeichen, dass item.bestBefore nirgends mehr
nicht-optional benutzt wird, dass ExpiringItem nirgends von Hand erzeugt
wird (nur entschluesselt), und der Abgleich jedes Feldnamens gegen eine
echte Serverantwort. Der Build in Xcode bleibt die Probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
429 lines
18 KiB
Swift
429 lines
18 KiB
Swift
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 {
|
||
// Das Warndatum, nicht das MHD: Eine angebrochene Packung ohne
|
||
// aufgedrucktes Datum braucht die Erinnerung am dringendsten.
|
||
guard let mhd = parseTag(item.warnDate, 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<Int>] {
|
||
var kinder: [Int: [Int]] = [:]
|
||
for c in categories {
|
||
if let p = c.parentId { kinder[p, default: []].append(c.id) }
|
||
}
|
||
var ergebnis: [Int: Set<Int>] = [:]
|
||
for c in categories {
|
||
var alle: Set<Int> = []
|
||
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)"
|
||
}
|
||
}
|
||
}
|