Refactor data sources and feed model

This commit is contained in:
2026-01-10 00:25:36 +00:00
parent 1e65a3f57d
commit 324b35a464
15 changed files with 631 additions and 609 deletions

View File

@@ -11,7 +11,7 @@ import os
@MainActor
final class CandidatesViewModel: ObservableObject {
@Published private(set) var candidates: [Candidate] = []
@Published private(set) var candidates: [FeedItem] = []
@Published private(set) var lastUpdatedAt: Date? = nil
@Published private(set) var isLoading = false
@Published private(set) var lastError: String? = nil
@@ -41,24 +41,121 @@ final class CandidatesViewModel: ObservableObject {
if #available(iOS 16.0, *) {
let ds = WeatherDataSource()
let result = await ds.candidatesWithDiagnostics(for: location, now: now)
let output: WeatherDataSource.Snapshot
do {
output = try await ds.dataWithDiagnostics(for: location, now: now)
} catch let error as WeatherDataSource.WeatherError {
switch error {
case .weatherKitFailed(_, let diagnostics):
await MainActor.run {
self.candidates = []
self.diagnostics = diagnostics
self.lastError = "WeatherKit error: \(error.localizedDescription)"
}
return
}
} catch {
await MainActor.run {
self.candidates = []
self.diagnostics = ["weatherkit_error": String(describing: error)]
self.lastError = "WeatherKit error: \(error)"
}
return
}
let data = output.data
var items: [FeedItem] = []
if let current = data.current {
items.append(
FeedItem(
id: "wx:now:\(now / 60)",
type: .currentWeather,
title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax),
subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax),
priority: 0.8,
ttlSec: 1800,
condition: current.condition,
startsAt: nil,
bucket: .fyi,
actions: ["DISMISS"]
)
)
}
if let rainSoon = data.rainSoon {
let minutes = max(0, Int(((TimeInterval(rainSoon.startAt - now)) / 60.0).rounded()))
let title = (minutes <= 0) ? "Rain now" : "Rain in ~\(minutes) min"
let subtitle = (rainSoon.source == .minutely) ? "Carry an umbrella" : "Rain likely soon"
let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6
items.append(
FeedItem(
id: "wx:rain:\(rainSoon.startAt)",
type: .weatherAlert,
title: title.truncated(maxLength: TextConstraints.titleMax),
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: confidence,
ttlSec: max(1, rainSoon.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
)
}
if let wind = data.windAlert {
let mph = Int((wind.gustMps * 2.236936).rounded())
items.append(
FeedItem(
id: "wx:wind:\(now):\(Int(wind.thresholdMps * 10))",
type: .weatherAlert,
title: "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax),
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
priority: 0.8,
ttlSec: max(1, wind.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
)
}
for warning in data.warnings {
items.append(
FeedItem(
id: warning.id,
type: .weatherWarning,
title: warning.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(warning.confidence, 0.0), 1.0),
ttlSec: max(1, warning.ttlSec),
condition: nil,
startsAt: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
)
}
items.sort { $0.priority > $1.priority }
await MainActor.run {
self.candidates = result.candidates.sorted { $0.confidence > $1.confidence }
self.diagnostics = result.diagnostics
if let error = result.weatherKitError {
self.candidates = items
self.diagnostics = output.diagnostics
if let error = output.diagnostics["weatherkit_error"] {
self.lastError = "WeatherKit error: \(error)"
}
}
if let error = result.weatherKitError {
if let error = output.diagnostics["weatherkit_error"] {
self.logger.error("WeatherKit error: \(error)")
}
self.logger.info("Produced candidates count=\(result.candidates.count)")
for c in result.candidates {
self.logger.info("Candidate id=\(c.id, privacy: .public) type=\(c.type.rawValue, privacy: .public) conf=\(c.confidence, format: .fixed(precision: 2)) ttl=\(c.ttlSec) title=\(c.title, privacy: .public)")
self.logger.info("Produced feed items count=\(items.count)")
for item in items {
self.logger.info("FeedItem id=\(item.id, privacy: .public) type=\(item.type.rawValue, privacy: .public) prio=\(item.priority, format: .fixed(precision: 2)) ttl=\(item.ttlSec) title=\(item.title, privacy: .public)")
}
if result.candidates.isEmpty {
self.logger.info("Diagnostics: \(String(describing: result.diagnostics), privacy: .public)")
if items.isEmpty {
self.logger.info("Diagnostics: \(String(describing: output.diagnostics), privacy: .public)")
}
} else {
await MainActor.run {