Refactor data sources and feed model
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
@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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user