Root cause: mergeIntoCache() unconditionally evicted every event in the fetched date range, even for calendars whose sync failed. Those calendars' events were removed and never restored, so the calendar appeared to vanish. Fix: - SyncError now decodes calendarId from the server's "calendar_id" field (added server-side in e0ea16f but not yet read by iOS). - mergeIntoCache() gains a keepKeysInRange parameter: events from failed calendars are retained (not evicted) even within the fetch window. - loadEvents() and prefetchBackground() compute the failed keys from the syncErrors list and pass them to mergeIntoCache. Result: when CalDAV / Google / HA sync fails for a specific calendar, the user sees stale-but-correct events alongside the existing error banner, instead of a completely empty calendar with no explanation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
174 lines
6.4 KiB
Swift
174 lines
6.4 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Creator (or owner, in the group combined view) of an event.
|
|
/// `id` is nil for imported events.
|
|
struct EventPerson: Hashable {
|
|
let id: Int?
|
|
let displayName: String
|
|
|
|
static func from(_ json: Any?) -> EventPerson? {
|
|
guard let obj = json as? [String: Any],
|
|
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
|
let id: Int?
|
|
if let n = obj["id"] as? Int { id = n }
|
|
else if let s = obj["id"] as? String { id = Int(s) }
|
|
else { id = nil }
|
|
return EventPerson(id: id, displayName: name)
|
|
}
|
|
}
|
|
|
|
/// A partial-sync failure reported alongside an otherwise-successful
|
|
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
|
/// expired credentials) even though it's still enabled. Distinct from a
|
|
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
|
/// succeeded, just not every source within it.
|
|
struct SyncError: Hashable {
|
|
let source: String
|
|
let calendarId: String? // nil on older server responses without calendar_id
|
|
let name: String
|
|
let message: String
|
|
|
|
static func from(json: [String: Any]) -> SyncError? {
|
|
guard
|
|
let source = json["source"] as? String,
|
|
let name = json["name"] as? String,
|
|
let message = json["message"] as? String
|
|
else { return nil }
|
|
let calendarId = json["calendar_id"].map { "\($0)" }
|
|
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
|
|
}
|
|
}
|
|
|
|
struct CalEvent: Identifiable, Hashable {
|
|
let id: String
|
|
let url: String
|
|
var title: String
|
|
var startDate: Date
|
|
var endDate: Date
|
|
var isAllDay: Bool
|
|
var location: String
|
|
var notes: String
|
|
var color: String?
|
|
var calendarId: String
|
|
var calendarName: String
|
|
var calendarColor: String
|
|
var source: String
|
|
var creator: EventPerson? = nil
|
|
var isPrivate: Bool = false
|
|
// Only set in the group combined view:
|
|
var owner: EventPerson? = nil
|
|
var isGroupEvent: Bool = false
|
|
var displayColor: String? = nil
|
|
// Server-decorated title for the group combined view (group icon / owner
|
|
// prefix); rendered in group mode while `title` stays raw for editing.
|
|
var displayTitle: String? = nil
|
|
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
|
var reminders: [Int] = []
|
|
|
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
|
|
|
static func from(json: [String: Any]) -> CalEvent? {
|
|
guard
|
|
let title = json["title"] as? String,
|
|
let startStr = json["start"] as? String,
|
|
let endStr = json["end"] as? String
|
|
else { return nil }
|
|
|
|
// id can be String (local UUID) or Int (CalDAV numeric)
|
|
let id: String
|
|
if let s = json["id"] as? String { id = s }
|
|
else if let n = json["id"] as? Int { id = String(n) }
|
|
else { return nil }
|
|
|
|
let isAllDay = json["allDay"] as? Bool ?? false
|
|
let startDate = parseDate(startStr, allDay: isAllDay)
|
|
let endDate = parseDate(endStr, allDay: isAllDay)
|
|
guard let s = startDate, let e = endDate else { return nil }
|
|
|
|
return CalEvent(
|
|
id: id,
|
|
url: json["url"] as? String ?? "",
|
|
title: title,
|
|
startDate: s,
|
|
endDate: e,
|
|
isAllDay: isAllDay,
|
|
location: json["location"] as? String ?? "",
|
|
notes: json["description"] as? String ?? "",
|
|
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
|
calendarName: json["calendar_name"] as? String ?? "",
|
|
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
|
source: json["source"] as? String ?? "local",
|
|
creator: EventPerson.from(json["creator"]),
|
|
isPrivate: json["private"] as? Bool ?? false,
|
|
owner: EventPerson.from(json["owner"]),
|
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? []
|
|
)
|
|
}
|
|
}
|
|
|
|
private let isoFull: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
private let isoBasic: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
private let dateOnly: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.timeZone = .current
|
|
return f
|
|
}()
|
|
|
|
// Handles all date formats the backend may produce:
|
|
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
|
|
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
|
|
private let noTZFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
|
f.timeZone = TimeZone(abbreviation: "UTC")
|
|
return f
|
|
}()
|
|
|
|
private let spaceSepFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
|
|
return f
|
|
}()
|
|
|
|
func parseDate(_ s: String, allDay: Bool) -> Date? {
|
|
let clean = s.trimmingCharacters(in: .whitespaces)
|
|
if allDay || (clean.count == 10 && !clean.contains("T")) {
|
|
return dateOnly.date(from: String(clean.prefix(10)))
|
|
}
|
|
// Try each formatter in order of likelihood
|
|
if let d = isoFull.date(from: clean) { return d }
|
|
if let d = isoBasic.date(from: clean) { return d }
|
|
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
|
|
if let d = spaceSepFormatter.date(from: clean) { return d }
|
|
// No timezone → treat as UTC
|
|
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
|
|
// Last resort: just parse the date part
|
|
return dateOnly.date(from: String(clean.prefix(10)))
|
|
}
|
|
|
|
func formatISO(_ date: Date, allDay: Bool) -> String {
|
|
if allDay {
|
|
return dateOnly.string(from: date)
|
|
}
|
|
return isoBasic.string(from: date)
|
|
}
|