diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift index 000b203..133e391 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherCache.swift @@ -11,6 +11,14 @@ public struct WeatherCache: Sendable { /// Wie lange ein Stand als aktuell gilt. public static let maxAge: TimeInterval = 15 * 60 + /// Ab wann ein Messwert nicht mehr als aktuell durchgeht. + /// + /// Großzügiger als die Haltbarkeit: dass ein Abruf einmal danebengeht und + /// der Wert zwanzig Minuten alt ist, muss keine Warnung wert sein. Eine + /// Stunde altes Wetter dagegen schon — spätestens dann stimmt die + /// Temperatur nicht mehr mit dem überein, was man vor dem Fenster sieht. + public static let staleAge: TimeInterval = 60 * 60 + private var entries: [String: WeatherSnapshot] = [:] private let url: URL? diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift index ed62078..22479d8 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherModels.swift @@ -71,6 +71,14 @@ public struct WeatherAttribution: Equatable, Sendable, Codable { } public struct WeatherSnapshot: Equatable, Sendable, Codable { + + /// Ob ein Messwert nicht mehr als aktuell durchgeht. + /// + /// Die Prüfung liegt hier und nicht in der View: sie entscheidet, ob eine + /// Warnung erscheint, und das ist eine Regel, keine Darstellung. + public static func isStale(asOf: Date, now: Date = Date()) -> Bool { + now.timeIntervalSince(asOf) > WeatherCache.staleAge + } public let placeName: String public let temperature: Measurement public let apparentTemperature: Measurement diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index f17ce17..2efee9d 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -34,6 +34,8 @@ public final class WeatherModel { private let defaults: UserDefaults private var cache = WeatherCache() private var task: Task? + /// Der Takt, in dem nachgesehen wird. + private var timer: Timer? public init(source: WeatherKitSource = WeatherKitSource(), defaults: UserDefaults = .standard) { @@ -80,6 +82,8 @@ public final class WeatherModel { /// Panels. WeatherKit rechnet nach Abrufen ab, und das Wetter ändert sich /// nicht im Sekundentakt. public func load(force: Bool = false) { + startTimerIfNeeded() + if !force, let fresh = cache.valid(for: place) { state = .ready(fresh) return @@ -113,9 +117,32 @@ public final class WeatherModel { } } + /// Sieht regelmäßig nach. + /// + /// **Ohne das lud das Widget genau einmal je Programmstart.** Der Aufruf + /// hing an `task` der View, und die erscheint bei einem Panel, das immer + /// existiert, ein einziges Mal. Wer Onyx tagelang laufen lässt, sah + /// tagelang dieselbe Temperatur — nachts 35 Grad, weil sie vom Nachmittag + /// des Vortags stammte. + /// + /// Der Takt entspricht der Haltbarkeit des Zwischenspeichers. Teurer wird + /// es dadurch nicht: `load` fragt nur nach, wenn der gespeicherte Stand + /// abgelaufen ist. Und der Ort wird dabei neu bestimmt — wer die Stadt + /// wechselt, sieht sonst weiter das Wetter der alten. + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer(timeInterval: WeatherCache.maxAge, repeats: true) { [weak self] _ in + Task { @MainActor in self?.load() } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + public func stop() { task?.cancel() task = nil + timer?.invalidate() + timer = nil } /// Sucht die Koordinaten zu einem Ortsnamen und übernimmt ihn. @@ -159,7 +186,11 @@ private struct WeatherWidgetView: View { Group { switch model.state { case .ready(let snapshot): - content(snapshot, isStale: false) + // Auch ein „fertiger" Stand kann alt sein — dann ist er als + // solcher zu kennzeichnen. Genau das fehlte: drei Tage alte + // 35 Grad standen um elf Uhr nachts da, als wären sie eben + // gemessen worden. + content(snapshot, isStale: WeatherSnapshot.isStale(asOf: snapshot.asOf)) case .unavailable(let lastKnown, _): if let lastKnown { content(lastKnown, isStale: true) diff --git a/Packages/OnyxKit/Tests/WeatherProviderTests/StalenessTests.swift b/Packages/OnyxKit/Tests/WeatherProviderTests/StalenessTests.swift new file mode 100644 index 0000000..3e21577 --- /dev/null +++ b/Packages/OnyxKit/Tests/WeatherProviderTests/StalenessTests.swift @@ -0,0 +1,40 @@ +import Testing +import Foundation +@testable import WeatherProvider + +@Suite("Alter eines Messwerts") +struct StalenessTests { + + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test("Frisch Gemessenes ist nicht alt") + func freshIsNotStale() { + #expect(!WeatherSnapshot.isStale(asOf: now.addingTimeInterval(-60), now: now)) + } + + @Test("Ein missglückter Abruf macht noch keine Warnung") + func aMissedFetchIsTolerated() { + // Zwanzig Minuten sind über der Haltbarkeit, aber kein Grund für ein + // Warnzeichen — sonst steht es bei jedem verpassten Abruf da. + #expect(!WeatherSnapshot.isStale(asOf: now.addingTimeInterval(-20 * 60), now: now)) + } + + @Test("Nach einer Stunde ist es alt") + func anHourIsStale() { + #expect(WeatherSnapshot.isStale(asOf: now.addingTimeInterval(-61 * 60), now: now)) + } + + @Test("Der Fall aus der Praxis: drei Tage") + func threeDaysIsStale() { + // Nachts um elf standen 35 Grad da, gemessen am Nachmittag drei Tage + // vorher — und nichts wies darauf hin. + #expect(WeatherSnapshot.isStale(asOf: now.addingTimeInterval(-3 * 86400), now: now)) + } + + @Test("Die Haltbarkeit ist kürzer als die Warnschwelle") + func cacheExpiresBeforeItWarns() { + // Sonst wäre jeder abgelaufene Eintrag zugleich ein Warnfall, und die + // Warnung sagte nichts mehr aus. + #expect(WeatherCache.maxAge < WeatherCache.staleAge) + } +}