initial commit

This commit is contained in:
2026-01-08 19:16:32 +00:00
commit d89aedd5af
121 changed files with 8509 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
//
// CalendarDataSource.swift
// iris
//
// Created by Codex.
//
import EventKit
import Foundation
struct CalendarDataSourceConfig: Sendable {
var lookaheadSec: Int = 2 * 60 * 60
var soonWindowSec: Int = 30 * 60
var maxCandidates: Int = 3
var includeAllDay: Bool = false
var includeDeclined: Bool = false
init() {}
}
final class CalendarDataSource {
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let error: String?
let diagnostics: [String: String]
}
private let store: EKEventStore
private let config: CalendarDataSourceConfig
init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) {
self.store = store
self.config = config
}
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
var diagnostics: [String: String] = [
"now": String(now),
"lookahead_sec": String(config.lookaheadSec),
"soon_window_sec": String(config.soonWindowSec),
]
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec))
let auth = calendarAuthorizationStatusString()
diagnostics["auth"] = auth
let accessGranted = await ensureCalendarAccess()
diagnostics["access_granted"] = accessGranted ? "true" : "false"
guard accessGranted else {
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
}
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
let events = store.events(matching: predicate)
diagnostics["events_matched"] = String(events.count)
let filtered = events
.filter { shouldInclude(event: $0) }
.sorted { $0.startDate < $1.startDate }
diagnostics["events_filtered"] = String(filtered.count)
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
diagnostics["candidates"] = String(candidates.count)
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
}
private func shouldInclude(event: EKEvent) -> Bool {
if event.isAllDay, !config.includeAllDay {
return false
}
if !config.includeDeclined {
// `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability.
// Many calendars wont provide participants; keep it simple for Phase 1.
if event.status == .canceled {
return false
}
}
return true
}
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
var results: [Candidate] = []
results.reserveCapacity(min(config.maxCandidates, events.count))
for event in events {
if results.count >= config.maxCandidates { break }
guard let start = event.startDate, let end = event.endDate else { continue }
let isOngoing = start <= nowDate && end > nowDate
let startsInSec = Int(start.timeIntervalSince(nowDate))
if !isOngoing, startsInSec > config.soonWindowSec {
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))"
results.append(
Candidate(
id: id,
type: .info,
title: title,
subtitle: subtitle,
confidence: confidence,
createdAt: now,
ttlSec: ttl,
metadata: [
"source": "eventkit",
"calendar": event.calendar.title,
"start": String(Int(start.timeIntervalSince1970)),
"end": String(Int(end.timeIntervalSince1970)),
"all_day": event.isAllDay ? "true" : "false",
"location": event.location ?? "",
]
)
)
}
return results
}
private func ttlSec(end: Date, nowDate: Date) -> Int {
let ttl = Int(end.timeIntervalSince(nowDate))
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
return min(max(ttl, 60), 2 * 60 * 60)
}
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
guard let start = event.startDate, let end = event.endDate else { return "" }
let isOngoing = start <= nowDate && end > nowDate
if isOngoing {
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "\(loc)\(remainingMin)m left"
}
return "\(remainingMin)m left"
}
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "In \(minutes)m • \(loc)"
}
return "In \(minutes)m"
}
private func calendarAuthorizationStatusString() -> String {
if #available(iOS 17.0, *) {
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
private func ensureCalendarAccess() async -> Bool {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await requestAccess()
@unknown default:
return false
}
}
private func requestAccess() async -> Bool {
await MainActor.run {
// Ensure the permission prompt is allowed to present.
}
return await withCheckedContinuation { continuation in
if #available(iOS 17.0, *) {
store.requestFullAccessToEvents { granted, _ in
continuation.resume(returning: granted)
}
} else {
store.requestAccess(to: .event) { granted, _ in
continuation.resume(returning: granted)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
//
// POIDataSource.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
struct POIDataSourceConfig: Sendable {
var maxCandidates: Int = 3
init() {}
}
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
final class POIDataSource {
private let config: POIDataSourceConfig
init(config: POIDataSourceConfig = .init()) {
self.config = config
}
func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] {
// Phase 1 stub: return nothing.
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
_ = config
_ = location
_ = now
return []
}
}

View File

@@ -0,0 +1,334 @@
//
// WeatherDataSource.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import WeatherKit
struct WeatherAlertConfig: Sendable {
var rainLookaheadSec: Int = 20 * 60
var precipitationChanceThreshold: Double = 0.5
var gustThresholdMps: Double? = nil
var rainTTL: Int = 1800
var windTTL: Int = 3600
init() {}
}
protocol WeatherWarningProviding: 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 title: String
let subtitle: String
let ttlSec: Int?
let confidence: Double?
let meta: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case ttlSec = "ttl_sec"
case confidence
case meta
}
}
let url: URL?
init(url: URL?) {
self.url = url
}
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] {
guard let url else { return [] }
guard let data = try? Data(contentsOf: url) else { return [] }
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
return items.map { item in
Candidate(
id: item.id,
type: .weatherWarning,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0),
createdAt: now,
ttlSec: max(1, item.ttlSec ?? 3600),
metadata: item.meta
)
}
}
}
@available(iOS 16.0, *)
final class WeatherDataSource {
private let service: WeatherService
private let config: WeatherAlertConfig
private let warningProvider: WeatherWarningProviding
init(service: WeatherService = .shared,
config: WeatherAlertConfig = .init(),
warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider(
url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json")
)) {
self.service = service
self.config = config
self.warningProvider = warningProvider
}
/// Returns alert candidates derived from WeatherKit forecasts.
func candidates(for location: CLLocation, now: Int) async -> [Candidate] {
let result = await candidatesWithDiagnostics(for: location, now: now)
return result.candidates
}
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let weatherKitError: String?
let diagnostics: [String: String]
}
func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult {
var results: [Candidate] = []
var errorString: String? = nil
var diagnostics: [String: String] = [
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
"now": String(now),
"rain_lookahead_sec": String(config.rainLookaheadSec),
"precip_chance_threshold": String(format: "%.2f", config.precipitationChanceThreshold),
"gust_threshold_mps": config.gustThresholdMps.map { String(format: "%.2f", $0) } ?? "nil",
]
do {
let weather = try await service.weather(for: location)
results.append(currentConditionsCandidate(weather: weather, location: location, now: now))
diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil"
diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count)
if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value {
diagnostics["current_gust_mps"] = String(format: "%.2f", gust)
} else {
diagnostics["current_gust_mps"] = "nil"
}
results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now))
results.append(contentsOf: windCandidates(weather: weather, location: location, now: now))
diagnostics["candidates_weatherkit"] = String(results.count)
diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new }
} catch {
errorString = String(describing: error)
diagnostics["weatherkit_error"] = errorString
}
results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now))
diagnostics["candidates_total"] = String(results.count)
return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics)
}
private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate {
let tempC = weather.currentWeather.temperature.converted(to: .celsius).value
let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value
let tempInt = Int(tempC.rounded())
let feelsInt = Int(feelsC.rounded())
let cond = weather.currentWeather.condition.description
let conditionEnum = weather.currentWeather.condition
let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax)
let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax)
return Candidate(
id: "wx:now:\(now / 60)",
type: .currentWeather,
title: title,
subtitle: subtitle,
confidence: 0.8,
createdAt: now,
ttlSec: 1800,
condition: conditionEnum,
metadata: [
"source": "weatherkit_current",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
}
private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast {
if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.9,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_minutely",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
return []
}
if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.6,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_hourly_approx",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
return []
}
private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
guard let gustThreshold = config.gustThresholdMps else { return [] }
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] }
guard gust >= gustThreshold else { return [] }
let mph = Int((gust * 2.236936).rounded())
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
return [
Candidate(
id: "wx:wind:\(now):\(Int(gustThreshold * 10))",
type: .weatherAlert,
title: title,
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.8,
createdAt: now,
ttlSec: config.windTTL,
metadata: [
"source": "weatherkit_current",
"gust_mps": String(format: "%.2f", gust),
"threshold_mps": String(format: "%.2f", gustThreshold),
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? {
for minute in minutes {
guard minute.date >= now else { continue }
let dt = minute.date.timeIntervalSince(now)
guard dt <= lookahead else { break }
let chance = minute.precipitationChance
let isRainy = minute.precipitation == .rain || minute.precipitation == .mixed
if isRainy, chance >= config.precipitationChanceThreshold {
return minute.date
}
}
return nil
}
private func firstRainStartHourly(_ hours: [HourWeather], now: Date, within lookahead: TimeInterval) -> Date? {
for hour in hours {
guard hour.date >= now else { continue }
let dt = hour.date.timeIntervalSince(now)
guard dt <= lookahead else { break }
let chance = hour.precipitationChance
let isRainy = hour.precipitation == .rain || hour.precipitation == .mixed
if isRainy, chance >= config.precipitationChanceThreshold {
return hour.date
}
}
return nil
}
private func rainTitle(start: Date, now: Date) -> String {
let minutes = max(0, Int(((start.timeIntervalSince(now)) / 60.0).rounded()))
if minutes <= 0 { return "Rain now" }
return "Rain in ~\(minutes) min"
}
private func rainDiagnostics(weather: Weather, now: Int) -> [String: String] {
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast {
var bestChance: Double = 0
var bestType: String = "none"
var firstAnyPrecipOffsetSec: Int? = nil
for minute in minuteForecast.forecast {
guard minute.date >= nowDate else { continue }
let dt = minute.date.timeIntervalSince(nowDate)
guard dt <= lookahead else { break }
if minute.precipitation != .none, firstAnyPrecipOffsetSec == nil {
firstAnyPrecipOffsetSec = Int(dt.rounded())
}
if minute.precipitationChance > bestChance {
bestChance = minute.precipitationChance
bestType = minute.precipitation.rawValue
}
}
return [
"rain_resolution": "minutely",
"rain_best_chance": String(format: "%.2f", bestChance),
"rain_best_type": bestType,
"rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil",
]
}
var bestChance: Double = 0
var bestType: String = "none"
var firstAnyPrecipOffsetSec: Int? = nil
for hour in weather.hourlyForecast.forecast {
guard hour.date >= nowDate else { continue }
let dt = hour.date.timeIntervalSince(nowDate)
guard dt <= lookahead else { break }
if hour.precipitation != .none, firstAnyPrecipOffsetSec == nil {
firstAnyPrecipOffsetSec = Int(dt.rounded())
}
if hour.precipitationChance > bestChance {
bestChance = hour.precipitationChance
bestType = hour.precipitation.rawValue
}
}
return [
"rain_resolution": "hourly",
"rain_best_chance": String(format: "%.2f", bestChance),
"rain_best_type": bestType,
"rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil",
]
}
}