fix: keep VTODO-only task lists out of the Calendars drawer #28

Stalwart exposes task lists (supported-calendar-component-set = VTODO)
as sibling CalDAV collections, so Calendar/get returns them mixed in
with event calendars and the drawer showed confusing duplicate entries
(Todoist Inbox, per-project task lists colliding with same-named
event calendars).

JMAP Calendar/get doesn't surface the CalDAV component set, so the
store now infers the collection kind from its contents: a calendar
whose objects are all Tasks (>=1 task, no events) is flagged as a task
list. Flagged calendars are excluded from the Calendars drawer, the
event-creation calendar picker, and the ICS import target picker, and
sorted first in the Tasks sheet so new tasks default into a dedicated
task list. Empty calendars and mixed event+task calendars are
untouched.
This commit is contained in:
Linus Rath
2026-07-22 21:37:27 +02:00
parent 05e2af23dd
commit 2427bdbd79
3 changed files with 93 additions and 6 deletions
+31 -5
View File
@@ -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() {
<EventModal
visible={modalVisible}
event={modalEvent}
calendars={allCalendars}
calendars={eventCalendars}
defaultDate={modalDate}
onSave={handleSave}
onDelete={handleDeleteFromModal}
@@ -537,7 +560,7 @@ export default function CalendarScreen() {
<CalendarSidebarDrawer
visible={sidebarVisible}
calendars={allCalendars}
calendars={eventCalendars}
hiddenCalendarIds={hiddenCalendarIds}
onToggle={toggleCalendarVisibility}
onClose={() => setSidebarVisible(false)}
@@ -555,7 +578,7 @@ export default function CalendarScreen() {
<TasksSheet
visible={tasksVisible}
tasks={tasks}
calendars={allCalendars}
calendars={taskSheetCalendars}
onClose={() => setTasksVisible(false)}
onCreate={createTask}
onToggle={toggleTaskComplete}
@@ -565,8 +588,11 @@ export default function CalendarScreen() {
<ICalImportSheet
visible={importVisible}
// Import batch-creates against the primary account, so only offer the
// user's own calendars as targets.
calendars={displayCalendars.filter((cal) => !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}
/>
@@ -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
+25 -1
View File
@@ -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<CalendarState>()(
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
hiddenCalendarIds: [],
loadedRange: null,
loading: false,
@@ -203,8 +210,23 @@ export const useCalendarStore = create<CalendarState>()(
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<string>();
const taskCalendarIds = new Set<string>();
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<CalendarState>()(
calendars: [],
events: [],
tasks: [],
taskOnlyCalendarIds: [],
loadedRange: null,
loading: false,
error: null,
@@ -449,6 +472,7 @@ export const useCalendarStore = create<CalendarState>()(
calendars: state.calendars,
events: state.events,
tasks: state.tasks,
taskOnlyCalendarIds: state.taskOnlyCalendarIds,
loadedRange: state.loadedRange,
}),
},