fix(visibility): auto-reconcile server sidebar_hidden on launch/resume/sync
Hiding or showing a calendar on the web sets enabled=false + sidebar_hidden server-side, so the server stops returning that calendar's events entirely. The app only reconciled these per-calendar flags when the filter sheet or the accounts screen happened to be opened — never on launch, resume or the periodic pull. So a calendar re-enabled on the web stayed invisible (still banished locally, and its events weren't in the cache) until a manual sync or relaunch — the "hide it on the web and the app won't bring it back" bug. Add CalendarStore.reconcileCalendarVisibility(api:) which pulls the account lists, updates the banished set from the server's sidebar_hidden flags, and returns whether anything changed. CalendarHostView now runs it (via syncFromServer) on launch, on scenePhase .active, in the periodic loop, and on manual sync — forcing a refetch when the set changed so a re-enabled calendar's events actually reappear without user intervention. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user