From 89be5f1e5b8d7c01c2cf44419857720d7e65f0fc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:50:42 +0100 Subject: [PATCH] fix: keep every calendar hidden after reload when the user hid them all --- lib/account-state-manager.ts | 1 + .../calendar-selection-after-fetch.test.ts | 44 ++++++++++++++++++- stores/calendar-store.ts | 13 ++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index ac45c4afa..a2164fbf6 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -70,6 +70,7 @@ export function snapshotAccount(accountId: string): void { calendars: [...calendarState.calendars], events: [...calendarState.events], selectedCalendarIds: [...calendarState.selectedCalendarIds], + calendarSelectionChosen: calendarState.calendarSelectionChosen, viewMode: calendarState.viewMode, supportsCalendar: calendarState.supportsCalendar, }, diff --git a/stores/__tests__/calendar-selection-after-fetch.test.ts b/stores/__tests__/calendar-selection-after-fetch.test.ts index 4783f78cf..114af1b3c 100644 --- a/stores/__tests__/calendar-selection-after-fetch.test.ts +++ b/stores/__tests__/calendar-selection-after-fetch.test.ts @@ -35,7 +35,7 @@ const own = [cal({ id: 'a', accountId: 'me' }), cal({ id: 'b', accountId: 'me' } describe('calendar selection after a fetch', () => { beforeEach(() => { - useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], error: null }); + useCalendarStore.setState({ calendars: [], selectedCalendarIds: [], calendarSelectionChosen: false, error: null }); }); it('leaves the list and the selection alone when the fetch fails', async () => { @@ -106,6 +106,48 @@ describe('calendar selection after a fetch', () => { }); }); +describe('a selection the user made', () => { + beforeEach(() => { + useCalendarStore.setState({ calendars: own, selectedCalendarIds: ['a', 'b'], calendarSelectionChosen: false, error: null }); + }); + + it('stays empty after the user hid every calendar', async () => { + useCalendarStore.getState().toggleCalendarVisibility('a'); + useCalendarStore.getState().toggleCalendarVisibility('b'); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual([]); + }); + + it('keeps only the birthday calendar when the user hid every other one', async () => { + useCalendarStore.setState({ selectedCalendarIds: ['a', 'b', BIRTHDAY_CALENDAR_ID] }); + useCalendarStore.getState().toggleCalendarVisibility('a'); + useCalendarStore.getState().toggleCalendarVisibility('b'); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual([BIRTHDAY_CALENDAR_ID]); + }); + + it('falls back to all when the calendars it named were removed', async () => { + useCalendarStore.getState().toggleCalendarVisibility('b'); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: [cal({ id: 'c', accountId: 'me' })] })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['c']); + }); + + it('does not count showing the birthday calendar as a choice', async () => { + useCalendarStore.setState({ selectedCalendarIds: [] }); + useCalendarStore.getState().toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID); + + await useCalendarStore.getState().fetchCalendars(clientReturning({ calendars: own })); + + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['a', 'b', BIRTHDAY_CALENDAR_ID]); + }); +}); + describe('reconcileSelectedIds with unloaded accounts', () => { it('keeps ids under an unloaded prefix verbatim instead of remapping them by raw id', () => { const calendars = [cal({ id: 'A@h::b', originalId: 'b', localAccountId: 'A@h' })]; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 55af17e00..d502b4947 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -527,6 +527,8 @@ export function reconcileSelectedIds( * default when no real calendar is left in it. The virtual birthday calendar * always survives reconciliation, so it must not count - counting it pinned * the selection to "birthdays only" once the real calendars had dropped out. + * A selection the user emptied by hiding every calendar (`chosen`) stays + * empty; one that named calendars which are gone now falls back. */ function selectionAfterFetch( selectedCalendarIds: string[], @@ -534,9 +536,11 @@ function selectionAfterFetch( defaultSelected: string[], unloadedPrefixes?: string[], rawOwner?: string, + chosen = false, ): string[] { const reconciled = reconcileSelectedIds(selectedCalendarIds, calendars, unloadedPrefixes, rawOwner); if (reconciled.some((id) => id !== BIRTHDAY_CALENDAR_ID)) return reconciled; + if (chosen && !selectedCalendarIds.some((id) => id !== BIRTHDAY_CALENDAR_ID)) return reconciled; return reconciled.includes(BIRTHDAY_CALENDAR_ID) ? [...defaultSelected, BIRTHDAY_CALENDAR_ID] : defaultSelected; } @@ -770,6 +774,8 @@ interface CalendarStore { selectedDate: Date; viewMode: CalendarViewMode; selectedCalendarIds: string[]; + /** The user picked the visible calendars by hand: an empty pick hides them all. */ + calendarSelectionChosen: boolean; selectedEventId: string | null; isLoading: boolean; isLoadingEvents: boolean; @@ -857,6 +863,7 @@ const initialState = { events: [], selectedDate: displayNow(), selectedCalendarIds: [] as string[], + calendarSelectionChosen: false, selectedEventId: null as string | null, isLoading: false, isLoadingEvents: false, @@ -928,6 +935,8 @@ export const useCalendarStore = create()( defaultSelected, // Shared calendars' ids are `:` (getAllCalendars). failedAccountIds.map((accountId) => `${accountId}:`), + undefined, + get().calendarSelectionChosen, ), }); } catch (error) { @@ -1014,6 +1023,7 @@ export const useCalendarStore = create()( calendars.map(c => c.id), unloadedPrefixes, activeLocalAccountId ?? undefined, + get().calendarSelectionChosen, ), }); } catch (error) { @@ -1653,6 +1663,8 @@ export const useCalendarStore = create()( selectedCalendarIds: visible ? [...state.selectedCalendarIds, calendarId] : state.selectedCalendarIds.filter(id => id !== calendarId), + // Showing birthdays is a setting, not a pick of calendars. + calendarSelectionChosen: state.calendarSelectionChosen || calendarId !== BIRTHDAY_CALENDAR_ID, })); void calendarHooks.onCalendarVisibilityToggle.emit({ calendarId, visible }); }, @@ -2060,6 +2072,7 @@ export const useCalendarStore = create()( }, partialize: (state) => ({ selectedCalendarIds: state.selectedCalendarIds, + calendarSelectionChosen: state.calendarSelectionChosen, viewMode: state.viewMode, icalSubscriptions: state.icalSubscriptions, deletedSubscriptionIds: state.deletedSubscriptionIds,