feat: Zweite Stufe der iOS-App - Uebersicht, Budget, Buchungen, Vertraege

Die App kann jetzt lesen, wofuer man sie oeffnet. Vier neue Reiter:

- Uebersicht: Einnahmen/Ausgaben des Monats, Budget-Fortschritt je Gruppe,
  Kontostaende und die letzten Buchungen. Feste Auswahl statt des
  verschiebbaren Rasters der Weboberflaeche - auf Telefonbreite waere das
  Arbeit ohne Gewinn. Vier Endpunkte nebeneinander in einem Abruf, damit der
  Offline-Stand nicht aus vier verschiedenen Momenten stammt.
- Budget: Soll gegen Ist je Gruppe und Kategorie, blaetterbar ueber prev/next
  aus der Antwort. Rot nur bei echter Ueberschreitung - wer mehr zuruecklegt
  als geplant, hat nichts ueberzogen. Vertraege ohne Kategorie und fehlende
  Wechselkurse stehen als Hinweis darunter, statt die Summen still zu
  verfaelschen.
- Buchungen: Suche und Filter laufen im Server (Backend-Ergaenzung B), nicht
  im Telefon. Gesucht wird beim Absenden, nicht je Tastendruck.
- Vertraege: Monats- und Jahressumme, Rhythmus, naechste Faelligkeit.

Zwei Dinge, die das Backend bewusst dem Client ueberlaesst:

Ein Uebertrag kommt in zwei Gestalten - am Stueck (eine Buchung mit
to_account_id) oder als zwei verknuepfte Buchungen, wie die Bank sie liefert.
Beide Beine als zwei Zeilen zu zeigen hiesse, dasselbe Geld zweimal zu zeigen.
Buchungszeile.zeilen fasst sie zusammen, aber nur wenn beide in der Liste
stehen - sonst liesse ein Kontofilter Zeilen verschwinden, die sehr wohl zu
diesem Konto gehoeren. Die Richtung kommt aus der Verknuepfung, nicht aus dem
Vorzeichen: Manche Banken liefern beide Beine positiv.

Kategorien, Schlagwoerter und Konten liegen als Nachschlagewerk im AppZustand,
einmal geladen. Je Ansicht neu zu holen waere genau das N+1, das das Backend
sich selbst verbietet.

Texte in Deutsch und Englisch, zeilengleich. 44 Tests gruen, darunter die
Uebertrags-Paarung und jedes Modell gegen eine echte Serverantwort.

