diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index b8426f6c..8207bf8e 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -163,6 +163,7 @@ export default function CalendarScreen() { const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const storeCalendars = useCalendarStore((s) => s.calendars); + const taskOnlyCalendarIds = useCalendarStore((s) => s.taskOnlyCalendarIds); const hiddenCalendarIds = useCalendarStore((s) => s.hiddenCalendarIds); const loading = useCalendarStore((s) => s.loading); const error = useCalendarStore((s) => s.error); @@ -218,6 +219,28 @@ export default function CalendarScreen() { [storeEvents, birthdayEvents], ); + // VTODO-only task lists (Todoist imports, per-project Thunderbird task + // lists) are sibling CalDAV collections that Calendar/get returns alongside + // event calendars. Their contents surface in the Tasks sheet, so keep them + // out of the calendar drawer and out of the event/import calendar pickers — + // listing them as calendars just produces duplicate/confusing entries. (#28) + const eventCalendars = React.useMemo(() => { + if (taskOnlyCalendarIds.length === 0) return allCalendars; + const taskOnly = new Set(taskOnlyCalendarIds); + return allCalendars.filter((cal) => !taskOnly.has(cal.id)); + }, [allCalendars, taskOnlyCalendarIds]); + + // The Tasks sheet keeps the full list but with task lists sorted first, so + // its create-picker (which defaults to the first writable calendar) targets + // a dedicated task list when the account has one. + const taskSheetCalendars = React.useMemo(() => { + if (taskOnlyCalendarIds.length === 0) return allCalendars; + const taskOnly = new Set(taskOnlyCalendarIds); + return [...allCalendars].sort( + (a, b) => Number(taskOnly.has(b.id)) - Number(taskOnly.has(a.id)), + ); + }, [allCalendars, taskOnlyCalendarIds]); + const calendars = React.useMemo( () => allCalendars.filter((c) => !hiddenCalendarIds.includes(c.id)), [allCalendars, hiddenCalendarIds], @@ -521,7 +544,7 @@ export default function CalendarScreen() { setSidebarVisible(false)} @@ -555,7 +578,7 @@ export default function CalendarScreen() { setTasksVisible(false)} onCreate={createTask} onToggle={toggleTaskComplete} @@ -565,8 +588,11 @@ export default function CalendarScreen() { !cal.isShared)} + // user's own event calendars (no shared calendars, no task lists) as + // targets. + calendars={eventCalendars.filter( + (cal) => !cal.isShared && cal.id !== BIRTHDAY_CALENDAR_ID, + )} onClose={() => setImportVisible(false)} onImport={importEvents} /> diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index ed420d2d..3f6f4f9d 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -45,6 +45,8 @@ beforeEach(() => { useCalendarStore.setState({ calendars: [], events: [], + tasks: [], + taskOnlyCalendarIds: [], hiddenCalendarIds: [], loadedRange: null, loading: false, @@ -130,6 +132,41 @@ describe('calendar-store', () => { expect(stored[0].accountId).toBe('acc-2'); }); + it('classifies calendars whose contents are all Tasks as task lists (#28)', async () => { + // VTODO-only CalDAV collections (Todoist imports, Thunderbird task + // lists) must be flagged so the drawer can keep them out of the + // calendar list. Mixed calendars, calendars with start-less Events, + // and empty calendars all stay ordinary calendars. + mockQueryEvents.mockResolvedValue(['t1', 'e1', 't2', 'startless']); + mockGetEvents.mockResolvedValue([ + { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-tasks': true } }, + { id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-mixed': true } }, + { id: 't2', '@type': 'Task', title: 'Ship it', calendarIds: { 'cal-mixed': true } }, + { id: 'startless', '@type': 'Event', calendarIds: { 'cal-startless': true } }, + ]); + + await useCalendarStore.getState().fetchEvents( + ['cal-tasks', 'cal-mixed', 'cal-startless', 'cal-empty'], + '2026-03-01', + '2026-03-31', + ); + + expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual(['cal-tasks']); + expect(useCalendarStore.getState().tasks.map((t) => t.id)).toEqual(['t1', 't2']); + }); + + it('reclassifies task lists on refetch instead of accumulating stale ids', async () => { + useCalendarStore.setState({ taskOnlyCalendarIds: ['cal-old'] }); + mockQueryEvents.mockResolvedValue(['e1']); + mockGetEvents.mockResolvedValue([ + { id: 'e1', '@type': 'Event', start: '2026-03-15T10:00:00', calendarIds: { 'cal-old': true } }, + ]); + + await useCalendarStore.getState().fetchEvents(['cal-old'], '2026-03-01', '2026-03-31'); + + expect(useCalendarStore.getState().taskOnlyCalendarIds).toEqual([]); + }); + it('routes queries by account when own and shared calendars share a raw id', async () => { // The user's own "default" calendar and a family group's "default" // calendar collide on the raw JMAP id. The shared one is namespaced diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index a5085316..06eaf28f 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -65,6 +65,12 @@ export interface CalendarState { calendars: Calendar[]; events: CalendarEvent[]; tasks: CalendarEvent[]; + // Store ids of calendars that hold only Task objects (VTODO-only CalDAV + // collections, e.g. Thunderbird/Todoist task lists). JMAP Calendar/get + // doesn't expose the CalDAV supported-calendar-component-set, so the + // collection kind is inferred from contents: ≥1 task and no events. + // Empty calendars are treated as ordinary event calendars. (#28) + taskOnlyCalendarIds: string[]; hiddenCalendarIds: string[]; loadedRange: LoadedRange | null; loading: boolean; @@ -124,6 +130,7 @@ export const useCalendarStore = create()( calendars: [], events: [], tasks: [], + taskOnlyCalendarIds: [], hiddenCalendarIds: [], loadedRange: null, loading: false, @@ -203,8 +210,23 @@ export const useCalendarStore = create()( return (t === 'Event' || t === undefined) && !!e.start; }); const tasks = raw.filter((e) => (e as { '@type'?: string })['@type'] === 'Task'); + // Classify VTODO-only task lists from the full (undated) object set: + // a calendar counts as a task list when every object in it is a Task. + // Anything that isn't a Task — including start-less Events — keeps the + // calendar in the ordinary event-calendar bucket. + const eventCalendarIds = new Set(); + const taskCalendarIds = new Set(); + for (const obj of raw) { + const isTask = (obj as { '@type'?: string })['@type'] === 'Task'; + for (const calId of Object.keys(obj.calendarIds || {})) { + (isTask ? taskCalendarIds : eventCalendarIds).add(calId); + } + } + const taskOnlyCalendarIds = [...taskCalendarIds].filter( + (calId) => !eventCalendarIds.has(calId), + ); const events = expandRecurringEvents(onlyEvents, after, before); - set({ events, tasks, loadedRange: { after, before }, loading: false }); + set({ events, tasks, taskOnlyCalendarIds, loadedRange: { after, before }, loading: false }); } catch (err) { set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' }); } @@ -434,6 +456,7 @@ export const useCalendarStore = create()( calendars: [], events: [], tasks: [], + taskOnlyCalendarIds: [], loadedRange: null, loading: false, error: null, @@ -449,6 +472,7 @@ export const useCalendarStore = create()( calendars: state.calendars, events: state.events, tasks: state.tasks, + taskOnlyCalendarIds: state.taskOnlyCalendarIds, loadedRange: state.loadedRange, }), },