Refactor data sources and feed model
This commit is contained in:
@@ -19,12 +19,42 @@ struct CalendarDataSourceConfig: Sendable {
|
||||
}
|
||||
|
||||
final class CalendarDataSource {
|
||||
struct CandidatesResult: Sendable {
|
||||
let candidates: [Candidate]
|
||||
let error: String?
|
||||
struct Event: Sendable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let startAt: Int
|
||||
let endAt: Int
|
||||
let isAllDay: Bool
|
||||
let location: String?
|
||||
}
|
||||
|
||||
struct Data: Sendable, Equatable {
|
||||
let events: [Event]
|
||||
}
|
||||
|
||||
struct Snapshot: Sendable, Equatable {
|
||||
let data: Data
|
||||
let diagnostics: [String: String]
|
||||
}
|
||||
|
||||
enum CalendarError: Error, LocalizedError, Sendable, Equatable {
|
||||
case accessNotGranted(diagnostics: [String: String])
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .accessNotGranted:
|
||||
return "Calendar access not granted."
|
||||
}
|
||||
}
|
||||
|
||||
var diagnostics: [String: String] {
|
||||
switch self {
|
||||
case .accessNotGranted(let diagnostics):
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let store: EKEventStore
|
||||
private let config: CalendarDataSourceConfig
|
||||
|
||||
@@ -33,7 +63,7 @@ final class CalendarDataSource {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
|
||||
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
|
||||
var diagnostics: [String: String] = [
|
||||
"now": String(now),
|
||||
"lookahead_sec": String(config.lookaheadSec),
|
||||
@@ -50,7 +80,7 @@ final class CalendarDataSource {
|
||||
diagnostics["access_granted"] = accessGranted ? "true" : "false"
|
||||
|
||||
guard accessGranted else {
|
||||
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
|
||||
throw CalendarError.accessNotGranted(diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
|
||||
@@ -64,10 +94,10 @@ final class CalendarDataSource {
|
||||
|
||||
diagnostics["events_filtered"] = String(filtered.count)
|
||||
|
||||
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
|
||||
diagnostics["candidates"] = String(candidates.count)
|
||||
let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
|
||||
diagnostics["events_output"] = String(outputEvents.count)
|
||||
|
||||
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
|
||||
return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics)
|
||||
}
|
||||
|
||||
private func shouldInclude(event: EKEvent) -> Bool {
|
||||
@@ -84,8 +114,8 @@ final class CalendarDataSource {
|
||||
return true
|
||||
}
|
||||
|
||||
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
|
||||
var results: [Candidate] = []
|
||||
private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
|
||||
var results: [Event] = []
|
||||
results.reserveCapacity(min(config.maxCandidates, events.count))
|
||||
|
||||
for event in events {
|
||||
@@ -99,34 +129,16 @@ final class CalendarDataSource {
|
||||
continue
|
||||
}
|
||||
|
||||
let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming")
|
||||
.truncated(maxLength: TextConstraints.titleMax)
|
||||
|
||||
let subtitle = subtitleText(event: event, nowDate: nowDate)
|
||||
.truncated(maxLength: TextConstraints.subtitleMax)
|
||||
|
||||
let confidence: Double = isOngoing ? 0.9 : 0.7
|
||||
let ttl = ttlSec(end: end, nowDate: nowDate)
|
||||
|
||||
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
|
||||
|
||||
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
results.append(
|
||||
Candidate(
|
||||
Event(
|
||||
id: id,
|
||||
type: .info,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
confidence: confidence,
|
||||
createdAt: now,
|
||||
ttlSec: ttl,
|
||||
metadata: [
|
||||
"source": "eventkit",
|
||||
"calendar": event.calendar.title,
|
||||
"start": String(Int(start.timeIntervalSince1970)),
|
||||
"end": String(Int(end.timeIntervalSince1970)),
|
||||
"all_day": event.isAllDay ? "true" : "false",
|
||||
"location": event.location ?? "",
|
||||
]
|
||||
title: title.isEmpty ? "Event" : title,
|
||||
startAt: Int(start.timeIntervalSince1970),
|
||||
endAt: Int(end.timeIntervalSince1970),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -134,31 +146,6 @@ final class CalendarDataSource {
|
||||
return results
|
||||
}
|
||||
|
||||
private func ttlSec(end: Date, nowDate: Date) -> Int {
|
||||
let ttl = Int(end.timeIntervalSince(nowDate))
|
||||
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
|
||||
return min(max(ttl, 60), 2 * 60 * 60)
|
||||
}
|
||||
|
||||
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
|
||||
guard let start = event.startDate, let end = event.endDate else { return "" }
|
||||
let isOngoing = start <= nowDate && end > nowDate
|
||||
|
||||
if isOngoing {
|
||||
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
|
||||
if let loc = event.location, !loc.isEmpty {
|
||||
return "\(loc) • \(remainingMin)m left"
|
||||
}
|
||||
return "\(remainingMin)m left"
|
||||
}
|
||||
|
||||
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
|
||||
if let loc = event.location, !loc.isEmpty {
|
||||
return "In \(minutes)m • \(loc)"
|
||||
}
|
||||
return "In \(minutes)m"
|
||||
}
|
||||
|
||||
private func calendarAuthorizationStatusString() -> String {
|
||||
if #available(iOS 17.0, *) {
|
||||
return String(describing: EKEventStore.authorizationStatus(for: .event))
|
||||
@@ -169,13 +156,18 @@ final class CalendarDataSource {
|
||||
private func ensureCalendarAccess() async -> Bool {
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
if status == .fullAccess { return true }
|
||||
if status == .writeOnly { return false }
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
return await requestAccess()
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .authorized:
|
||||
return true
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -15,13 +15,18 @@ struct POIDataSourceConfig: Sendable {
|
||||
|
||||
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
|
||||
final class POIDataSource {
|
||||
struct POI: Sendable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
private let config: POIDataSourceConfig
|
||||
|
||||
init(config: POIDataSourceConfig = .init()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] {
|
||||
func data(for location: CLLocation, now: Int) async throws -> [POI] {
|
||||
// Phase 1 stub: return nothing.
|
||||
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
|
||||
_ = config
|
||||
@@ -30,4 +35,3 @@ final class POIDataSource {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -2,60 +2,7 @@
|
||||
// Candidate.swift
|
||||
// iris
|
||||
//
|
||||
// Created by Codex.
|
||||
// Deprecated: data sources now return typed data and the orchestrator produces `FeedItem`s.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WeatherKit
|
||||
|
||||
struct Candidate: Codable, Equatable {
|
||||
let id: String
|
||||
let type: WinnerType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let confidence: Double
|
||||
let createdAt: Int
|
||||
let ttlSec: Int
|
||||
let condition: WeatherKit.WeatherCondition?
|
||||
let metadata: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case subtitle
|
||||
case confidence
|
||||
case createdAt
|
||||
case ttlSec
|
||||
case condition
|
||||
case metadata
|
||||
}
|
||||
|
||||
init(id: String,
|
||||
type: WinnerType,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
confidence: Double,
|
||||
createdAt: Int,
|
||||
ttlSec: Int,
|
||||
condition: WeatherKit.WeatherCondition? = nil,
|
||||
metadata: [String: String]? = nil) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.confidence = confidence
|
||||
self.createdAt = createdAt
|
||||
self.ttlSec = ttlSec
|
||||
self.condition = condition
|
||||
self.metadata = metadata
|
||||
}
|
||||
|
||||
func isExpired(at now: Int) -> Bool {
|
||||
if ttlSec > 0 {
|
||||
createdAt + ttlSec <= now
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import WeatherKit
|
||||
struct FeedEnvelope: Codable, Equatable {
|
||||
let schema: Int
|
||||
let generatedAt: Int
|
||||
let feed: [FeedCard]
|
||||
let feed: [FeedItem]
|
||||
let meta: FeedMeta
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -22,19 +22,20 @@ struct FeedEnvelope: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedCard: Codable, Equatable {
|
||||
struct FeedItem: Codable, Equatable {
|
||||
enum Bucket: String, Codable {
|
||||
case rightNow = "RIGHT_NOW"
|
||||
case fyi = "FYI"
|
||||
}
|
||||
|
||||
let id: String
|
||||
let type: WinnerType
|
||||
let type: FeedItemType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let priority: Double
|
||||
let ttlSec: Int
|
||||
let condition: WeatherKit.WeatherCondition?
|
||||
let startsAt: Int?
|
||||
let bucket: Bucket
|
||||
let actions: [String]
|
||||
|
||||
@@ -46,17 +47,19 @@ struct FeedCard: Codable, Equatable {
|
||||
case priority
|
||||
case ttlSec = "ttl_sec"
|
||||
case condition
|
||||
case startsAt = "starts_at"
|
||||
case bucket
|
||||
case actions
|
||||
}
|
||||
|
||||
init(id: String,
|
||||
type: WinnerType,
|
||||
type: FeedItemType,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
priority: Double,
|
||||
ttlSec: Int,
|
||||
condition: WeatherKit.WeatherCondition? = nil,
|
||||
startsAt: Int? = nil,
|
||||
bucket: Bucket,
|
||||
actions: [String]) {
|
||||
self.id = id
|
||||
@@ -66,6 +69,7 @@ struct FeedCard: Codable, Equatable {
|
||||
self.priority = priority
|
||||
self.ttlSec = ttlSec
|
||||
self.condition = condition
|
||||
self.startsAt = startsAt
|
||||
self.bucket = bucket
|
||||
self.actions = actions
|
||||
}
|
||||
@@ -73,13 +77,14 @@ struct FeedCard: Codable, Equatable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
type = try container.decode(WinnerType.self, forKey: .type)
|
||||
type = try container.decode(FeedItemType.self, forKey: .type)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
subtitle = try container.decode(String.self, forKey: .subtitle)
|
||||
priority = try container.decode(Double.self, forKey: .priority)
|
||||
ttlSec = try container.decode(Int.self, forKey: .ttlSec)
|
||||
bucket = try container.decode(Bucket.self, forKey: .bucket)
|
||||
actions = try container.decode([String].self, forKey: .actions)
|
||||
startsAt = try container.decodeIfPresent(Int.self, forKey: .startsAt)
|
||||
|
||||
if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) {
|
||||
condition = WeatherKit.WeatherCondition.irisDecode(encoded)
|
||||
@@ -98,6 +103,7 @@ struct FeedCard: Codable, Equatable {
|
||||
try container.encode(ttlSec, forKey: .ttlSec)
|
||||
try container.encode(bucket, forKey: .bucket)
|
||||
try container.encode(actions, forKey: .actions)
|
||||
try container.encodeIfPresent(startsAt, forKey: .startsAt)
|
||||
if let condition {
|
||||
try container.encode(condition.irisScreamingCase(), forKey: .condition)
|
||||
}
|
||||
@@ -114,50 +120,9 @@ struct FeedMeta: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedEnvelope {
|
||||
static func fromWinnerAndWeather(now: Int, winner: WinnerEnvelope, weather: Candidate?) -> FeedEnvelope {
|
||||
var cards: [FeedCard] = []
|
||||
|
||||
let winnerCard = FeedCard(
|
||||
id: winner.winner.id,
|
||||
type: winner.winner.type,
|
||||
title: winner.winner.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: winner.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: min(max(winner.winner.priority, 0.0), 1.0),
|
||||
ttlSec: max(1, winner.winner.ttlSec),
|
||||
condition: nil,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
cards.append(winnerCard)
|
||||
|
||||
if let weather, weather.id != winner.winner.id {
|
||||
let weatherCard = FeedCard(
|
||||
id: weather.id,
|
||||
type: weather.type,
|
||||
title: weather.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: weather.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: min(max(weather.confidence, 0.0), 1.0),
|
||||
ttlSec: max(1, weather.ttlSec),
|
||||
condition: weather.condition,
|
||||
bucket: .fyi,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
cards.append(weatherCard)
|
||||
}
|
||||
|
||||
return FeedEnvelope(
|
||||
schema: 1,
|
||||
generatedAt: now,
|
||||
feed: cards,
|
||||
meta: FeedMeta(winnerId: winner.winner.id, unreadCount: cards.count)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedEnvelope {
|
||||
static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope {
|
||||
let card = FeedCard(
|
||||
let item = FeedItem(
|
||||
id: "quiet-000",
|
||||
type: .allQuiet,
|
||||
title: "All Quiet",
|
||||
@@ -165,29 +130,14 @@ extension FeedEnvelope {
|
||||
priority: 0.05,
|
||||
ttlSec: 300,
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
return FeedEnvelope(schema: 1, generatedAt: now, feed: [card], meta: FeedMeta(winnerId: card.id, unreadCount: 1))
|
||||
return FeedEnvelope(schema: 1, generatedAt: now, feed: [item], meta: FeedMeta(winnerId: item.id, unreadCount: 1))
|
||||
}
|
||||
|
||||
func winnerCard() -> FeedCard? {
|
||||
func winnerItem() -> FeedItem? {
|
||||
feed.first(where: { $0.id == meta.winnerId }) ?? feed.first
|
||||
}
|
||||
|
||||
func asWinnerEnvelope() -> WinnerEnvelope {
|
||||
let now = generatedAt
|
||||
guard let winnerCard = winnerCard() else {
|
||||
return WinnerEnvelope.allQuiet(now: now)
|
||||
}
|
||||
let winner = Winner(
|
||||
id: winnerCard.id,
|
||||
type: winnerCard.type,
|
||||
title: winnerCard.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: winnerCard.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: min(max(winnerCard.priority, 0.0), 1.0),
|
||||
ttlSec: max(1, winnerCard.ttlSec)
|
||||
)
|
||||
return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
final class FeedStore {
|
||||
struct CardKey: Hashable, Codable {
|
||||
let id: String
|
||||
let type: WinnerType
|
||||
let type: FeedItemType
|
||||
}
|
||||
|
||||
struct CardState: Codable, Equatable {
|
||||
@@ -58,17 +58,17 @@ final class FeedStore {
|
||||
}
|
||||
}
|
||||
|
||||
func lastShownAt(candidateId: String) -> Int? {
|
||||
func lastShownAt(feedItemId: String) -> Int? {
|
||||
queue.sync {
|
||||
let matches = states.compactMap { (key, value) -> Int? in
|
||||
guard key.hasSuffix("|" + candidateId) else { return nil }
|
||||
guard key.hasSuffix("|" + feedItemId) else { return nil }
|
||||
return value.lastShownAt
|
||||
}
|
||||
return matches.max()
|
||||
}
|
||||
}
|
||||
|
||||
func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool {
|
||||
func isSuppressed(id: String, type: FeedItemType, now: Int) -> Bool {
|
||||
queue.sync {
|
||||
let key = Self.keyString(id: id, type: type)
|
||||
guard let state = states[key] else { return false }
|
||||
@@ -78,7 +78,7 @@ final class FeedStore {
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss(id: String, type: WinnerType, until: Int? = nil) {
|
||||
func dismiss(id: String, type: FeedItemType, until: Int? = nil) {
|
||||
queue.sync {
|
||||
let key = Self.keyString(id: id, type: type)
|
||||
var state = states[key] ?? CardState()
|
||||
@@ -88,7 +88,7 @@ final class FeedStore {
|
||||
}
|
||||
}
|
||||
|
||||
func snooze(id: String, type: WinnerType, until: Int) {
|
||||
func snooze(id: String, type: FeedItemType, until: Int) {
|
||||
queue.sync {
|
||||
let key = Self.keyString(id: id, type: type)
|
||||
var state = states[key] ?? CardState()
|
||||
@@ -98,7 +98,7 @@ final class FeedStore {
|
||||
}
|
||||
}
|
||||
|
||||
func clearSuppression(id: String, type: WinnerType) {
|
||||
func clearSuppression(id: String, type: FeedItemType) {
|
||||
queue.sync {
|
||||
let key = Self.keyString(id: id, type: type)
|
||||
var state = states[key] ?? CardState()
|
||||
@@ -119,11 +119,11 @@ final class FeedStore {
|
||||
}
|
||||
|
||||
private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope {
|
||||
let normalizedCards = feed.feed.compactMap { card -> FeedCard? in
|
||||
let normalizedCards = feed.feed.compactMap { card -> FeedItem? in
|
||||
let ttl = max(1, card.ttlSec)
|
||||
if feed.generatedAt + ttl <= now { return nil }
|
||||
if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil }
|
||||
return FeedCard(
|
||||
return FeedItem(
|
||||
id: card.id,
|
||||
type: card.type,
|
||||
title: card.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
@@ -131,6 +131,7 @@ final class FeedStore {
|
||||
priority: min(max(card.priority, 0.0), 1.0),
|
||||
ttlSec: ttl,
|
||||
condition: card.condition,
|
||||
startsAt: card.startsAt,
|
||||
bucket: card.bucket,
|
||||
actions: card.actions
|
||||
)
|
||||
@@ -146,7 +147,7 @@ final class FeedStore {
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func isSuppressedUnlocked(id: String, type: WinnerType, now: Int) -> Bool {
|
||||
private func isSuppressedUnlocked(id: String, type: FeedItemType, now: Int) -> Bool {
|
||||
let key = Self.keyString(id: id, type: type)
|
||||
guard let state = states[key] else { return false }
|
||||
if let until = state.dismissedUntil, until > now { return true }
|
||||
@@ -159,7 +160,7 @@ final class FeedStore {
|
||||
Self.save(persisted, to: fileURL)
|
||||
}
|
||||
|
||||
private static func keyString(id: String, type: WinnerType) -> String {
|
||||
private static func keyString(id: String, type: FeedItemType) -> String {
|
||||
"\(type.rawValue)|\(id)"
|
||||
}
|
||||
|
||||
|
||||
@@ -27,49 +27,65 @@ final class HeuristicRanker {
|
||||
self.lastShownAtProvider = lastShownAt
|
||||
}
|
||||
|
||||
func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner {
|
||||
struct Ranked: Equatable {
|
||||
let item: FeedItem
|
||||
let confidence: Double
|
||||
let isEligibleForRightNow: Bool
|
||||
}
|
||||
|
||||
struct WinnerSelection: Equatable {
|
||||
let item: FeedItem
|
||||
let priority: Double
|
||||
}
|
||||
|
||||
func pickWinner(from items: [Ranked], now: Int? = nil, context: UserContext) -> WinnerSelection? {
|
||||
let currentTime = now ?? nowProvider()
|
||||
|
||||
let valid = candidates
|
||||
.filter { !$0.isExpired(at: currentTime) }
|
||||
let valid = items
|
||||
.filter { $0.item.ttlSec > 0 }
|
||||
.filter { $0.confidence >= 0.0 }
|
||||
.filter { $0.isEligibleForRightNow }
|
||||
|
||||
guard !valid.isEmpty else {
|
||||
return WinnerEnvelope.allQuiet(now: currentTime).winner
|
||||
}
|
||||
guard !valid.isEmpty else { return nil }
|
||||
|
||||
var best: (candidate: Candidate, score: Double)?
|
||||
for candidate in valid {
|
||||
let baseWeight = baseWeight(for: candidate.type)
|
||||
var score = baseWeight * min(max(candidate.confidence, 0.0), 1.0)
|
||||
if let shownAt = lastShownAtProvider(candidate.id),
|
||||
var best: (item: FeedItem, score: Double, confidence: Double)?
|
||||
for proposed in valid {
|
||||
let baseWeight = baseWeight(for: proposed.item.type)
|
||||
var score = baseWeight * min(max(proposed.confidence, 0.0), 1.0)
|
||||
if let shownAt = lastShownAtProvider(proposed.item.id),
|
||||
currentTime - shownAt <= 2 * 60 * 60 {
|
||||
score -= 0.4
|
||||
}
|
||||
if best == nil || score > best!.score {
|
||||
best = (candidate, score)
|
||||
best = (proposed.item, score, proposed.confidence)
|
||||
}
|
||||
}
|
||||
|
||||
guard let best else {
|
||||
return WinnerEnvelope.allQuiet(now: currentTime).winner
|
||||
return nil
|
||||
}
|
||||
|
||||
let priority = min(max(best.score, 0.0), 1.0)
|
||||
return Winner(
|
||||
id: best.candidate.id,
|
||||
type: best.candidate.type,
|
||||
title: best.candidate.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: best.candidate.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
let item = FeedItem(
|
||||
id: best.item.id,
|
||||
type: best.item.type,
|
||||
title: best.item.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: best.item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: priority,
|
||||
ttlSec: max(1, best.candidate.ttlSec)
|
||||
ttlSec: max(1, best.item.ttlSec),
|
||||
condition: best.item.condition,
|
||||
startsAt: best.item.startsAt,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
return WinnerSelection(item: item, priority: priority)
|
||||
}
|
||||
|
||||
private func baseWeight(for type: WinnerType) -> Double {
|
||||
private func baseWeight(for type: FeedItemType) -> Double {
|
||||
switch type {
|
||||
case .weatherWarning: return 1.0
|
||||
case .weatherAlert: return 0.9
|
||||
case .calendarEvent: return 0.8
|
||||
case .transit: return 0.75
|
||||
case .poiNearby: return 0.6
|
||||
case .info: return 0.4
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum WinnerType: String, Codable, CaseIterable {
|
||||
enum FeedItemType: String, Codable, CaseIterable {
|
||||
case weatherAlert = "WEATHER_ALERT"
|
||||
case weatherWarning = "WEATHER_WARNING"
|
||||
case transit = "TRANSIT"
|
||||
@@ -15,23 +15,6 @@ enum WinnerType: String, Codable, CaseIterable {
|
||||
case info = "INFO"
|
||||
case nowPlaying = "NOW_PLAYING"
|
||||
case currentWeather = "CURRENT_WEATHER"
|
||||
case calendarEvent = "CALENDAR_EVENT"
|
||||
case allQuiet = "ALL_QUIET"
|
||||
}
|
||||
|
||||
struct Winner: Codable, Equatable {
|
||||
let id: String
|
||||
let type: WinnerType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let priority: Double
|
||||
let ttlSec: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case subtitle
|
||||
case priority
|
||||
case ttlSec = "ttl_sec"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,82 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct WinnerEnvelope: Codable, Equatable {
|
||||
struct DebugInfo: Codable, Equatable {
|
||||
let reason: String
|
||||
let source: String
|
||||
}
|
||||
|
||||
let schema: Int
|
||||
let generatedAt: Int
|
||||
let winner: Winner
|
||||
let debug: DebugInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schema
|
||||
case generatedAt = "generated_at"
|
||||
case winner
|
||||
case debug
|
||||
}
|
||||
|
||||
static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> WinnerEnvelope {
|
||||
let winner = Winner(
|
||||
id: "quiet-000",
|
||||
type: .allQuiet,
|
||||
title: "All Quiet",
|
||||
subtitle: "No urgent updates",
|
||||
priority: 0.05,
|
||||
ttlSec: 300
|
||||
)
|
||||
return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: .init(reason: reason, source: source))
|
||||
}
|
||||
}
|
||||
|
||||
enum EnvelopeValidationError: Error, LocalizedError {
|
||||
case invalidSchema(Int)
|
||||
case invalidPriority(Double)
|
||||
case invalidTTL(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidSchema(let schema):
|
||||
return "Invalid schema \(schema). Expected 1."
|
||||
case .invalidPriority(let priority):
|
||||
return "Invalid priority \(priority). Must be between 0 and 1."
|
||||
case .invalidTTL(let ttl):
|
||||
return "Invalid ttl \(ttl). Must be greater than 0."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateEnvelope(_ envelope: WinnerEnvelope) throws -> WinnerEnvelope {
|
||||
guard envelope.schema == 1 else {
|
||||
throw EnvelopeValidationError.invalidSchema(envelope.schema)
|
||||
}
|
||||
guard envelope.winner.priority >= 0.0, envelope.winner.priority <= 1.0 else {
|
||||
throw EnvelopeValidationError.invalidPriority(envelope.winner.priority)
|
||||
}
|
||||
guard envelope.winner.ttlSec > 0 else {
|
||||
throw EnvelopeValidationError.invalidTTL(envelope.winner.ttlSec)
|
||||
}
|
||||
|
||||
let validatedWinner = Winner(
|
||||
id: envelope.winner.id,
|
||||
type: envelope.winner.type,
|
||||
title: envelope.winner.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: envelope.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: envelope.winner.priority,
|
||||
ttlSec: envelope.winner.ttlSec
|
||||
)
|
||||
|
||||
return WinnerEnvelope(
|
||||
schema: envelope.schema,
|
||||
generatedAt: envelope.generatedAt,
|
||||
winner: validatedWinner,
|
||||
debug: envelope.debug
|
||||
)
|
||||
}
|
||||
|
||||
enum TextConstraints {
|
||||
static let titleMax = 26
|
||||
static let subtitleMax = 30
|
||||
@@ -97,4 +21,3 @@ extension String {
|
||||
return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ final class LocalServer: ObservableObject {
|
||||
private var listener: NWListener?
|
||||
private var browser: NWBrowser?
|
||||
private var startDate = Date()
|
||||
private var currentEnvelope: WinnerEnvelope
|
||||
private var currentEnvelope: FeedEnvelope
|
||||
private var heartbeatTimer: DispatchSourceTimer?
|
||||
private var addressTimer: DispatchSourceTimer?
|
||||
private var requestBuffers: [ObjectIdentifier: Data] = [:]
|
||||
@@ -32,20 +32,7 @@ final class LocalServer: ObservableObject {
|
||||
|
||||
init(port: Int = 8765) {
|
||||
self.port = port
|
||||
let winner = Winner(
|
||||
id: "quiet-000",
|
||||
type: .allQuiet,
|
||||
title: "All Quiet",
|
||||
subtitle: "No urgent updates",
|
||||
priority: 0.05,
|
||||
ttlSec: 300
|
||||
)
|
||||
self.currentEnvelope = WinnerEnvelope(
|
||||
schema: 1,
|
||||
generatedAt: Int(Date().timeIntervalSince1970),
|
||||
winner: winner,
|
||||
debug: nil
|
||||
)
|
||||
self.currentEnvelope = FeedEnvelope.allQuiet(now: Int(Date().timeIntervalSince1970), reason: "no_feed", source: "server")
|
||||
}
|
||||
|
||||
var testURL: String {
|
||||
@@ -97,15 +84,15 @@ final class LocalServer: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastWinner(_ envelope: WinnerEnvelope) {
|
||||
let validated = (try? validateEnvelope(envelope)) ?? envelope
|
||||
currentEnvelope = validated
|
||||
func broadcastFeed(_ envelope: FeedEnvelope) {
|
||||
currentEnvelope = envelope
|
||||
let winner = envelope.winnerItem()
|
||||
DispatchQueue.main.async {
|
||||
self.lastWinnerTitle = validated.winner.title
|
||||
self.lastWinnerSubtitle = validated.winner.subtitle
|
||||
self.lastWinnerTitle = winner?.title ?? "All Quiet"
|
||||
self.lastWinnerSubtitle = winner?.subtitle ?? "No urgent updates"
|
||||
self.lastBroadcastAt = Date()
|
||||
}
|
||||
let data = sseEvent(name: "winner", payload: jsonLine(from: validated))
|
||||
let data = sseEvent(name: "feed", payload: jsonLine(from: envelope))
|
||||
broadcast(data: data)
|
||||
}
|
||||
|
||||
@@ -312,7 +299,7 @@ final class LocalServer: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func jsonLine(from envelope: WinnerEnvelope) -> String {
|
||||
private func jsonLine(from envelope: FeedEnvelope) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
if let data = try? encoder.encode(envelope),
|
||||
let string = String(data: data, encoding: .utf8) {
|
||||
|
||||
@@ -18,10 +18,10 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
@Published private(set) var lastLocation: CLLocation? = nil
|
||||
@Published private(set) var lastRecomputeAt: Date? = nil
|
||||
@Published private(set) var lastRecomputeReason: String? = nil
|
||||
@Published private(set) var lastWinner: WinnerEnvelope? = nil
|
||||
@Published private(set) var lastFeed: FeedEnvelope? = nil
|
||||
@Published private(set) var lastError: String? = nil
|
||||
@Published private(set) var lastCandidates: [Candidate] = []
|
||||
@Published private(set) var lastWeatherDiagnostics: [String: String] = [:]
|
||||
@Published private(set) var lastCalendarDiagnostics: [String: String] = [:]
|
||||
@Published private(set) var lastPipelineElapsedMs: Int? = nil
|
||||
@Published private(set) var lastFetchFailed: Bool = false
|
||||
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
|
||||
@@ -50,7 +50,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
self.store = store
|
||||
self.server = server
|
||||
self.ble = ble
|
||||
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(candidateId: id) })
|
||||
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) })
|
||||
super.init()
|
||||
|
||||
locationManager.delegate = self
|
||||
@@ -61,8 +61,8 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
|
||||
ble.onFirstSubscribe = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.logger.info("BLE subscribed: pushing latest winner")
|
||||
self?.pushLatestWinnerToBle()
|
||||
self?.logger.info("BLE subscribed: pushing latest feed")
|
||||
self?.pushLatestFeedToBle()
|
||||
}
|
||||
}
|
||||
ble.onControlCommand = { [weak self] command in
|
||||
@@ -76,12 +76,12 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
guard let self else { return }
|
||||
self.musicAuthorization = update.authorization
|
||||
self.nowPlaying = update.snapshot
|
||||
self.pushLatestWinnerToBle()
|
||||
self.pushLatestFeedToBle()
|
||||
}
|
||||
}
|
||||
|
||||
let feed = store.getFeed()
|
||||
lastWinner = feed.asWinnerEnvelope()
|
||||
lastFeed = feed
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -191,98 +191,246 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
|
||||
let start = Date()
|
||||
async let weatherResult = withTimeoutResult(seconds: 6) {
|
||||
await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch)
|
||||
try await self.weatherDataSource.dataWithDiagnostics(for: location, now: nowEpoch)
|
||||
}
|
||||
async let calendarResult = withTimeoutResult(seconds: 6) {
|
||||
await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch)
|
||||
try await self.calendarDataSource.dataWithDiagnostics(now: nowEpoch)
|
||||
}
|
||||
async let poiResult = withTimeoutResult(seconds: 6) {
|
||||
try await self.poiDataSource.candidates(for: location, now: nowEpoch)
|
||||
try await self.poiDataSource.data(for: location, now: nowEpoch)
|
||||
}
|
||||
|
||||
let wxRes = await weatherResult
|
||||
let calRes = await calendarResult
|
||||
let poiRes = await poiResult
|
||||
|
||||
var candidates: [Candidate] = []
|
||||
func calendarTTL(endAt: Int, now: Int) -> Int {
|
||||
let ttl = endAt - now
|
||||
return min(max(ttl, 60), 2 * 60 * 60)
|
||||
}
|
||||
|
||||
func rainTitle(startAt: Int, now: Int) -> String {
|
||||
let minutes = max(0, Int(((TimeInterval(startAt - now)) / 60.0).rounded()))
|
||||
if minutes <= 0 { return "Rain now" }
|
||||
return "Rain in ~\(minutes) min"
|
||||
}
|
||||
|
||||
var rightNowCandidates: [HeuristicRanker.Ranked] = []
|
||||
var calendarItems: [FeedItem] = []
|
||||
var weatherNowItem: FeedItem? = nil
|
||||
var fetchFailed = false
|
||||
var wxDiagnostics: [String: String] = [:]
|
||||
var weatherNowCandidate: Candidate? = nil
|
||||
var calDiagnostics: [String: String] = [:]
|
||||
|
||||
switch wxRes {
|
||||
case .success(let wx):
|
||||
candidates.append(contentsOf: wx.candidates)
|
||||
wxDiagnostics = wx.diagnostics
|
||||
weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") })
|
||||
if let wxErr = wx.weatherKitError {
|
||||
case .success(let snapshot):
|
||||
wxDiagnostics = snapshot.diagnostics
|
||||
let weather = snapshot.data
|
||||
|
||||
if let current = weather.current {
|
||||
weatherNowItem = FeedItem(
|
||||
id: "wx:now:\(nowEpoch / 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 = weather.rainSoon {
|
||||
let title = rainTitle(startAt: rainSoon.startAt, now: nowEpoch).truncated(maxLength: TextConstraints.titleMax)
|
||||
let subtitle = (rainSoon.source == .minutely ? "Carry an umbrella" : "Rain likely soon")
|
||||
.truncated(maxLength: TextConstraints.subtitleMax)
|
||||
let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6
|
||||
|
||||
let item = FeedItem(
|
||||
id: "wx:rain:\(rainSoon.startAt)",
|
||||
type: .weatherAlert,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
priority: confidence,
|
||||
ttlSec: max(1, rainSoon.ttlSec),
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||
}
|
||||
|
||||
if let wind = weather.windAlert {
|
||||
let mph = Int((wind.gustMps * 2.236936).rounded())
|
||||
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
|
||||
let subtitle = "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax)
|
||||
let confidence: Double = 0.8
|
||||
|
||||
let item = FeedItem(
|
||||
id: "wx:wind:\(nowEpoch):\(Int(wind.thresholdMps * 10))",
|
||||
type: .weatherAlert,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
priority: confidence,
|
||||
ttlSec: max(1, wind.ttlSec),
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||
}
|
||||
|
||||
for warning in weather.warnings {
|
||||
let confidence = min(max(warning.confidence, 0.0), 1.0)
|
||||
let item = FeedItem(
|
||||
id: warning.id,
|
||||
type: .weatherWarning,
|
||||
title: warning.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: confidence,
|
||||
ttlSec: max(1, warning.ttlSec),
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .rightNow,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true))
|
||||
}
|
||||
|
||||
if let wxErr = wxDiagnostics["weatherkit_error"] {
|
||||
fetchFailed = true
|
||||
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
|
||||
}
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
|
||||
if let weatherError = error as? WeatherDataSource.WeatherError,
|
||||
case .weatherKitFailed(_, let diagnostics) = weatherError {
|
||||
wxDiagnostics = diagnostics
|
||||
logger.warning("weather fetch error: \(weatherError.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
switch poiRes {
|
||||
case .success(let pois):
|
||||
candidates.append(contentsOf: pois)
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
switch calRes {
|
||||
case .success(let cal):
|
||||
candidates.append(contentsOf: cal.candidates)
|
||||
if let err = cal.error {
|
||||
fetchFailed = true
|
||||
logger.warning("calendar error: \(err, privacy: .public)")
|
||||
case .success(let snapshot):
|
||||
calDiagnostics = snapshot.diagnostics
|
||||
for event in snapshot.data.events {
|
||||
let isOngoing = event.startAt <= nowEpoch && event.endAt > nowEpoch
|
||||
let startsInSec = event.startAt - nowEpoch
|
||||
let eligibleForRightNow = isOngoing || startsInSec <= 30 * 60
|
||||
let confidence: Double = isOngoing ? 0.9 : 0.7
|
||||
|
||||
let item = FeedItem(
|
||||
id: event.id,
|
||||
type: .calendarEvent,
|
||||
title: event.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: "",
|
||||
priority: confidence,
|
||||
ttlSec: calendarTTL(endAt: event.endAt, now: nowEpoch),
|
||||
condition: nil,
|
||||
startsAt: event.startAt,
|
||||
bucket: .fyi,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
calendarItems.append(item)
|
||||
rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: eligibleForRightNow))
|
||||
}
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
|
||||
if let calendarError = error as? CalendarDataSource.CalendarError {
|
||||
calDiagnostics = calendarError.diagnostics
|
||||
logger.warning("calendar error: \(calendarError.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
lastPipelineElapsedMs = elapsedMs
|
||||
lastFetchFailed = fetchFailed
|
||||
lastCandidates = candidates
|
||||
lastWeatherDiagnostics = wxDiagnostics
|
||||
lastCalendarDiagnostics = calDiagnostics
|
||||
|
||||
logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||
logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||
|
||||
if fetchFailed, candidates.isEmpty {
|
||||
if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil {
|
||||
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||||
let fallbackWinner = fallbackFeed.asWinnerEnvelope()
|
||||
lastWinner = fallbackWinner
|
||||
lastError = "Fetch failed; using previous winner."
|
||||
server.broadcastWinner(fallbackWinner)
|
||||
lastFeed = fallbackFeed
|
||||
lastError = "Fetch failed; using previous feed."
|
||||
server.broadcastFeed(fallbackFeed)
|
||||
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
|
||||
return
|
||||
}
|
||||
|
||||
let unsuppressed = candidates
|
||||
.filter { $0.type != .currentWeather }
|
||||
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||
let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext)
|
||||
let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil)
|
||||
let validated = (try? validateEnvelope(envelope)) ?? envelope
|
||||
let eligibleUnsuppressed = rightNowCandidates.filter { ranked in
|
||||
!store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
|
||||
}
|
||||
|
||||
let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate)
|
||||
let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext)
|
||||
let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0]
|
||||
|
||||
let fyiCalendar = calendarItems
|
||||
.filter { $0.id != winnerItem.id }
|
||||
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||
.sorted(by: { ($0.startsAt ?? Int.max) < ($1.startsAt ?? Int.max) })
|
||||
.prefix(3)
|
||||
|
||||
var fyi: [FeedItem] = []
|
||||
fyi.append(contentsOf: fyiCalendar.map { item in
|
||||
FeedItem(
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: min(max(item.priority, 0.0), 1.0),
|
||||
ttlSec: max(1, item.ttlSec),
|
||||
condition: item.condition,
|
||||
startsAt: item.startsAt,
|
||||
bucket: .fyi,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
})
|
||||
|
||||
if let weatherNowItem,
|
||||
weatherNowItem.id != winnerItem.id,
|
||||
!store.isSuppressed(id: weatherNowItem.id, type: weatherNowItem.type, now: nowEpoch) {
|
||||
fyi.append(weatherNowItem)
|
||||
}
|
||||
|
||||
let items = [winnerItem] + fyi
|
||||
let feedEnvelope = FeedEnvelope(
|
||||
schema: 1,
|
||||
generatedAt: nowEpoch,
|
||||
feed: items,
|
||||
meta: FeedMeta(winnerId: winnerItem.id, unreadCount: items.count)
|
||||
)
|
||||
store.setFeed(feedEnvelope, now: nowEpoch)
|
||||
lastWinner = validated
|
||||
lastFeed = feedEnvelope
|
||||
lastRecomputeAt = Date()
|
||||
lastRecomputeLocation = location
|
||||
lastRecomputeAccuracy = location.horizontalAccuracy
|
||||
lastError = fetchFailed ? "Partial fetch failure." : nil
|
||||
|
||||
logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)")
|
||||
logger.info("winner id=\(winnerItem.id, privacy: .public) type=\(winnerItem.type.rawValue, privacy: .public) prio=\(winnerItem.priority, format: .fixed(precision: 2)) ttl=\(winnerItem.ttlSec)")
|
||||
|
||||
server.broadcastWinner(validated)
|
||||
server.broadcastFeed(feedEnvelope)
|
||||
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
|
||||
}
|
||||
|
||||
private func pushLatestWinnerToBle() {
|
||||
private func pushLatestFeedToBle() {
|
||||
let nowEpoch = Int(Date().timeIntervalSince1970)
|
||||
let feed = store.getFeed(now: nowEpoch)
|
||||
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1)
|
||||
@@ -292,7 +440,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
guard !command.isEmpty else { return }
|
||||
if command == "REQ_FULL" {
|
||||
logger.info("BLE control REQ_FULL")
|
||||
pushLatestWinnerToBle()
|
||||
pushLatestFeedToBle()
|
||||
return
|
||||
}
|
||||
if command.hasPrefix("ACK:") {
|
||||
@@ -303,7 +451,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
|
||||
guard let nowPlayingCard = nowPlaying?.asFeedCard(baseGeneratedAt: base.generatedAt, now: now) else {
|
||||
guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else {
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -376,7 +524,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
|
||||
let album: String?
|
||||
let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus
|
||||
|
||||
func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard {
|
||||
func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem {
|
||||
let desiredLifetimeSec = 30
|
||||
let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec)
|
||||
|
||||
@@ -385,7 +533,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
|
||||
.filter { !$0.isEmpty }
|
||||
let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: " • ")
|
||||
|
||||
return FeedCard(
|
||||
return FeedItem(
|
||||
id: "music:now:\(itemId)",
|
||||
type: .nowPlaying,
|
||||
title: title.truncated(maxLength: TextConstraints.titleMax),
|
||||
@@ -393,6 +541,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
|
||||
priority: playbackStatus == .playing ? 0.35 : 0.2,
|
||||
ttlSec: ttl,
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .fyi,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":3}}
|
||||
{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"cal:demo:1767717000","type":"CALENDAR_EVENT","title":"Team Sync","subtitle":"","priority":0.7,"ttl_sec":5400,"starts_at":1767717000,"bucket":"FYI","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":4}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -75,7 +75,7 @@ struct CandidatesView: View {
|
||||
}
|
||||
|
||||
private struct CandidateRow: View {
|
||||
let candidate: Candidate
|
||||
let candidate: FeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -88,29 +88,22 @@ private struct CandidateRow: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(candidate.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if !candidate.subtitle.isEmpty {
|
||||
Text(candidate.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "conf %.2f", candidate.confidence))
|
||||
Text(String(format: "prio %.2f", candidate.priority))
|
||||
Text("ttl \(candidate.ttlSec)s")
|
||||
Text(expiresText(now: Int(Date().timeIntervalSince1970)))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func expiresText(now: Int) -> String {
|
||||
let expiresAt = candidate.createdAt + candidate.ttlSec
|
||||
let remaining = expiresAt - now
|
||||
if remaining <= 0 { return "expired" }
|
||||
if remaining < 60 { return "in \(remaining)s" }
|
||||
return "in \(remaining / 60)m"
|
||||
}
|
||||
}
|
||||
|
||||
struct CandidatesView_Previews: PreviewProvider {
|
||||
|
||||
@@ -45,18 +45,48 @@ struct OrchestratorView: View {
|
||||
Button("Recompute Now") { orchestrator.recomputeNow() }
|
||||
}
|
||||
|
||||
Section("Winner") {
|
||||
if let env = orchestrator.lastWinner {
|
||||
Text(env.winner.title)
|
||||
Section("Feed") {
|
||||
if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
|
||||
Text(winner.title)
|
||||
.font(.headline)
|
||||
Text(env.winner.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s")
|
||||
if !winner.subtitle.isEmpty {
|
||||
Text(winner.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("type \(winner.type.rawValue) • prio \(String(format: "%.2f", winner.priority)) • ttl \(winner.ttlSec)s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if feed.feed.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
|
||||
ForEach(feed.feed, id: \.id) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(item.type.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !item.subtitle.isEmpty {
|
||||
Text(item.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text("bucket \(item.bucket.rawValue) • prio \(String(format: "%.2f", item.priority)) • ttl \(item.ttlSec)s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} else {
|
||||
Text("No winner yet")
|
||||
Text("No feed yet")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -80,35 +110,6 @@ struct OrchestratorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Candidates (\(orchestrator.lastCandidates.count))") {
|
||||
if orchestrator.lastCandidates.isEmpty {
|
||||
Text("No candidates")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(orchestrator.lastCandidates, id: \.id) { c in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(c.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(c.type.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(c.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("conf \(String(format: "%.2f", c.confidence)) • ttl \(c.ttlSec)s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !orchestrator.lastWeatherDiagnostics.isEmpty {
|
||||
Section("Weather Diagnostics") {
|
||||
ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in
|
||||
@@ -122,6 +123,19 @@ struct OrchestratorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !orchestrator.lastCalendarDiagnostics.isEmpty {
|
||||
Section("Calendar Diagnostics") {
|
||||
ForEach(orchestrator.lastCalendarDiagnostics.keys.sorted(), id: \.self) { key in
|
||||
LabeledContent(key) {
|
||||
Text(orchestrator.lastCalendarDiagnostics[key] ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Test") {
|
||||
Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user