Nicht geprueft: der Weg gegen ein echtes Backend. Die App laesst nur https zu,
ein lokaler Server haette eine ATS-Ausnahme gebraucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 20:24:06 +02:00
co-authored by Claude Opus 5
parent 58bd504333
commit bf437b3b69
16 changed files with 2252 additions and 2 deletions
+99
View File
@@ -0,0 +1,99 @@
import Foundation
/// `SubscriptionListOut` – die Vertragsliste samt Kopfzahlen.
struct Aboliste: Codable, Sendable, Hashable {
let subscriptions: [Abo]
let summary: Abosummen
}
/// `SubscriptionOut` aus `backend/app/schemas.py`.
struct Abo: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
/// Der Betrag je Zahlung, in der Währung des Vertrags.
let amountCents: Int
let currency: String
/// 1 = monatlich, 12 = jährlich, 3 = vierteljährlich …
let intervalMonths: Int
let accountId: Int?
let categoryId: Int?
let nextDue: Tagesdatum?
/// Woran die Erkennung den Vertrag in einer Buchung wiedererkennt.
let payeePattern: String?
let savingsAccountId: Int?
let isActive: Bool
let cancelledAt: Tagesdatum?
let notes: String?
let position: Int
/// Auf einen Monat umgelegt, in der Vertragswährung – vom Server gerechnet.
let monatlichCents: Int
/// Dasselbe in der Hauptwährung. Fehlt der Kurs, fällt es still auf den
/// nativen Wert zurück; welche Währungen betroffen sind, sagt
/// `Abosummen.ohneKurs`.
let monatlichBaseCents: Int
let tageBisFaellig: Int?
let hasLogo: Bool
let logoVersion: Int?
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, currency, notes, position
case amountCents = "amount_cents"
case intervalMonths = "interval_months"
case accountId = "account_id"
case categoryId = "category_id"
case nextDue = "next_due"
case payeePattern = "payee_pattern"
case savingsAccountId = "savings_account_id"
case isActive = "is_active"
case cancelledAt = "cancelled_at"
case monatlichCents = "monatlich_cents"
case monatlichBaseCents = "monatlich_base_cents"
case tageBisFaellig = "tage_bis_faellig"
case hasLogo = "has_logo"
case logoVersion = "logo_version"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
/// „monatlich", „jährlich", „alle 3 Monate".
var rhythmus: String {
switch intervalMonths {
case 1: return String(localized: "abo.monatlich")
case 3: return String(localized: "abo.vierteljaehrlich")
case 6: return String(localized: "abo.halbjaehrlich")
case 12: return String(localized: "abo.jaehrlich")
default: return String(format: String(localized: "abo.alleNMonate"), intervalMonths)
}
}
/// Steht die nächste Zahlung in der nächsten Woche an?
var istBaldFaellig: Bool {
guard isActive, let tage = tageBisFaellig else { return false }
return tage <= 7
}
}
/// Was am Kopf der Liste steht – die Zahl, wegen der man sie öffnet.
struct Abosummen: Codable, Sendable, Hashable {
let currency: String
let monatlichCents: Int
let jaehrlichCents: Int
let anzahlAktiv: Int
/// Währungen, die sich mangels Kurs nicht umrechnen liessen. Lieber
/// nennen als stillschweigend zu klein summieren.
let ohneKurs: [String]
private enum CodingKeys: String, CodingKey {
case currency
case monatlichCents = "monatlich_cents"
case jaehrlichCents = "jaehrlich_cents"
case anzahlAktiv = "anzahl_aktiv"
case ohneKurs = "ohne_kurs"
}
var monatlich: Geldbetrag { Geldbetrag(cents: monatlichCents, waehrung: currency) }
var jaehrlich: Geldbetrag { Geldbetrag(cents: jaehrlichCents, waehrung: currency) }
}
+192
View File
@@ -0,0 +1,192 @@
import Foundation
/// `TransactionOut` aus `backend/app/schemas.py`.
///
/// Drei Beträge an jeder Buchung, und keiner davon wird hier gerechnet:
/// `amountCents` steht in der Kontowährung, `amountEurCents` ist der beim
/// Speichern eingefrorene EUR-Wert, `amountBaseCents` rechnet der Server bei
/// jedem Lesen in die Hauptwährung um. Die App formatiert nur.
struct Buchung: Codable, Sendable, Identifiable, Hashable {
let id: Int
let type: Buchungsart
let date: Tagesdatum
/// `"14:18:15"` oder nichts. Nur zur Anzeige – gerechnet wird mit dem Tag.
let time: String?
let accountId: Int
let toAccountId: Int?
let accountCurrency: String
let amountCents: Int
let toAmountCents: Int?
let fxRate: Double?
let amountEurCents: Int
let amountBaseCents: Int
let categoryId: Int?
let budgetTreatment: Budgetwirkung?
let budgetCategoryId: Int?
/// Der Vertrag, zu dem die Buchung gehört – von der Erkennung gesetzt.
let subscriptionId: Int?
let budgetPeriod: String
let budgetPeriodManuell: Bool
let importBatchId: Int?
/// bank, mail, shortcut, transfer, csv, manuell.
let herkunft: String
let payee: String?
let description: String?
let notes: String?
let tagIds: [Int]
private enum CodingKeys: String, CodingKey {
case id, type, date, time, herkunft, payee, description, notes
case accountId = "account_id"
case toAccountId = "to_account_id"
case accountCurrency = "account_currency"
case amountCents = "amount_cents"
case toAmountCents = "to_amount_cents"
case fxRate = "fx_rate"
case amountEurCents = "amount_eur_cents"
case amountBaseCents = "amount_base_cents"
case categoryId = "category_id"
case budgetTreatment = "budget_treatment"
case budgetCategoryId = "budget_category_id"
case subscriptionId = "subscription_id"
case budgetPeriod = "budget_period"
case budgetPeriodManuell = "budget_period_manuell"
case importBatchId = "import_batch_id"
case tagIds = "tag_ids"
}
/// In der Kontowährung.
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: accountCurrency) }
/// Was in der Liste als Überschrift steht. Ohne Empfänger nimmt der
/// Verwendungszweck seinen Platz ein – eine leere Zeile hilft niemandem.
var ueberschrift: String {
for text in [payee, description] where !(text ?? "").isEmpty {
return text!
}
return String(localized: "buchung.ohneBezeichnung")
}
/// Die Zeile darunter: Verwendungszweck, falls er nicht schon oben steht.
var unterschrift: String? {
guard let beschreibung = description, !beschreibung.isEmpty,
beschreibung != ueberschrift else { return nil }
return beschreibung
}
/// Ob diese Buchung Geld aus dem Haushalt herausbewegt. Überträge nicht –
/// sie verschieben nur.
var istAusgabe: Bool { type == .expense }
}
/// `TxnType`
enum Buchungsart: String, Codable, Sendable, CaseIterable, Identifiable {
case expense, income, transfer
var id: String { rawValue }
var name: String {
switch self {
case .expense: return String(localized: "buchungsart.ausgabe")
case .income: return String(localized: "buchungsart.einnahme")
case .transfer: return String(localized: "buchungsart.uebertrag")
}
}
var symbol: String {
switch self {
case .expense: return "arrow.up.right"
case .income: return "arrow.down.left"
case .transfer: return "arrow.left.arrow.right"
}
}
}
/// `BudgetTreatment` – ob eine Buchung im Budget zählt oder bloss durchläuft.
enum Budgetwirkung: String, Codable, Sendable {
case budgeted, neutral
}
/// Ein Eintrag aus `GET /api/transactions/links`: die Gegenseite einer
/// Buchung, die mit einer zweiten zu einem Übertrag verknüpft ist.
///
/// Das Gegenkonto steht mit drin, weil die andere Buchung oft gar nicht in
/// der gerade gefilterten Liste steht – und „Übertrag" ohne Angabe wohin
/// sagt nichts.
struct Uebertragsverknuepfung: Codable, Sendable, Hashable {
let linkId: Int
let otherId: Int
/// `"raus"`: Von dieser Buchung geht das Geld weg. `"rein"`: hier kommt es an.
let richtung: String
let otherAccountId: Int?
let automatisch: Bool
private enum CodingKeys: String, CodingKey {
case richtung, automatisch
case linkId = "link_id"
case otherId = "other_id"
case otherAccountId = "other_account_id"
}
var gehtRaus: Bool { richtung == "raus" }
}
/// Eine Zeile der Buchungsliste: entweder eine einzelne Buchung oder zwei,
/// die zusammen einen Übertrag ergeben.
///
/// Das Backend kennt zwei Mechanismen nebeneinander: einen Übertrag am Stück
/// (eine Buchung mit `toAccountId`) und die Verknüpfung zweier getrennter
/// Buchungen, wie die Bank sie liefert. Beide Beine als zwei Zeilen zu zeigen,
/// hiesse dasselbe Geld zweimal zeigen. Das Zusammenfassen macht der Client –
/// das Backend liefert nur die Paarliste.
struct Buchungszeile: Identifiable, Hashable, Sendable {
let buchung: Buchung
/// Das zweite Bein, wenn es in derselben Liste steht.
let gegenstueck: Buchung?
/// Die Verknüpfung selbst – auch dann da, wenn das zweite Bein fehlt.
let verknuepfung: Uebertragsverknuepfung?
var id: Int { buchung.id }
var istUebertrag: Bool { buchung.type == .transfer || verknuepfung != nil }
/// Von welchem Konto das Geld weggeht.
var vonKonto: Int {
guard let verknuepfung else { return buchung.accountId }
if verknuepfung.gehtRaus { return buchung.accountId }
return gegenstueck?.accountId ?? verknuepfung.otherAccountId ?? buchung.accountId
}
/// Auf welchem es ankommt – `nil`, wenn es sich nicht sagen lässt.
var nachKonto: Int? {
guard let verknuepfung else { return buchung.toAccountId }
if verknuepfung.gehtRaus { return gegenstueck?.accountId ?? verknuepfung.otherAccountId }
return buchung.accountId
}
/// Baut aus einer Buchungsliste und der Paarliste die Anzeigezeilen.
///
/// Zusammengefasst wird nur, wenn **beide** Beine in dieser Liste stehen.
/// Fehlt eines, bleibt die Zeile für sich – sie trägt dann aber die
/// Verknüpfung, damit sie „Übertrag nach …" sagen kann statt gar nichts.
/// Ohne diese Vorsicht liesse ein Kontofilter Zeilen verschwinden, die
/// sehr wohl zu diesem Konto gehören.
static func zeilen(aus buchungen: [Buchung],
paare: [String: Uebertragsverknuepfung]) -> [Buchungszeile] {
let nachId = Dictionary(buchungen.map { ($0.id, $0) }, uniquingKeysWith: { erstes, _ in erstes })
var erledigt = Set<Int>()
var ergebnis: [Buchungszeile] = []
for buchung in buchungen where !erledigt.contains(buchung.id) {
let verknuepfung = paare[String(buchung.id)]
let anderes = verknuepfung.flatMap { nachId[$0.otherId] }
if let anderes {
erledigt.insert(anderes.id)
}
erledigt.insert(buchung.id)
ergebnis.append(Buchungszeile(buchung: buchung, gegenstueck: anderes,
verknuepfung: verknuepfung))
}
return ergebnis
}
}
+175
View File
@@ -0,0 +1,175 @@
import Foundation
/// Die Monatsansicht aus `GET /api/budget/{label}` beziehungsweise
/// `/budget/current` (`_build_view` in `backend/app/routers/budgets.py`).
///
/// Alle Beträge stehen schon in der Hauptwährung. Der Client rechnet nur,
/// was es in der API nicht gibt: Prozente und „Rest".
struct Budgetmonat: Codable, Sendable, Hashable {
/// `"2026-10"`.
let label: String
let start: Tagesdatum
let end: Tagesdatum
/// Ob dieser Monat von der Vorlage abweicht.
let customized: Bool
let baseCurrency: String
let prev: String
let next: String
let groups: [Budgetgruppe]
let income: [GeplanteEinnahme]
let vertraege: Vertragsreste
let totals: Budgetsummen
private enum CodingKeys: String, CodingKey {
case label, start, end, customized, prev, next, groups, income, vertraege, totals
case baseCurrency = "base_currency"
}
/// Was vom geplanten Ausgabenbudget noch übrig ist. Kann negativ werden.
var restCents: Int { totals.plannedExpense - totals.actualExpense }
/// Anteil des Verbrauchten, zwischen 0 und 1 gekappt – für einen Balken.
/// Ohne Plan gibt es keinen Anteil, nicht etwa einen vollen Balken.
var anteilVerbraucht: Double {
guard totals.plannedExpense > 0 else { return 0 }
return min(1, max(0, Double(totals.actualExpense) / Double(totals.plannedExpense)))
}
}
/// Eine Kategoriegruppe im Budget.
struct Budgetgruppe: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
let plannedCents: Int
let actualCents: Int
/// Überzogen. Sparen ist davon ausgenommen – siehe `good`.
let over: Bool
/// Alle Kategorien darin sind „Ausgaben, die kein Verlust sind" (Sparen).
/// Dann ist Mehr-als-geplant keine Warnung.
let good: Bool
let ausVertraegenCents: Int
let categories: [Budgetkategorie]
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, over, good, categories
case plannedCents = "planned_cents"
case actualCents = "actual_cents"
case ausVertraegenCents = "aus_vertraegen_cents"
}
}
/// Eine Zeile im Budget.
struct Budgetkategorie: Codable, Sendable, Identifiable, Hashable {
let categoryId: Int
let name: String
let icon: String
let color: String
let plannedCents: Int
let actualCents: Int
let deltaCents: Int
let over: Bool
/// `"fest"` oder `"berechnet"`. Bei „berechnet" ergibt sich der Soll-Wert
/// aus den Verträgen plus Zuschlag.
let modus: String
let zuschlagCents: Int
/// Was die Verträge dieser Kategorie im Monat kosten. Steht auch bei
/// „fest" da – dann als Hinweis, nicht als Summand.
let ausVertraegenCents: Int
let vertraegeAnzahl: Int
let good: Bool
var id: Int { categoryId }
private enum CodingKeys: String, CodingKey {
case name, icon, color, over, modus, good
case categoryId = "category_id"
case plannedCents = "planned_cents"
case actualCents = "actual_cents"
case deltaCents = "delta_cents"
case zuschlagCents = "zuschlag_cents"
case ausVertraegenCents = "aus_vertraegen_cents"
case vertraegeAnzahl = "vertraege_anzahl"
}
var istBerechnet: Bool { modus == "berechnet" }
var anteilVerbraucht: Double {
guard plannedCents > 0 else { return 0 }
return min(1, max(0, Double(actualCents) / Double(plannedCents)))
}
}
/// Eine geplante Einnahme – „Gehalt", „Kindergeld".
struct GeplanteEinnahme: Codable, Sendable, Identifiable, Hashable {
let id: Int
let label: String
let categoryId: Int?
let targetAccountId: Int?
let plannedCents: Int
let receivedCents: Int
private enum CodingKeys: String, CodingKey {
case id, label
case categoryId = "category_id"
case targetAccountId = "target_account_id"
case plannedCents = "planned_cents"
case receivedCents = "received_cents"
}
}
/// Verträge, die sich nicht zuordnen liessen.
///
/// Ohne diese Angaben fehlt ihr Geld im Budget, ohne dass irgendwo steht,
/// dass es fehlt. `ohneKurs` nennt die Währungen, für die kein Wechselkurs
/// vorlag – deren Beträge fehlen in der Summe.
struct Vertragsreste: Codable, Sendable, Hashable {
let ohneKategorieCents: Int
let ohneKategorieAnzahl: Int
let ohneKurs: [String]
private enum CodingKeys: String, CodingKey {
case ohneKategorieCents = "ohne_kategorie_cents"
case ohneKategorieAnzahl = "ohne_kategorie_anzahl"
case ohneKurs = "ohne_kurs"
}
var istLeer: Bool { ohneKategorieAnzahl == 0 && ohneKurs.isEmpty }
}
struct Budgetsummen: Codable, Sendable, Hashable {
let plannedExpense: Int
let actualExpense: Int
let plannedIncome: Int
let receivedIncome: Int
private enum CodingKeys: String, CodingKey {
case plannedExpense = "planned_expense"
case actualExpense = "actual_expense"
case plannedIncome = "planned_income"
case receivedIncome = "received_income"
}
}
/// `GET /api/budget/template` – das Standard-Budget hinter den Monaten.
struct Budgetvorlage: Codable, Sendable, Hashable {
let lines: [Vorlagenzeile]
let income: [GeplanteEinnahme]
}
struct Vorlagenzeile: Codable, Sendable, Identifiable, Hashable {
let categoryId: Int
let plannedCents: Int
let modus: String
let zuschlagCents: Int
var id: Int { categoryId }
private enum CodingKeys: String, CodingKey {
case modus
case categoryId = "category_id"
case plannedCents = "planned_cents"
case zuschlagCents = "zuschlag_cents"
}
}
+123
View File
@@ -0,0 +1,123 @@
import Foundation
// Die Karten des Dashboards. Anders als im Web ist die Auswahl hier fest –
// ein verschiebbares Raster auf Telefonbreite wäre Arbeit ohne Gewinn. Die
// vier Endpunkte sind dieselben, die das Web benutzt.
/// `GET /api/dashboard/balances`
struct Kontostaende: Codable, Sendable, Hashable {
let accounts: [Kontozeile]
/// Verfügbar, ohne gebundene und archivierte Konten.
let total: Int
/// Was auf gebundenen Konten liegt – Geld, an das man nicht herankommt.
let gebunden: Int
let currency: String
var summe: Geldbetrag { Geldbetrag(cents: total, waehrung: currency) }
var gebundenBetrag: Geldbetrag { Geldbetrag(cents: gebunden, waehrung: currency) }
struct Kontozeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let icon: String
let color: String
let currency: String
let balanceCents: Int
let balanceBaseCents: Int
let archived: Bool
let locked: Bool
private enum CodingKeys: String, CodingKey {
case id, name, icon, color, currency, archived, locked
case balanceCents = "balance_cents"
case balanceBaseCents = "balance_base_cents"
}
var saldo: Geldbetrag { Geldbetrag(cents: balanceCents, waehrung: currency) }
}
}
/// `GET /api/dashboard/budget-progress`
struct Budgetfortschritt: Codable, Sendable, Hashable {
let rows: [Zeile]
let currency: String
let label: String
let groupBy: String
private enum CodingKeys: String, CodingKey {
case rows, currency, label
case groupBy = "group_by"
}
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let name: String
let color: String
let planned: Int
let actual: Int
let over: Bool
/// Sparen: mehr als geplant ist hier keine Warnung.
let good: Bool
var id: String { name }
var anteil: Double {
guard planned > 0 else { return 0 }
return min(1, max(0, Double(actual) / Double(planned)))
}
}
}
/// `GET /api/dashboard/income-expense`
struct EinnahmenAusgaben: Codable, Sendable, Hashable {
let income: Int
let expense: Int
let net: Int
let currency: String
let label: String
var einnahmen: Geldbetrag { Geldbetrag(cents: income, waehrung: currency) }
var ausgaben: Geldbetrag { Geldbetrag(cents: expense, waehrung: currency) }
var uebrig: Geldbetrag { Geldbetrag(cents: net, waehrung: currency) }
}
/// `GET /api/dashboard/recent`
struct LetzteBuchungen: Codable, Sendable, Hashable {
let transactions: [Zeile]
let currency: String
struct Zeile: Codable, Sendable, Identifiable, Hashable {
let id: Int
let date: Tagesdatum
let type: Buchungsart
/// Schon fertig formuliert: die Kategorie, sonst der Empfänger, bei
/// einem Übertrag „Konto A → Konto B".
let title: String
let icon: String
let color: String
/// `"pos"`, `"neg"` oder nichts – bei einem Übertrag gibt es keine
/// Richtung, weil kein Geld den Haushalt verlässt.
let sign: String?
let amountCents: Int
let currency: String
let amountBaseCents: Int
private enum CodingKeys: String, CodingKey {
case id, date, type, title, icon, color, sign, currency
case amountCents = "amount_cents"
case amountBaseCents = "amount_base_cents"
}
var betrag: Geldbetrag { Geldbetrag(cents: amountCents, waehrung: currency) }
/// Grün bei Einnahme, rot bei Ausgabe, neutral beim Übertrag.
var vorzeichen: Vorzeichen {
switch sign {
case "pos": return .plus
case "neg": return .minus
default: return .keines
}
}
enum Vorzeichen: Sendable { case plus, minus, keines }
}
}
+124
View File
@@ -0,0 +1,124 @@
import Foundation
/// `CategoryGroupOut` – Kategorien stehen immer in einer Gruppe.
struct Kategoriegruppe: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let kind: Kategorieart
let icon: String
let color: String
let position: Int
let isBuiltin: Bool
let categories: [Kategorie]
private enum CodingKeys: String, CodingKey {
case id, name, kind, icon, color, position, categories
case isBuiltin = "is_builtin"
}
}
/// `CategoryOut`
struct Kategorie: Codable, Sendable, Identifiable, Hashable {
let id: Int
let groupId: Int
let name: String
let type: Kategorieart
let icon: String
let color: String
let position: Int
let isBuiltin: Bool
let isArchived: Bool
/// Zählt in keiner Auswertung mit – etwa „Durchlaufende Posten".
let excludedFromStats: Bool
/// Eine Ausgabe, die kein Verlust ist: Sparen. Mehr als geplant ist hier
/// keine Warnung.
let spendingIsGood: Bool
let keywordCount: Int
private enum CodingKeys: String, CodingKey {
case id, name, type, icon, color, position
case groupId = "group_id"
case isBuiltin = "is_builtin"
case isArchived = "is_archived"
case excludedFromStats = "excluded_from_stats"
case spendingIsGood = "spending_is_good"
case keywordCount = "keyword_count"
}
}
/// `CategoryKind`
enum Kategorieart: String, Codable, Sendable {
case income, expense
}
/// `TagOut` – ein frei vergebenes Etikett, quer zur Kategorie.
struct Schlagwort: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let color: String
}
/// Kategorien und Tags, wie die Ansichten sie brauchen: einmal geladen,
/// danach als Nachschlagewerk.
///
/// Fast jede Liste will zu einer ID den Namen, das Symbol und die Farbe.
/// Das je Zeile zu holen wäre genau das N+1, das das Backend sich selbst
/// verbietet.
struct Nachschlag: Codable, Sendable {
let gruppen: [Kategoriegruppe]
let schlagwoerter: [Schlagwort]
let konten: [Konto]
private let kategorienNachId: [Int: Kategorie]
private let schlagwortNachId: [Int: Schlagwort]
private let kontoNachId: [Int: Konto]
init(gruppen: [Kategoriegruppe], schlagwoerter: [Schlagwort], konten: [Konto]) {
self.gruppen = gruppen
self.schlagwoerter = schlagwoerter
self.konten = konten
let alle = gruppen.flatMap(\.categories)
self.kategorienNachId = Dictionary(alle.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
self.schlagwortNachId = Dictionary(schlagwoerter.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
self.kontoNachId = Dictionary(konten.map { ($0.id, $0) },
uniquingKeysWith: { erstes, _ in erstes })
}
static let leer = Nachschlag(gruppen: [], schlagwoerter: [], konten: [])
/// Gespeichert werden nur die drei Listen; die Nachschlagetabellen baut
/// der Konstruktor daraus neu. Sie mitzuschreiben hiesse, dieselben Daten
/// zweimal auf der Platte zu halten.
private enum CodingKeys: String, CodingKey {
case gruppen, schlagwoerter, konten
}
init(from decoder: Decoder) throws {
let behaelter = try decoder.container(keyedBy: CodingKeys.self)
self.init(gruppen: try behaelter.decode([Kategoriegruppe].self, forKey: .gruppen),
schlagwoerter: try behaelter.decode([Schlagwort].self, forKey: .schlagwoerter),
konten: try behaelter.decode([Konto].self, forKey: .konten))
}
func encode(to encoder: Encoder) throws {
var behaelter = encoder.container(keyedBy: CodingKeys.self)
try behaelter.encode(gruppen, forKey: .gruppen)
try behaelter.encode(schlagwoerter, forKey: .schlagwoerter)
try behaelter.encode(konten, forKey: .konten)
}
/// Alle Ausgabekategorien, flach – für Auswahllisten.
var ausgabekategorien: [Kategorie] {
gruppen.filter { $0.kind == .expense }.flatMap(\.categories).filter { !$0.isArchived }
}
func kategorie(_ id: Int?) -> Kategorie? { id.flatMap { kategorienNachId[$0] } }
func schlagwort(_ id: Int) -> Schlagwort? { schlagwortNachId[id] }
func konto(_ id: Int?) -> Konto? { id.flatMap { kontoNachId[$0] } }
func kontoname(_ id: Int?) -> String {
konto(id)?.name ?? String(localized: "allgemein.unbekannt")
}
}