diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index af3b5de..f13fd2c 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -225,6 +225,38 @@ class CalendarStore { publishWidgetSnapshot() } + /// Reconcile the local "banished" set with the server's per-calendar + /// `sidebar_hidden` flags (server wins for CalDAV / Google / HA). Returns + /// `true` if the set changed, so the caller can force a refetch. + /// + /// This closes the sync gap where hiding/showing a calendar on the web (or + /// another device) was only ever picked up when the filter sheet or the + /// accounts screen happened to be opened — not on app launch / resume. A + /// calendar re-enabled on the web has NO events in the cache (the server + /// excludes a hidden calendar's events entirely), so a plain + /// `refreshFromCache` can't bring them back: the caller must force a reload. + func reconcileCalendarVisibility(api: CalendarrAPI) async -> Bool { + async let c = (try? await api.getCalDAVAccounts()) ?? [] + async let g = (try? await api.getGoogleAccounts()) ?? [] + async let h = (try? await api.getHomeAssistantAccounts()) ?? [] + let (caldav, google, ha) = await (c, g, h) + + var b = banishedCalendarKeys + func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { + let key = Self.calendarKey(source: source, calendarId: "\(id)") + if hidden { b.insert(key) } else { b.remove(key) } + } + for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } + for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } + for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + + guard b != banishedCalendarKeys else { return false } + banishedCalendarKeys = b + Self.saveBanishedKeys(b) + NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + return true + } + // MARK: – Reminder-disabled-calendar persistence private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys" diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 36915d0..d674889 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -85,7 +85,7 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } - .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } @@ -152,7 +152,7 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } - .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } @@ -267,7 +267,7 @@ struct CalendarHostView: View { } } // Sync - Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: { + Button { Task { await syncFromServer(force: true) } } label: { Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") } Divider() @@ -437,6 +437,10 @@ struct CalendarHostView: View { applyServerDrivenSettings(initial: true) await store.loadWritableCalendars(api: api) + // Reconcile per-calendar visibility with the server BEFORE the first + // load so a calendar hidden/shown on the web is honoured immediately + // (banished set correct before events are filtered). + _ = await store.reconcileCalendarVisibility(api: api) groups = (try? await api.getGroups()) ?? [] // 1. Load current view immediately (visible) let (s, e) = store.rangeForCurrentView() @@ -445,11 +449,11 @@ struct CalendarHostView: View { Task(priority: .background) { await store.prefetchBackground(api: api, months: cacheMonths) } - // 3. Periodic settings pull (tied to this .task's lifetime). + // 3. Periodic settings + visibility pull (tied to this .task's lifetime). while !Task.isCancelled { try? await Task.sleep(for: .seconds(600)) if Task.isCancelled { break } - await SettingsSync.pull(api: api) + await syncFromServer() } } @@ -497,6 +501,17 @@ struct CalendarHostView: View { } } + /// Pull server-driven settings AND reconcile per-calendar visibility in one + /// step (used on launch, resume and the periodic loop). If the server + /// changed a calendar's `sidebar_hidden` — e.g. hidden/shown on the web or + /// another device — or `force` is set (manual sync), refetch so the change + /// shows up without the user opening the filter sheet. + private func syncFromServer(force: Bool = false) async { + await SettingsSync.pull(api: api) + let changed = await store.reconcileCalendarVisibility(api: api) + if changed || force { await forceReload() } + } + /// Called when the user scrolls into a new month – refreshes the visible range /// immediately from cache, then fetches on demand if needed. private func ensureLoaded(around month: Date) async {