Refactor data sources and feed model

This commit is contained in:
2026-01-10 00:25:36 +00:00
parent 1e65a3f57d
commit 324b35a464
15 changed files with 631 additions and 609 deletions

View File

@@ -19,12 +19,42 @@ struct CalendarDataSourceConfig: Sendable {
}
final class CalendarDataSource {
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let error: String?
struct Event: Sendable, Equatable {
let id: String
let title: String
let startAt: Int
let endAt: Int
let isAllDay: Bool
let location: String?
}
struct Data: Sendable, Equatable {
let events: [Event]
}
struct Snapshot: Sendable, Equatable {
let data: Data
let diagnostics: [String: String]
}
enum CalendarError: Error, LocalizedError, Sendable, Equatable {
case accessNotGranted(diagnostics: [String: String])
var errorDescription: String? {
switch self {
case .accessNotGranted:
return "Calendar access not granted."
}
}
var diagnostics: [String: String] {
switch self {
case .accessNotGranted(let diagnostics):
return diagnostics
}
}
}
private let store: EKEventStore
private let config: CalendarDataSourceConfig
@@ -33,7 +63,7 @@ final class CalendarDataSource {
self.config = config
}
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
func dataWithDiagnostics(now: Int) async throws -> Snapshot {
var diagnostics: [String: String] = [
"now": String(now),
"lookahead_sec": String(config.lookaheadSec),
@@ -50,7 +80,7 @@ final class CalendarDataSource {
diagnostics["access_granted"] = accessGranted ? "true" : "false"
guard accessGranted else {
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
throw CalendarError.accessNotGranted(diagnostics: diagnostics)
}
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
@@ -64,10 +94,10 @@ final class CalendarDataSource {
diagnostics["events_filtered"] = String(filtered.count)
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
diagnostics["candidates"] = String(candidates.count)
let outputEvents = buildEvents(from: filtered, nowDate: nowDate)
diagnostics["events_output"] = String(outputEvents.count)
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics)
}
private func shouldInclude(event: EKEvent) -> Bool {
@@ -84,8 +114,8 @@ final class CalendarDataSource {
return true
}
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
var results: [Candidate] = []
private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] {
var results: [Event] = []
results.reserveCapacity(min(config.maxCandidates, events.count))
for event in events {
@@ -99,34 +129,16 @@ final class CalendarDataSource {
continue
}
let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming")
.truncated(maxLength: TextConstraints.titleMax)
let subtitle = subtitleText(event: event, nowDate: nowDate)
.truncated(maxLength: TextConstraints.subtitleMax)
let confidence: Double = isOngoing ? 0.9 : 0.7
let ttl = ttlSec(end: end, nowDate: nowDate)
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines)
results.append(
Candidate(
Event(
id: id,
type: .info,
title: title,
subtitle: subtitle,
confidence: confidence,
createdAt: now,
ttlSec: ttl,
metadata: [
"source": "eventkit",
"calendar": event.calendar.title,
"start": String(Int(start.timeIntervalSince1970)),
"end": String(Int(end.timeIntervalSince1970)),
"all_day": event.isAllDay ? "true" : "false",
"location": event.location ?? "",
]
title: title.isEmpty ? "Event" : title,
startAt: Int(start.timeIntervalSince1970),
endAt: Int(end.timeIntervalSince1970),
isAllDay: event.isAllDay,
location: event.location
)
)
}
@@ -134,31 +146,6 @@ final class CalendarDataSource {
return results
}
private func ttlSec(end: Date, nowDate: Date) -> Int {
let ttl = Int(end.timeIntervalSince(nowDate))
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
return min(max(ttl, 60), 2 * 60 * 60)
}
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
guard let start = event.startDate, let end = event.endDate else { return "" }
let isOngoing = start <= nowDate && end > nowDate
if isOngoing {
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "\(loc)\(remainingMin)m left"
}
return "\(remainingMin)m left"
}
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "In \(minutes)m • \(loc)"
}
return "In \(minutes)m"
}
private func calendarAuthorizationStatusString() -> String {
if #available(iOS 17.0, *) {
return String(describing: EKEventStore.authorizationStatus(for: .event))
@@ -169,13 +156,18 @@ final class CalendarDataSource {
private func ensureCalendarAccess() async -> Bool {
let status = EKEventStore.authorizationStatus(for: .event)
if #available(iOS 17.0, *) {
if status == .fullAccess { return true }
if status == .writeOnly { return false }
}
switch status {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await requestAccess()
case .denied, .restricted:
return false
case .authorized:
return true
@unknown default:
return false
}