diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 97ca3e7..0abfd20 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -127,11 +127,21 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. *Verliehen / Überfällig / Im Haus*. Weil alle Scan-Wege (QR über *Nachschlagen*, NFC-Tag, `vorrania://i/`) in derselben Ansicht enden, ist „scannen und verleihen" damit überall möglich, ohne einen eigenen Weg dafür. -- Noch offen: die **Rückgabe-Erinnerung**. Sie kann nur den Weg der - Ablauf-Meldungen gehen (lokale Benachrichtigung der App aus einem - Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die Datenquelle) – - echtes Push fehlt weiterhin, siehe Schritt 3. Beachten: iOS erlaubt 60 - geplante Meldungen je App, die sich Verleih und MHD dann teilen müssen. +- **Rückgabe-Erinnerung** in der App, auf dem Weg der Ablauf-Meldungen: lokale + Benachrichtigung, geplant aus `GET /loans?due_in=…`. Einstellbar je Server: + „X vorher", am Rückgabetag, und täglich weiter, solange etwas überfällig ist. + Echtes Push (APNs) fehlt weiterhin, siehe Schritt 3. +- **Eine Meldung je Meldetag und Server** statt einer je Meldungsart. Vorher gab + es an einem Tag getrennte Mitteilungen für „bald ablaufend" und „abgelaufen"; + mit dem Verleih wären es vier geworden. Der Grund ist nicht nur weniger Lärm: + iOS erlaubt 64 geplante Mitteilungen je App, und ein Platz je *Tag* statt je + Art ist die Voraussetzung dafür, dass der volle Planungshorizont hineinpasst. +- **Horizont auf 30 Tage** (vorher 60). Er ist die Zusage, wie lange die App + ungeöffnet bleiben darf, ohne dass Erinnerungen ausfallen – der + `BGAppRefreshTask` ist keine Garantie, iOS führt ihn nur aus, wenn es will. + Ein längerer Horizont hätte nur spätere Tage eingeplant und dafür die nächsten + verdrängt. *Preis:* Eine MHD-Regel mit mehr als 30 Tagen Vorlauf greift erst, + wenn das Datum in dieses Fenster rückt. ## Schritt 3 – Weitere Ideen – offen - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). diff --git a/ios/README.md b/ios/README.md index eb51fa3..4518dd9 100644 --- a/ios/README.md +++ b/ios/README.md @@ -81,6 +81,28 @@ verliehen zählt es aber nicht mehr zum Bestand, es ist ja nicht im Haus. In der Einzelstück-Liste steht der Verleih unter dem Stück (überfällig in Rot) und lässt sich über das Filter-Menü auf *Verliehen*, *Überfällig* oder *Im Haus* eingrenzen. +### Rückgabe-Erinnerung + +Unter *Benachrichtigungen* (je Server) gibt es neben den MHD-Regeln den Abschnitt +**Verliehene Einzelstücke**: „X vorher", am Rückgabetag, und täglich weiter, +solange etwas überfällig ist. Ein Verleih ohne Rückgabedatum ist nie fällig und +kommt nicht vor. + +Zwei Dinge dazu, die man wissen sollte: + +- **Ein Meldetag ist eine Meldung.** Alles, was an einem Tag auf einem Server + anliegt – ablaufende Chargen *und* fällige Rückgaben – steht in derselben + Mitteilung („2 Produkte laufen bald ab: … / Kamera (Markus) ist überfällig"). + Das ist nicht nur weniger Lärm: iOS erlaubt 64 geplante Mitteilungen je App, + und ein Platz je Tag statt einer je Meldungsart ist der Grund, warum der ganze + Monat hineinpasst. +- **Der Plan steht einen Monat.** Geplant werden die nächsten 30 Tage, damit die + Erinnerungen auch kommen, wenn die App einen Monat nicht geöffnet wird – der + Hintergrundlauf (`BGAppRefreshTask`) ist keine Garantie, iOS führt ihn nur aus, + wenn es will. Weiter entfernte Tage plant die App beim nächsten Start. Eine + MHD-Regel mit mehr als 30 Tagen Vorlauf greift deshalb erst, wenn das Datum in + dieses Fenster rückt. + ## NFC-Tags für Kleidung Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag diff --git a/ios/Sources/NotificationScheduler.swift b/ios/Sources/NotificationScheduler.swift index b7b911e..5908076 100644 --- a/ios/Sources/NotificationScheduler.swift +++ b/ios/Sources/NotificationScheduler.swift @@ -7,7 +7,23 @@ import UserNotifications /// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen /// laesst. struct PlannedNotification: Equatable { - enum Kind { case upcoming, expired } + 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 @@ -111,10 +127,10 @@ enum ExpiryPlanner { profileID: profileID, serverName: serverName, day: tag, kind: .expired, products: namen)) } - // Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst). + // Nach Datum, dann nach Dringlichkeit (abgelaufen vor bald-ablaufend). return result.sorted { $0.day != $1.day ? $0.day < $1.day - : ($0.kind == .expired && $1.kind == .upcoming) + : $0.kind.dringlichkeit < $1.kind.dringlichkeit } } @@ -146,6 +162,100 @@ enum ExpiryPlanner { } } +/// 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. @@ -153,9 +263,26 @@ enum ExpiryPlanner { final class NotificationScheduler { static let shared = NotificationScheduler() - /// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS - /// erlaubt 64 ausstehende je App, ueber alle Server zusammen. - static let horizonDays = 60 + /// 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 @@ -207,20 +334,71 @@ final class NotificationScheduler { 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) } - // Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst. - geplant.sort { $0.day != $1.day ? $0.day < $1.day - : ($0.kind == .expired && $1.kind == .upcoming) } + // 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 meldung in geplant.prefix(Self.maxScheduled) { - center.add(request(for: meldung), withCompletionHandler: nil) + 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 func request(for meldung: PlannedNotification) -> UNNotificationRequest { + 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: ", ") @@ -228,33 +406,21 @@ final class NotificationScheduler { switch meldung.kind { case .upcoming: - inhalt.title = meldung.serverName - inhalt.body = anzahl == 1 + return anzahl == 1 ? "\(liste) läuft bald ab." : "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)" case .expired: - inhalt.title = "ACHTUNG – \(meldung.serverName)" - inhalt.body = anzahl == 1 + 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)" } - inhalt.sound = .default - inhalt.userInfo = ["profileID": meldung.profileID.uuidString] - - let config = NotificationStore.config(for: meldung.profileID) - var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day) - wann.hour = config.hour - wann.minute = config.minute - - // Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene - // Kennungen, sonst ueberschreibt die zweite die erste. - let art = meldung.kind == .expired ? "expired" : "upcoming" - let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)" - let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)" - - return UNNotificationRequest( - identifier: id, - content: inhalt, - trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false)) } } diff --git a/ios/Sources/NotificationSettings.swift b/ios/Sources/NotificationSettings.swift index 2695cd9..0bc90b2 100644 --- a/ios/Sources/NotificationSettings.swift +++ b/ios/Sources/NotificationSettings.swift @@ -47,9 +47,52 @@ struct ServerNotificationConfig: Codable, Equatable { var lastWarningOnExpiry: Bool = true var rules: [NotificationRule] = [] - /// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen. + // MARK: Rueckgabe-Erinnerung fuer verliehene Einzelstuecke + // + // Bewusst schlichter als die MHD-Regeln: Ein Verleih hat genau ein Datum, + // und nach Kategorien zu unterscheiden waere hier erfunden statt gebraucht. + // Ein Verleih ohne Rueckgabedatum ist nie faellig und kommt nie vor. + + var loansEnabled: Bool = false + /// „X vorher" – ein Hinweis, bevor die Rueckgabe ansteht. + var loanLeadAmount: Int = 1 + var loanLeadUnit: LeadUnit = .days + /// Am Rueckgabetag selbst noch einmal. + var loanOnDueDate: Bool = true + /// Taeglich weiter, solange das Stueck ueberfaellig ist. + var loanOverdue: Bool = true + + /// Vorlauf der Rueckgabe-Erinnerung in Tagen. + var loanLeadDays: Int { loanLeadUnit == .weeks ? loanLeadAmount * 7 : loanLeadAmount } + + /// Alte gespeicherte Konfigurationen kennen die Verleih-Schluessel nicht – + /// deshalb alle mit decodeIfPresent und Standardwert, sonst waere eine + /// bestehende Einstellung nach dem Update nicht mehr lesbar. + enum CodingKeys: String, CodingKey { + case enabled, hour, minute, lastWarningOnExpiry, rules + case loansEnabled, loanLeadAmount, loanLeadUnit, loanOnDueDate, loanOverdue + } + + init() {} + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + hour = try c.decodeIfPresent(Int.self, forKey: .hour) ?? 9 + minute = try c.decodeIfPresent(Int.self, forKey: .minute) ?? 0 + lastWarningOnExpiry = try c.decodeIfPresent(Bool.self, forKey: .lastWarningOnExpiry) ?? true + rules = try c.decodeIfPresent([NotificationRule].self, forKey: .rules) ?? [] + loansEnabled = try c.decodeIfPresent(Bool.self, forKey: .loansEnabled) ?? false + loanLeadAmount = try c.decodeIfPresent(Int.self, forKey: .loanLeadAmount) ?? 1 + loanLeadUnit = try c.decodeIfPresent(LeadUnit.self, forKey: .loanLeadUnit) ?? .days + loanOnDueDate = try c.decodeIfPresent(Bool.self, forKey: .loanOnDueDate) ?? true + loanOverdue = try c.decodeIfPresent(Bool.self, forKey: .loanOverdue) ?? true + } + + /// Ohne aktive Regel, ohne letzte Warnung und ohne Verleih-Erinnerung gibt + /// es nichts zu planen – dann muss der Server auch nicht abgefragt werden. var hasSomethingToSchedule: Bool { - enabled && (!rules.isEmpty || lastWarningOnExpiry) + enabled && (!rules.isEmpty || lastWarningOnExpiry || loansEnabled) } } diff --git a/ios/Sources/NotificationSettingsView.swift b/ios/Sources/NotificationSettingsView.swift index a861354..5983fec 100644 --- a/ios/Sources/NotificationSettingsView.swift +++ b/ios/Sources/NotificationSettingsView.swift @@ -38,7 +38,7 @@ struct NotificationSettingsView: View { set: { an in Task { await setzeAktiv(an) } } )) } footer: { - Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.") + Text("Erinnert vor dem Ablauf von Produkten und an fällige Rückgaben auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.") } if berechtigungFehlt { @@ -91,6 +91,39 @@ struct NotificationSettingsView: View { } footer: { Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.") } + + Section { + Toggle("Rückgabe-Erinnerung", isOn: Binding( + get: { config.loansEnabled }, + set: { config.loansEnabled = $0; speichern() } + )) + if config.loansEnabled { + Stepper("\(config.loanLeadAmount) \(config.loanLeadUnit.label) vorher", + value: Binding( + get: { config.loanLeadAmount }, + set: { config.loanLeadAmount = $0; speichern() } + ), in: 1...99) + Picker("Einheit", selection: Binding( + get: { config.loanLeadUnit }, + set: { config.loanLeadUnit = $0; speichern() } + )) { + ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + Toggle("Am Rückgabetag", isOn: Binding( + get: { config.loanOnDueDate }, + set: { config.loanOnDueDate = $0; speichern() } + )) + Toggle("Täglich, solange überfällig", isOn: Binding( + get: { config.loanOverdue }, + set: { config.loanOverdue = $0; speichern() } + )) + } + } header: { + Text("Verliehene Einzelstücke") + } footer: { + Text("Erinnert an Stücke, die zurückkommen sollen. Ein Verleih ohne Rückgabedatum ist nie fällig und kommt hier nicht vor – das Datum ist beim Verleihen optional.") + } } } .navigationTitle("Benachrichtigungen") diff --git a/ios/Sources/ServerFetch.swift b/ios/Sources/ServerFetch.swift index e605a62..90f6bd8 100644 --- a/ios/Sources/ServerFetch.swift +++ b/ios/Sources/ServerFetch.swift @@ -8,11 +8,13 @@ import Foundation /// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`. enum ServerFetch { /// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont, - /// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum. + /// die Produkte (fuer die Kategoriezuordnung), der Kategorie-Baum und die + /// faelligen Verleihe. struct Snapshot { let expiring: [ExpiringItem] let products: [Product] let categories: [CategoryItem] + let loans: [Loan] } static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot { @@ -20,12 +22,18 @@ enum ServerFetch { "/expiring?days=\(horizonDays)", baseURL: baseURL, token: token) async let products: [Product] = get("/products", baseURL: baseURL, token: token) async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token) + // due_in filtert serverseitig auf den Horizont; ueberfaellige sind dabei + // immer enthalten, und Verleihe ohne Rueckgabedatum nie - sie sind nie + // faellig, es gibt also nichts zu erinnern. + async let loans: [Loan] = get( + "/loans?due_in=\(horizonDays)", baseURL: baseURL, token: token) // Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die // Kategorien nur, weil die Produktliste langsam war). return await Snapshot( expiring: (try? await expiring) ?? [], products: (try? await products) ?? [], - categories: (try? await categories) ?? [] + categories: (try? await categories) ?? [], + loans: (try? await loans) ?? [] ) }