mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user