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

@@ -19,12 +19,42 @@ struct CalendarDataSourceConfig: Sendable {
} }
final class CalendarDataSource { final class CalendarDataSource {
struct CandidatesResult: Sendable { struct Event: Sendable, Equatable {
let candidates: [Candidate] let id: String
let error: 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] 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 store: EKEventStore
private let config: CalendarDataSourceConfig private let config: CalendarDataSourceConfig
@@ -33,7 +63,7 @@ final class CalendarDataSource {
self.config = config self.config = config
} }
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult { func dataWithDiagnostics(now: Int) async throws -> Snapshot {
var diagnostics: [String: String] = [ var diagnostics: [String: String] = [
"now": String(now), "now": String(now),
"lookahead_sec": String(config.lookaheadSec), "lookahead_sec": String(config.lookaheadSec),
@@ -50,7 +80,7 @@ final class CalendarDataSource {
diagnostics["access_granted"] = accessGranted ? "true" : "false" diagnostics["access_granted"] = accessGranted ? "true" : "false"
guard accessGranted else { 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) let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
@@ -64,10 +94,10 @@ final class CalendarDataSource {
diagnostics["events_filtered"] = String(filtered.count) diagnostics["events_filtered"] = String(filtered.count)
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate) let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
diagnostics["candidates"] = String(candidates.count) 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 { private func shouldInclude(event: EKEvent) -> Bool {
@@ -84,8 +114,8 @@ final class CalendarDataSource {
return true return true
} }
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] { private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
var results: [Candidate] = [] var results: [Event] = []
results.reserveCapacity(min(config.maxCandidates, events.count)) results.reserveCapacity(min(config.maxCandidates, events.count))
for event in events { for event in events {
@@ -99,34 +129,16 @@ final class CalendarDataSource {
continue 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 id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
results.append( results.append(
Candidate( Event(
id: id, id: id,
type: .info, title: title.isEmpty ? "Event" : title,
title: title, startAt: Int(start.timeIntervalSince1970),
subtitle: subtitle, endAt: Int(end.timeIntervalSince1970),
confidence: confidence, isAllDay: event.isAllDay,
createdAt: now, location: event.location
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 ?? "",
]
) )
) )
} }
@@ -134,31 +146,6 @@ final class CalendarDataSource {
return results 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 { private func calendarAuthorizationStatusString() -> String {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
return String(describing: EKEventStore.authorizationStatus(for: .event)) return String(describing: EKEventStore.authorizationStatus(for: .event))
@@ -169,13 +156,18 @@ final class CalendarDataSource {
private func ensureCalendarAccess() async -> Bool { private func ensureCalendarAccess() async -> Bool {
let status = EKEventStore.authorizationStatus(for: .event) let status = EKEventStore.authorizationStatus(for: .event)
if #available(iOS 17.0, *) {
if status == .fullAccess { return true }
if status == .writeOnly { return false }
}
switch status { switch status {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined: case .notDetermined:
return await requestAccess() return await requestAccess()
case .denied, .restricted:
return false
case .authorized:
return true
@unknown default: @unknown default:
return false return false
} }

View File

@@ -15,13 +15,18 @@ struct POIDataSourceConfig: Sendable {
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs. /// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
final class POIDataSource { final class POIDataSource {
struct POI: Sendable, Equatable {
let id: String
let name: String
}
private let config: POIDataSourceConfig private let config: POIDataSourceConfig
init(config: POIDataSourceConfig = .init()) { init(config: POIDataSourceConfig = .init()) {
self.config = config 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. // Phase 1 stub: return nothing.
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.) // (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
_ = config _ = config
@@ -30,4 +35,3 @@ final class POIDataSource {
return [] return []
} }
} }

View File

@@ -20,28 +20,12 @@ struct WeatherAlertConfig: Sendable {
init() {} init() {}
} }
protocol WeatherWarningProviding: Sendable { struct WeatherWarning: Codable, Equatable, Sendable {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate]
}
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 id: String
let title: String let title: String
let subtitle: String let subtitle: String
let ttlSec: Int? let ttlSec: Int
let confidence: Double? let confidence: Double
let meta: [String: String]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@@ -49,66 +33,70 @@ struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable {
case subtitle case subtitle
case ttlSec = "ttl_sec" case ttlSec = "ttl_sec"
case confidence 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
)
}
} }
} }
@available(iOS 16.0, *) @available(iOS 16.0, *)
final class WeatherDataSource { 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 service: WeatherService
private let config: WeatherAlertConfig private let config: WeatherAlertConfig
private let warningProvider: WeatherWarningProviding
init(service: WeatherService = .shared, init(service: WeatherService = .shared,
config: WeatherAlertConfig = .init(), config: WeatherAlertConfig = .init()) {
warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider(
url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json")
)) {
self.service = service self.service = service
self.config = config self.config = config
self.warningProvider = warningProvider
} }
/// Returns alert candidates derived from WeatherKit forecasts. struct Snapshot: Sendable {
func candidates(for location: CLLocation, now: Int) async -> [Candidate] { let data: WeatherData
let result = await candidatesWithDiagnostics(for: location, now: now)
return result.candidates
}
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let weatherKitError: String?
let diagnostics: [String: String] let diagnostics: [String: String]
} }
func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult { enum WeatherError: Error, LocalizedError, Sendable {
var results: [Candidate] = [] case weatherKitFailed(message: String, diagnostics: [String: String])
var errorString: String? = nil
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] = [ var diagnostics: [String: String] = [
"lat": String(format: "%.5f", location.coordinate.latitude), "lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude), "lon": String(format: "%.5f", location.coordinate.longitude),
@@ -120,7 +108,7 @@ final class WeatherDataSource {
do { do {
let weather = try await service.weather(for: location) 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["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil"
diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count) diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count)
if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value { if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value {
@@ -129,122 +117,100 @@ final class WeatherDataSource {
diagnostics["current_gust_mps"] = "nil" diagnostics["current_gust_mps"] = "nil"
} }
results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now)) rainSoon = rainSoonAlert(weather: weather, now: now)
results.append(contentsOf: windCandidates(weather: weather, location: location, now: now)) windAlert = windAlertInfo(weather: weather)
diagnostics["candidates_weatherkit"] = String(results.count)
diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new } diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new }
} catch { } catch {
errorString = String(describing: error) let msg = String(describing: error)
diagnostics["weatherkit_error"] = errorString diagnostics["weatherkit_error"] = msg
throw WeatherError.weatherKitFailed(message: msg, diagnostics: diagnostics)
} }
results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now)) let warnings = warnings(now: now)
diagnostics["candidates_total"] = String(results.count) diagnostics["warnings_count"] = String(warnings.count)
return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics)
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 tempC = weather.currentWeather.temperature.converted(to: .celsius).value
let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value
let tempInt = Int(tempC.rounded()) let tempInt = Int(tempC.rounded())
let feelsInt = Int(feelsC.rounded()) let feelsInt = Int(feelsC.rounded())
let cond = weather.currentWeather.condition.description
let conditionEnum = weather.currentWeather.condition let conditionEnum = weather.currentWeather.condition
return WeatherData.Current(temperatureC: tempInt, feelsLikeC: feelsInt, condition: conditionEnum)
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),
]
)
} }
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 nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec) let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast { if let minuteForecast = weather.minuteForecast {
if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) { if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) {
return [ return WeatherData.RainSoon(
Candidate( startAt: Int(start.timeIntervalSince1970),
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, ttlSec: config.rainTTL,
metadata: [ source: .minutely
"source": "weatherkit_minutely",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
) )
]
} }
return [] return nil
} }
if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) { if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) {
return [ return WeatherData.RainSoon(
Candidate( startAt: Int(start.timeIntervalSince1970),
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, ttlSec: config.rainTTL,
metadata: [ source: .hourlyApprox
"source": "weatherkit_hourly_approx",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
) )
]
} }
return [] return nil
} }
private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] { private func windAlertInfo(weather: Weather) -> WeatherData.WindAlert? {
guard let gustThreshold = config.gustThresholdMps else { return [] } guard let gustThreshold = config.gustThresholdMps else { return nil }
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] } guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return nil }
guard gust >= gustThreshold else { return [] } guard gust >= gustThreshold else { return nil }
return WeatherData.WindAlert(gustMps: gust, thresholdMps: gustThreshold, ttlSec: config.windTTL)
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 firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? { private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? {

View File

@@ -2,60 +2,7 @@
// Candidate.swift // Candidate.swift
// iris // iris
// //
// Created by Codex. // Deprecated: data sources now return typed data and the orchestrator produces `FeedItem`s.
// //
import Foundation 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
}
}
}

