Files
webmail/lib/__tests__/calendar-component-detection.test.ts
T
Stefan Hildebrandt 9c084d9d63 fix(calendar): hide tasks-only calendars from the event calendar UI
A calendar holding only tasks (VTODO, e.g. from tasks.org) appeared in the
event calendar UI as an empty calendar. Stalwart's Calendar/get exposes no
supported-component set, so tasks-only-ness is derived from the calendar's
objects (mirroring the existing event/task split): a calendar is tasks-only
iff it holds >=1 object and every object is a task.

- getTasksOnlyCalendarIds scans an account's objects (CalendarEvent/query +
  minimal /get), early-exits once every calendar has an event, only marks
  after a full scan, and fails open on the cap or any error.
- getAllCalendars/getCalendars annotate calendars with isTasksOnly.
- Such calendars stay in the store for the tasks view (task picker, task-list
  grouping) but are filtered from the event sidebar and default selection.

Verified against Stalwart v0.16.16: the tasks-only calendar is flagged, the
default (with an event) is not.

Closes #761.
2026-08-16 21:18:57 +02:00

69 lines
2.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { isTaskLikeObject, findTasksOnlyCalendarIds, type ScannedCalendarObject } from '@/lib/calendar-component-detection';
describe('isTaskLikeObject', () => {
it('treats an explicit @type Task as a task', () => {
expect(isTaskLikeObject({ '@type': 'Task' })).toBe(true);
expect(isTaskLikeObject({ '@type': 'task' })).toBe(true);
});
it('treats an @type Event as not a task even with a stray field', () => {
expect(isTaskLikeObject({ '@type': 'Event' })).toBe(false);
expect(isTaskLikeObject({ '@type': 'Event', due: '2026-01-01T00:00:00' })).toBe(false);
});
it('detects a CalDAV task lacking @type by its task-only fields', () => {
expect(isTaskLikeObject({ due: '2026-01-01T00:00:00' })).toBe(true);
expect(isTaskLikeObject({ progress: 'needs-action' })).toBe(true);
expect(isTaskLikeObject({ percentComplete: 0 })).toBe(true);
});
it('treats a plain object with no task markers as an event', () => {
expect(isTaskLikeObject({})).toBe(false);
expect(isTaskLikeObject({ '@type': 'Event', title: 'Standup' })).toBe(false);
});
});
describe('findTasksOnlyCalendarIds', () => {
it('flags a calendar whose objects are all tasks', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Task', calendarIds: { 'cal-tasks': true } },
{ due: '2026-01-01T00:00:00', calendarIds: { 'cal-tasks': true } },
];
expect([...findTasksOnlyCalendarIds(objects, ['cal-tasks'])]).toEqual(['cal-tasks']);
});
it('does not flag a calendar that has at least one event', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Task', calendarIds: { 'cal-mixed': true } },
{ '@type': 'Event', calendarIds: { 'cal-mixed': true } },
];
expect(findTasksOnlyCalendarIds(objects, ['cal-mixed']).size).toBe(0);
});
it('does not flag an empty calendar (no objects)', () => {
expect(findTasksOnlyCalendarIds([], ['cal-empty']).size).toBe(0);
const objects: ScannedCalendarObject[] = [{ '@type': 'Event', calendarIds: { 'cal-other': true } }];
expect(findTasksOnlyCalendarIds(objects, ['cal-empty']).size).toBe(0);
});
it('classifies each calendar independently in a mixed account', () => {
const objects: ScannedCalendarObject[] = [
{ '@type': 'Event', calendarIds: { work: true } },
{ '@type': 'Task', calendarIds: { todos: true } },
{ percentComplete: 50, calendarIds: { todos: true } },
];
const result = findTasksOnlyCalendarIds(objects, ['work', 'todos', 'empty']);
expect([...result]).toEqual(['todos']);
});
it('handles an object that belongs to several calendars', () => {
// An event shared into two calendars keeps both off the tasks-only list.
const objects: ScannedCalendarObject[] = [
{ '@type': 'Event', calendarIds: { a: true, b: true } },
{ '@type': 'Task', calendarIds: { b: true } },
];
expect(findTasksOnlyCalendarIds(objects, ['a', 'b']).size).toBe(0);
});
});