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

@@ -20,95 +20,83 @@ struct WeatherAlertConfig: Sendable {
init() {}
}
protocol WeatherWarningProviding: Sendable {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate]
}
struct WeatherWarning: Codable, Equatable, Sendable {
let id: String
let title: String
let subtitle: String
let ttlSec: Int
let confidence: Double
struct NoopWeatherWarningProvider: WeatherWarningProviding {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] }
}
/// Loads mock warning candidates from a local JSON file.
///
/// File format:
/// [
/// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } }
/// ]
struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable {
struct MockWarning: Codable {
let id: String
let title: String
let subtitle: String
let ttlSec: Int?
let confidence: Double?
let meta: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case ttlSec = "ttl_sec"
case confidence
case meta
}
}
let url: URL?
init(url: URL?) {
self.url = url
}
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] {
guard let url else { return [] }
guard let data = try? Data(contentsOf: url) else { return [] }
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
return items.map { item in
Candidate(
id: item.id,
type: .weatherWarning,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0),
createdAt: now,
ttlSec: max(1, item.ttlSec ?? 3600),
metadata: item.meta
)
}
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case ttlSec = "ttl_sec"
case confidence
}
}
@available(iOS 16.0, *)
final class WeatherDataSource {
struct WeatherData: Sendable, Equatable {
struct Current: Sendable, Equatable {
let temperatureC: Int
let feelsLikeC: Int
let condition: WeatherKit.WeatherCondition
}
enum RainSource: String, Sendable, Equatable {
case minutely
case hourlyApprox
}
struct RainSoon: Sendable, Equatable {
let startAt: Int
let ttlSec: Int
let source: RainSource
}
struct WindAlert: Sendable, Equatable {
let gustMps: Double
let thresholdMps: Double
let ttlSec: Int
}
let current: Current?
let rainSoon: RainSoon?
let windAlert: WindAlert?
let warnings: [WeatherWarning]
}
private let service: WeatherService
private let config: WeatherAlertConfig
private let warningProvider: WeatherWarningProviding
init(service: WeatherService = .shared,
config: WeatherAlertConfig = .init(),
warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider(
url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json")
)) {
config: WeatherAlertConfig = .init()) {
self.service = service
self.config = config
self.warningProvider = warningProvider
}
/// Returns alert candidates derived from WeatherKit forecasts.
func candidates(for location: CLLocation, now: Int) async -> [Candidate] {
let result = await candidatesWithDiagnostics(for: location, now: now)
return result.candidates
}
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let weatherKitError: String?
struct Snapshot: Sendable {
let data: WeatherData
let diagnostics: [String: String]
}
func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult {
var results: [Candidate] = []
var errorString: String? = nil
enum WeatherError: Error, LocalizedError, Sendable {
case weatherKitFailed(message: String, diagnostics: [String: String])
var errorDescription: String? {
switch self {
case .weatherKitFailed(let message, _):
return message
}
}
}
func dataWithDiagnostics(for location: CLLocation, now: Int) async throws -> Snapshot {
var current: WeatherData.Current? = nil
var rainSoon: WeatherData.RainSoon? = nil
var windAlert: WeatherData.WindAlert? = nil
var diagnostics: [String: String] = [
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
@@ -120,7 +108,7 @@ final class WeatherDataSource {
do {
let weather = try await service.weather(for: location)
results.append(currentConditionsCandidate(weather: weather, location: location, now: now))
current = currentConditions(weather: weather)
diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil"
diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count)
if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value {
@@ -129,122 +117,100 @@ final class WeatherDataSource {
diagnostics["current_gust_mps"] = "nil"
}
results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now))
results.append(contentsOf: windCandidates(weather: weather, location: location, now: now))
diagnostics["candidates_weatherkit"] = String(results.count)
rainSoon = rainSoonAlert(weather: weather, now: now)
windAlert = windAlertInfo(weather: weather)
diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new }
} catch {
errorString = String(describing: error)
diagnostics["weatherkit_error"] = errorString
let msg = String(describing: error)
diagnostics["weatherkit_error"] = msg
throw WeatherError.weatherKitFailed(message: msg, diagnostics: diagnostics)
}
results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now))
diagnostics["candidates_total"] = String(results.count)
return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics)
let warnings = warnings(now: now)
diagnostics["warnings_count"] = String(warnings.count)
let data = WeatherData(
current: current,
rainSoon: rainSoon,
windAlert: windAlert,
warnings: warnings
)
let output = Snapshot(data: data, diagnostics: diagnostics)
return output
}
private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate {
private func warnings(now: Int) -> [WeatherWarning] {
struct MockWarning: Codable {
let id: String
let title: String
let subtitle: String
let ttlSec: Int?
let confidence: Double?
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case ttlSec = "ttl_sec"
case confidence
}
}
_ = now
guard let url = Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json") else { return [] }
guard let data = try? Data(contentsOf: url) else { return [] }
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
return items.map { item in
WeatherWarning(
id: item.id,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
ttlSec: max(1, item.ttlSec ?? 3600),
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0)
)
}
}
private func currentConditions(weather: Weather) -> WeatherData.Current {
let tempC = weather.currentWeather.temperature.converted(to: .celsius).value
let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value
let tempInt = Int(tempC.rounded())
let feelsInt = Int(feelsC.rounded())
let cond = weather.currentWeather.condition.description
let conditionEnum = weather.currentWeather.condition
let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax)
let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax)
return Candidate(
id: "wx:now:\(now / 60)",
type: .currentWeather,
title: title,
subtitle: subtitle,
confidence: 0.8,
createdAt: now,
ttlSec: 1800,
condition: conditionEnum,
metadata: [
"source": "weatherkit_current",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
return WeatherData.Current(temperatureC: tempInt, feelsLikeC: feelsInt, condition: conditionEnum)
}
private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
private func rainSoonAlert(weather: Weather, now: Int) -> WeatherData.RainSoon? {
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast {
if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.9,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_minutely",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
return WeatherData.RainSoon(
startAt: Int(start.timeIntervalSince1970),
ttlSec: config.rainTTL,
source: .minutely
)
}
return []
return nil
}
if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.6,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_hourly_approx",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
return WeatherData.RainSoon(
startAt: Int(start.timeIntervalSince1970),
ttlSec: config.rainTTL,
source: .hourlyApprox
)
}
return []
return nil
}
private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
guard let gustThreshold = config.gustThresholdMps else { return [] }
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] }
guard gust >= gustThreshold else { return [] }
let mph = Int((gust * 2.236936).rounded())
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
return [
Candidate(
id: "wx:wind:\(now):\(Int(gustThreshold * 10))",
type: .weatherAlert,
title: title,
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.8,
createdAt: now,
ttlSec: config.windTTL,
metadata: [
"source": "weatherkit_current",
"gust_mps": String(format: "%.2f", gust),
"threshold_mps": String(format: "%.2f", gustThreshold),
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
private func windAlertInfo(weather: Weather) -> WeatherData.WindAlert? {
guard let gustThreshold = config.gustThresholdMps else { return nil }
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return nil }
guard gust >= gustThreshold else { return nil }
return WeatherData.WindAlert(gustMps: gust, thresholdMps: gustThreshold, ttlSec: config.windTTL)
}
private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? {