View File

@@ -11,7 +11,7 @@ import WeatherKit
struct FeedEnvelope: Codable, Equatable { struct FeedEnvelope: Codable, Equatable {
let schema: Int let schema: Int
let generatedAt: Int let generatedAt: Int
let feed: [FeedCard] let feed: [FeedItem]
let meta: FeedMeta let meta: FeedMeta
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -22,19 +22,20 @@ struct FeedEnvelope: Codable, Equatable {
} }
} }
struct FeedCard: Codable, Equatable { struct FeedItem: Codable, Equatable {
enum Bucket: String, Codable { enum Bucket: String, Codable {
case rightNow = "RIGHT_NOW" case rightNow = "RIGHT_NOW"
case fyi = "FYI" case fyi = "FYI"
} }
let id: String let id: String
let type: WinnerType let type: FeedItemType
let title: String let title: String
let subtitle: String let subtitle: String
let priority: Double let priority: Double
let ttlSec: Int let ttlSec: Int
let condition: WeatherKit.WeatherCondition? let condition: WeatherKit.WeatherCondition?
let startsAt: Int?
let bucket: Bucket let bucket: Bucket
let actions: [String] let actions: [String]
@@ -46,17 +47,19 @@ struct FeedCard: Codable, Equatable {
case priority case priority
case ttlSec = "ttl_sec" case ttlSec = "ttl_sec"
case condition case condition
case startsAt = "starts_at"
case bucket case bucket
case actions case actions
} }
init(id: String, init(id: String,
type: WinnerType, type: FeedItemType,
title: String, title: String,
subtitle: String, subtitle: String,
priority: Double, priority: Double,
ttlSec: Int, ttlSec: Int,
condition: WeatherKit.WeatherCondition? = nil, condition: WeatherKit.WeatherCondition? = nil,
startsAt: Int? = nil,
bucket: Bucket, bucket: Bucket,
actions: [String]) { actions: [String]) {
self.id = id self.id = id
@@ -66,6 +69,7 @@ struct FeedCard: Codable, Equatable {
self.priority = priority self.priority = priority
self.ttlSec = ttlSec self.ttlSec = ttlSec
self.condition = condition self.condition = condition
self.startsAt = startsAt
self.bucket = bucket self.bucket = bucket
self.actions = actions self.actions = actions
} }
@@ -73,13 +77,14 @@ struct FeedCard: Codable, Equatable {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id) 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) title = try container.decode(String.self, forKey: .title)
subtitle = try container.decode(String.self, forKey: .subtitle) subtitle = try container.decode(String.self, forKey: .subtitle)
priority = try container.decode(Double.self, forKey: .priority) priority = try container.decode(Double.self, forKey: .priority)
ttlSec = try container.decode(Int.self, forKey: .ttlSec) ttlSec = try container.decode(Int.self, forKey: .ttlSec)
bucket = try container.decode(Bucket.self, forKey: .bucket) bucket = try container.decode(Bucket.self, forKey: .bucket)
actions = try container.decode([String].self, forKey: .actions) 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) { if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) {
condition = WeatherKit.WeatherCondition.irisDecode(encoded) condition = WeatherKit.WeatherCondition.irisDecode(encoded)
@@ -98,6 +103,7 @@ struct FeedCard: Codable, Equatable {
try container.encode(ttlSec, forKey: .ttlSec) try container.encode(ttlSec, forKey: .ttlSec)
try container.encode(bucket, forKey: .bucket) try container.encode(bucket, forKey: .bucket)
try container.encode(actions, forKey: .actions) try container.encode(actions, forKey: .actions)
try container.encodeIfPresent(startsAt, forKey: .startsAt)
if let condition { if let condition {
try container.encode(condition.irisScreamingCase(), forKey: .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 { extension FeedEnvelope {
static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope { static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope {
let card = FeedCard( let item = FeedItem(
id: "quiet-000", id: "quiet-000",
type: .allQuiet, type: .allQuiet,
title: "All Quiet", title: "All Quiet",
@@ -165,29 +130,14 @@ extension FeedEnvelope {
priority: 0.05, priority: 0.05,
ttlSec: 300, ttlSec: 300,
condition: nil, condition: nil,
startsAt: nil,
bucket: .rightNow, bucket: .rightNow,
actions: ["DISMISS"] 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 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)
}
} }

View File

@@ -10,7 +10,7 @@ import Foundation
final class FeedStore { final class FeedStore {
struct CardKey: Hashable, Codable { struct CardKey: Hashable, Codable {
let id: String let id: String
let type: WinnerType let type: FeedItemType
} }
struct CardState: Codable, Equatable { struct CardState: Codable, Equatable {
@@ -58,17 +58,17 @@ final class FeedStore {
} }
} }
func lastShownAt(candidateId: String) -> Int? { func lastShownAt(feedItemId: String) -> Int? {
queue.sync { queue.sync {
let matches = states.compactMap { (key, value) -> Int? in 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 value.lastShownAt
} }
return matches.max() return matches.max()
} }
} }
func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool { func isSuppressed(id: String, type: FeedItemType, now: Int) -> Bool {
queue.sync { queue.sync {
let key = Self.keyString(id: id, type: type) let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false } 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 { queue.sync {
let key = Self.keyString(id: id, type: type) let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState() 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 { queue.sync {
let key = Self.keyString(id: id, type: type) let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState() 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 { queue.sync {
let key = Self.keyString(id: id, type: type) let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState() var state = states[key] ?? CardState()
@@ -119,11 +119,11 @@ final class FeedStore {
} }
private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope { 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) let ttl = max(1, card.ttlSec)
if feed.generatedAt + ttl <= now { return nil } if feed.generatedAt + ttl <= now { return nil }
if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil } if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil }
return FeedCard( return FeedItem(
id: card.id, id: card.id,
type: card.type, type: card.type,
title: card.title.truncated(maxLength: TextConstraints.titleMax), title: card.title.truncated(maxLength: TextConstraints.titleMax),
@@ -131,6 +131,7 @@ final class FeedStore {
priority: min(max(card.priority, 0.0), 1.0), priority: min(max(card.priority, 0.0), 1.0),
ttlSec: ttl, ttlSec: ttl,
condition: card.condition, condition: card.condition,
startsAt: card.startsAt,
bucket: card.bucket, bucket: card.bucket,
actions: card.actions actions: card.actions
) )
@@ -146,7 +147,7 @@ final class FeedStore {
return normalized 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) let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false } guard let state = states[key] else { return false }
if let until = state.dismissedUntil, until > now { return true } if let until = state.dismissedUntil, until > now { return true }
@@ -159,7 +160,7 @@ final class FeedStore {
Self.save(persisted, to: fileURL) 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)" "\(type.rawValue)|\(id)"
} }

View File

@@ -27,49 +27,65 @@ final class HeuristicRanker {
self.lastShownAtProvider = lastShownAt self.lastShownAtProvider = lastShownAt
} }
func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner { struct Ranked: Equatable {
let currentTime = now ?? nowProvider() let item: FeedItem
let confidence: Double
let valid = candidates let isEligibleForRightNow: Bool
.filter { !$0.isExpired(at: currentTime) }
.filter { $0.confidence >= 0.0 }
guard !valid.isEmpty else {
return WinnerEnvelope.allQuiet(now: currentTime).winner
} }
var best: (candidate: Candidate, score: Double)? struct WinnerSelection: Equatable {
for candidate in valid { let item: FeedItem
let baseWeight = baseWeight(for: candidate.type) let priority: Double
var score = baseWeight * min(max(candidate.confidence, 0.0), 1.0) }
if let shownAt = lastShownAtProvider(candidate.id),
func pickWinner(from items: [Ranked], now: Int? = nil, context: UserContext) -> WinnerSelection? {
let currentTime = now ?? nowProvider()
let valid = items
.filter { $0.item.ttlSec > 0 }
.filter { $0.confidence >= 0.0 }
.filter { $0.isEligibleForRightNow }
guard !valid.isEmpty else { return nil }
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 { currentTime - shownAt <= 2 * 60 * 60 {
score -= 0.4 score -= 0.4
} }
if best == nil || score > best!.score { if best == nil || score > best!.score {
best = (candidate, score) best = (proposed.item, score, proposed.confidence)
} }
} }
guard let best else { guard let best else {
return WinnerEnvelope.allQuiet(now: currentTime).winner return nil
} }
let priority = min(max(best.score, 0.0), 1.0) let priority = min(max(best.score, 0.0), 1.0)
return Winner( let item = FeedItem(
id: best.candidate.id, id: best.item.id,
type: best.candidate.type, type: best.item.type,
title: best.candidate.title.truncated(maxLength: TextConstraints.titleMax), title: best.item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: best.candidate.subtitle.truncated(maxLength: TextConstraints.subtitleMax), subtitle: best.item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: priority, 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 { switch type {
case .weatherWarning: return 1.0 case .weatherWarning: return 1.0
case .weatherAlert: return 0.9 case .weatherAlert: return 0.9
case .calendarEvent: return 0.8
case .transit: return 0.75 case .transit: return 0.75
case .poiNearby: return 0.6 case .poiNearby: return 0.6
case .info: return 0.4 case .info: return 0.4

View File

@@ -7,7 +7,7 @@
import Foundation import Foundation
enum WinnerType: String, Codable, CaseIterable { enum FeedItemType: String, Codable, CaseIterable {
case weatherAlert = "WEATHER_ALERT" case weatherAlert = "WEATHER_ALERT"
case weatherWarning = "WEATHER_WARNING" case weatherWarning = "WEATHER_WARNING"
case transit = "TRANSIT" case transit = "TRANSIT"
@@ -15,23 +15,6 @@ enum WinnerType: String, Codable, CaseIterable {
case info = "INFO" case info = "INFO"
case nowPlaying = "NOW_PLAYING" case nowPlaying = "NOW_PLAYING"
case currentWeather = "CURRENT_WEATHER" case currentWeather = "CURRENT_WEATHER"
case calendarEvent = "CALENDAR_EVENT"
case allQuiet = "ALL_QUIET" 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"
}
}

View File

@@ -7,82 +7,6 @@
import Foundation 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 { enum TextConstraints {
static let titleMax = 26 static let titleMax = 26
static let subtitleMax = 30 static let subtitleMax = 30
@@ -97,4 +21,3 @@ extension String {
return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis
} }
} }

View File

@@ -24,7 +24,7 @@ final class LocalServer: ObservableObject {
private var listener: NWListener? private var listener: NWListener?
private var browser: NWBrowser? private var browser: NWBrowser?
private var startDate = Date() private var startDate = Date()
private var currentEnvelope: WinnerEnvelope private var currentEnvelope: FeedEnvelope
private var heartbeatTimer: DispatchSourceTimer? private var heartbeatTimer: DispatchSourceTimer?
private var addressTimer: DispatchSourceTimer? private var addressTimer: DispatchSourceTimer?
private var requestBuffers: [ObjectIdentifier: Data] = [:] private var requestBuffers: [ObjectIdentifier: Data] = [:]
@@ -32,20 +32,7 @@ final class LocalServer: ObservableObject {
init(port: Int = 8765) { init(port: Int = 8765) {
self.port = port self.port = port
let winner = Winner( self.currentEnvelope = FeedEnvelope.allQuiet(now: Int(Date().timeIntervalSince1970), reason: "no_feed", source: "server")
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
)
} }
var testURL: String { var testURL: String {
@@ -97,15 +84,15 @@ final class LocalServer: ObservableObject {
} }
} }
func broadcastWinner(_ envelope: WinnerEnvelope) { func broadcastFeed(_ envelope: FeedEnvelope) {
let validated = (try? validateEnvelope(envelope)) ?? envelope currentEnvelope = envelope
currentEnvelope = validated let winner = envelope.winnerItem()
DispatchQueue.main.async { DispatchQueue.main.async {
self.lastWinnerTitle = validated.winner.title self.lastWinnerTitle = winner?.title ?? "All Quiet"
self.lastWinnerSubtitle = validated.winner.subtitle self.lastWinnerSubtitle = winner?.subtitle ?? "No urgent updates"
self.lastBroadcastAt = Date() self.lastBroadcastAt = Date()
} }
let data = sseEvent(name: "winner", payload: jsonLine(from: validated)) let data = sseEvent(name: "feed", payload: jsonLine(from: envelope))
broadcast(data: data) 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() let encoder = JSONEncoder()
if let data = try? encoder.encode(envelope), if let data = try? encoder.encode(envelope),
let string = String(data: data, encoding: .utf8) { let string = String(data: data, encoding: .utf8) {

View File

@@ -18,10 +18,10 @@ final class ContextOrchestrator: NSObject, ObservableObject {
@Published private(set) var lastLocation: CLLocation? = nil @Published private(set) var lastLocation: CLLocation? = nil
@Published private(set) var lastRecomputeAt: Date? = nil @Published private(set) var lastRecomputeAt: Date? = nil
@Published private(set) var lastRecomputeReason: String? = 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 lastError: String? = nil
@Published private(set) var lastCandidates: [Candidate] = []
@Published private(set) var lastWeatherDiagnostics: [String: String] = [:] @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 lastPipelineElapsedMs: Int? = nil
@Published private(set) var lastFetchFailed: Bool = false @Published private(set) var lastFetchFailed: Bool = false
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
@@ -50,7 +50,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
self.store = store self.store = store
self.server = server self.server = server
self.ble = ble 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() super.init()
locationManager.delegate = self locationManager.delegate = self
@@ -61,8 +61,8 @@ final class ContextOrchestrator: NSObject, ObservableObject {
ble.onFirstSubscribe = { [weak self] in ble.onFirstSubscribe = { [weak self] in
Task { @MainActor in Task { @MainActor in
self?.logger.info("BLE subscribed: pushing latest winner") self?.logger.info("BLE subscribed: pushing latest feed")
self?.pushLatestWinnerToBle() self?.pushLatestFeedToBle()
} }
} }
ble.onControlCommand = { [weak self] command in ble.onControlCommand = { [weak self] command in
@@ -76,12 +76,12 @@ final class ContextOrchestrator: NSObject, ObservableObject {
guard let self else { return } guard let self else { return }
self.musicAuthorization = update.authorization self.musicAuthorization = update.authorization
self.nowPlaying = update.snapshot self.nowPlaying = update.snapshot
self.pushLatestWinnerToBle() self.pushLatestFeedToBle()
} }
} }
let feed = store.getFeed() let feed = store.getFeed()
lastWinner = feed.asWinnerEnvelope() lastFeed = feed
} }
func start() { func start() {
@@ -191,98 +191,246 @@ final class ContextOrchestrator: NSObject, ObservableObject {
let start = Date() let start = Date()
async let weatherResult = withTimeoutResult(seconds: 6) { 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) { 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) { 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 wxRes = await weatherResult
let calRes = await calendarResult let calRes = await calendarResult
let poiRes = await poiResult 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 fetchFailed = false
var wxDiagnostics: [String: String] = [:] var wxDiagnostics: [String: String] = [:]
var weatherNowCandidate: Candidate? = nil var calDiagnostics: [String: String] = [:]
switch wxRes { switch wxRes {
case .success(let wx): case .success(let snapshot):
candidates.append(contentsOf: wx.candidates) wxDiagnostics = snapshot.diagnostics
wxDiagnostics = wx.diagnostics let weather = snapshot.data
weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") })
if let wxErr = wx.weatherKitError { 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 fetchFailed = true
logger.warning("weather fetch error: \(wxErr, privacy: .public)") logger.warning("weather fetch error: \(wxErr, privacy: .public)")
} }
case .failure(let error): case .failure(let error):
fetchFailed = true fetchFailed = true
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)") logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
} }
}
switch poiRes { switch poiRes {
case .success(let pois): case .success:
candidates.append(contentsOf: pois) break
case .failure(let error): case .failure(let error):
fetchFailed = true fetchFailed = true
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
} }
switch calRes { switch calRes {
case .success(let cal): case .success(let snapshot):
candidates.append(contentsOf: cal.candidates) calDiagnostics = snapshot.diagnostics
if let err = cal.error { for event in snapshot.data.events {
fetchFailed = true let isOngoing = event.startAt <= nowEpoch && event.endAt > nowEpoch
logger.warning("calendar error: \(err, privacy: .public)") 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): case .failure(let error):
fetchFailed = true fetchFailed = true
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)") logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
} }
}
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
lastPipelineElapsedMs = elapsedMs lastPipelineElapsedMs = elapsedMs
lastFetchFailed = fetchFailed lastFetchFailed = fetchFailed
lastCandidates = candidates
lastWeatherDiagnostics = wxDiagnostics 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 fallbackFeed = store.getFeed(now: nowEpoch)
let fallbackWinner = fallbackFeed.asWinnerEnvelope() lastFeed = fallbackFeed
lastWinner = fallbackWinner lastError = "Fetch failed; using previous feed."
lastError = "Fetch failed; using previous winner." server.broadcastFeed(fallbackFeed)
server.broadcastWinner(fallbackWinner)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
return return
} }
let unsuppressed = candidates let eligibleUnsuppressed = rightNowCandidates.filter { ranked in
.filter { $0.type != .currentWeather } !store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch)
.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 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) store.setFeed(feedEnvelope, now: nowEpoch)
lastWinner = validated lastFeed = feedEnvelope
lastRecomputeAt = Date() lastRecomputeAt = Date()
lastRecomputeLocation = location lastRecomputeLocation = location
lastRecomputeAccuracy = location.horizontalAccuracy lastRecomputeAccuracy = location.horizontalAccuracy
lastError = fetchFailed ? "Partial fetch failure." : nil 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) 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 nowEpoch = Int(Date().timeIntervalSince1970)
let feed = store.getFeed(now: nowEpoch) let feed = store.getFeed(now: nowEpoch)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1) 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 } guard !command.isEmpty else { return }
if command == "REQ_FULL" { if command == "REQ_FULL" {
logger.info("BLE control REQ_FULL") logger.info("BLE control REQ_FULL")
pushLatestWinnerToBle() pushLatestFeedToBle()
return return
} }
if command.hasPrefix("ACK:") { if command.hasPrefix("ACK:") {
@@ -303,7 +451,7 @@ final class ContextOrchestrator: NSObject, ObservableObject {
} }
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { 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 return base
} }
@@ -376,7 +524,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
let album: String? let album: String?
let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus
func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard { func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem {
let desiredLifetimeSec = 30 let desiredLifetimeSec = 30
let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec) let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec)
@@ -385,7 +533,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: "") let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: "")
return FeedCard( return FeedItem(
id: "music:now:\(itemId)", id: "music:now:\(itemId)",
type: .nowPlaying, type: .nowPlaying,
title: title.truncated(maxLength: TextConstraints.titleMax), title: title.truncated(maxLength: TextConstraints.titleMax),
@@ -393,6 +541,7 @@ struct NowPlayingSnapshot: Equatable, Sendable {
priority: playbackStatus == .playing ? 0.35 : 0.2, priority: playbackStatus == .playing ? 0.35 : 0.2,
ttlSec: ttl, ttlSec: ttl,
condition: nil, condition: nil,
startsAt: nil,
bucket: .fyi, bucket: .fyi,
actions: ["DISMISS"] actions: ["DISMISS"]
) )

View File

@@ -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}}

View File

@@ -11,7 +11,7 @@ import os
@MainActor @MainActor
final class CandidatesViewModel: ObservableObject { 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 lastUpdatedAt: Date? = nil
@Published private(set) var isLoading = false @Published private(set) var isLoading = false
@Published private(set) var lastError: String? = nil @Published private(set) var lastError: String? = nil
@@ -41,24 +41,121 @@ final class CandidatesViewModel: ObservableObject {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
let ds = WeatherDataSource() 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 { await MainActor.run {
self.candidates = result.candidates.sorted { $0.confidence > $1.confidence } self.candidates = []
self.diagnostics = result.diagnostics self.diagnostics = diagnostics
if let error = result.weatherKitError { 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 = items
self.diagnostics = output.diagnostics
if let error = output.diagnostics["weatherkit_error"] {
self.lastError = "WeatherKit error: \(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.error("WeatherKit error: \(error)")
} }
self.logger.info("Produced candidates count=\(result.candidates.count)") self.logger.info("Produced feed items count=\(items.count)")
for c in result.candidates { for item in items {
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("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 { if items.isEmpty {
self.logger.info("Diagnostics: \(String(describing: result.diagnostics), privacy: .public)") self.logger.info("Diagnostics: \(String(describing: output.diagnostics), privacy: .public)")
} }
} else { } else {
await MainActor.run { await MainActor.run {

View File

@@ -75,7 +75,7 @@ struct CandidatesView: View {
} }
private struct CandidateRow: View { private struct CandidateRow: View {
let candidate: Candidate let candidate: FeedItem
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -88,29 +88,22 @@ private struct CandidateRow: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if !candidate.subtitle.isEmpty {
Text(candidate.subtitle) Text(candidate.subtitle)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
}
HStack(spacing: 12) { HStack(spacing: 12) {
Text(String(format: "conf %.2f", candidate.confidence)) Text(String(format: "prio %.2f", candidate.priority))
Text("ttl \(candidate.ttlSec)s") Text("ttl \(candidate.ttlSec)s")
Text(expiresText(now: Int(Date().timeIntervalSince1970)))
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.vertical, 4) .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 { struct CandidatesView_Previews: PreviewProvider {

View File

@@ -45,18 +45,48 @@ struct OrchestratorView: View {
Button("Recompute Now") { orchestrator.recomputeNow() } Button("Recompute Now") { orchestrator.recomputeNow() }
} }
Section("Winner") { Section("Feed") {
if let env = orchestrator.lastWinner { if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
Text(env.winner.title) Text(winner.title)
.font(.headline) .font(.headline)
Text(env.winner.subtitle) if !winner.subtitle.isEmpty {
Text(winner.subtitle)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s") }
Text("type \(winner.type.rawValue) • prio \(String(format: "%.2f", winner.priority)) • ttl \(winner.ttlSec)s")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .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 { } else {
Text("No winner yet") Text("No feed yet")
.foregroundStyle(.secondary) .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 { if !orchestrator.lastWeatherDiagnostics.isEmpty {
Section("Weather Diagnostics") { Section("Weather Diagnostics") {
ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in 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") { Section("Test") {
Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() } Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() }
} }