import Foundation /// Ein Kalendertag ohne Uhrzeit, so wie das Backend ihn schickt: `"2026-07-14"`. /// /// Warum ein eigener Typ und nicht `Date` mit einer Decoder-Strategie: Das /// Backend mischt zwei Formate. Buchungsdaten sind reine Tage, `created_at` /// und Konsorten sind volle Zeitstempel mit Zone. Eine einzige /// `dateDecodingStrategy` müsste beides raten und würde mal das eine, mal das /// andere zerlegen. Zwei saubere Typen sind hier weniger Arbeit als ein /// cleverer Decoder. struct Tagesdatum: Codable, Hashable, Comparable, Sendable, CustomStringConvertible { /// Mitternacht dieses Tages, in der Zeitzone des Geräts. let datum: Date /// Die Form, in der es kam und wieder hinausgeht. let text: String private static let leser: DateFormatter = { let f = DateFormatter() f.calendar = Calendar(identifier: .gregorian) f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = .current f.dateFormat = "yyyy-MM-dd" return f }() init?(_ text: String) { guard let geparst = Tagesdatum.leser.date(from: text) else { return nil } self.datum = geparst self.text = text } init(_ datum: Date) { self.datum = datum self.text = Tagesdatum.leser.string(from: datum) } static var heute: Tagesdatum { Tagesdatum(Date()) } init(from decoder: Decoder) throws { let roh = try decoder.singleValueContainer().decode(String.self) guard let fertig = Tagesdatum(roh) else { throw DecodingError.dataCorrupted(.init( codingPath: decoder.codingPath, debugDescription: "Kein Tagesdatum: \(roh)")) } self = fertig } func encode(to encoder: Encoder) throws { var behaelter = encoder.singleValueContainer() try behaelter.encode(text) } static func < (links: Tagesdatum, rechts: Tagesdatum) -> Bool { links.datum < rechts.datum } var description: String { text } /// Für die Anzeige, in der Sprache des Geräts. func formatiert(_ stil: Date.FormatStyle.DateStyle = .abbreviated) -> String { datum.formatted(.dateTime.day().month(.defaultDigits).year()) .replacingOccurrences(of: "\u{202F}", with: " ") } } /// Ein voller Zeitstempel, wie ihn `created_at`, `last_used_at` und /// `bank_balance_at` tragen. /// /// Das Backend schickt ISO 8601, mal mit Sekundenbruchteilen, mal ohne, mal /// mit `Z`, mal mit Offset. Deshalb zwei Leser hintereinander statt einem. struct Zeitpunkt: Codable, Hashable, Comparable, Sendable { let datum: Date private static let mitBruchteilen: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private static let ohneBruchteile: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() /// Letzter Ausweg: Python schreibt ohne Zone, wenn keine gesetzt ist. private static let ohneZone: DateFormatter = { let f = DateFormatter() f.calendar = Calendar(identifier: .gregorian) f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(identifier: "UTC") f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" return f }() init(_ datum: Date) { self.datum = datum } init?(_ text: String) { let ohneMikro = text.replacingOccurrences( of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression) if let d = Zeitpunkt.mitBruchteilen.date(from: ohneMikro) { self.datum = d; return } if let d = Zeitpunkt.ohneBruchteile.date(from: text) { self.datum = d; return } let gekuerzt = String(text.prefix(19)) if let d = Zeitpunkt.ohneZone.date(from: gekuerzt) { self.datum = d; return } return nil } init(from decoder: Decoder) throws { let roh = try decoder.singleValueContainer().decode(String.self) guard let fertig = Zeitpunkt(roh) else { throw DecodingError.dataCorrupted(.init( codingPath: decoder.codingPath, debugDescription: "Kein Zeitpunkt: \(roh)")) } self = fertig } func encode(to encoder: Encoder) throws { var behaelter = encoder.singleValueContainer() try behaelter.encode(Zeitpunkt.mitBruchteilen.string(from: datum)) } static func < (links: Zeitpunkt, rechts: Zeitpunkt) -> Bool { links.datum < rechts.datum } }