feat: add task management features to calendar

This commit is contained in:
Linus Rath
2026-03-21 20:45:22 +01:00
parent 64c3d7e384
commit 30284859c7
20 changed files with 1190 additions and 46 deletions
+66
View File
@@ -4,6 +4,7 @@ import type {
CalendarOffsetTrigger,
CalendarAbsoluteTrigger,
Calendar,
CalendarTask,
} from '@/lib/jmap/types';
export interface PendingAlert {
@@ -121,3 +122,68 @@ export function getPendingAlerts(
return pending;
}
export interface PendingTaskAlert {
taskId: string;
alertId: string;
fireTimeMs: number;
task: CalendarTask;
calendarName: string | null;
}
export function computeTaskFireTime(
task: CalendarTask,
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger
): number | null {
if (trigger['@type'] === 'AbsoluteTrigger') {
const t = new Date(trigger.when).getTime();
return Number.isNaN(t) ? null : t;
}
const offsetMs = parseAlertOffset(trigger.offset);
if (offsetMs === null) return null;
if (!task.due) return null;
const baseTime = new Date(task.due).getTime();
if (Number.isNaN(baseTime)) return null;
return baseTime + offsetMs;
}
export function getPendingTaskAlerts(
tasks: CalendarTask[],
calendars: Calendar[],
acknowledgedKeys: Set<string>,
now: number
): PendingTaskAlert[] {
const pending: PendingTaskAlert[] = [];
for (const task of tasks) {
if (!task.alerts) continue;
if (task.progress === 'completed' || task.progress === 'cancelled') continue;
const calendar = calendars.find(c => c.id === Object.keys(task.calendarIds)[0]) ?? null;
for (const [alertId, alert] of Object.entries(task.alerts)) {
if (alert.action !== 'display') continue;
if (alert.acknowledged) continue;
const fireTimeMs = computeTaskFireTime(task, alert.trigger);
if (fireTimeMs === null) continue;
if (fireTimeMs > now) continue;
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
const key = buildAlertKey(task.id, alertId, fireTimeMs);
if (acknowledgedKeys.has(key)) continue;
pending.push({
taskId: task.id,
alertId,
fireTimeMs,
task,
calendarName: calendar?.name ?? null,
});
}
}
return pending;
}
+50 -1
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -621,6 +621,55 @@ export class DemoJMAPClient implements IJMAPClient {
return []; // no-op in demo
}
// ── Calendar Tasks ────────────────────────────────────────────
async getCalendarTasks(calendarIds?: string[]): Promise<CalendarTask[]> {
let tasks = this.data.calendarTasks || [];
if (calendarIds) {
tasks = tasks.filter(t => Object.keys(t.calendarIds).some(id => calendarIds.includes(id)));
}
return [...tasks];
}
async createCalendarTask(task: Partial<CalendarTask>): Promise<CalendarTask> {
const full: CalendarTask = {
id: generateDemoId('task'),
uid: generateDemoId('task-uid'),
'@type': 'Task',
calendarIds: task.calendarIds || { [this.data.calendars[0]?.id || 'cal-1']: true },
title: task.title || '',
description: task.description || '',
due: task.due || null,
start: task.start || null,
duration: task.duration || null,
timeZone: task.timeZone || null,
showWithoutTime: task.showWithoutTime ?? true,
progress: task.progress || 'needs-action',
progressUpdated: null,
priority: task.priority || 0,
privacy: task.privacy || 'public',
keywords: task.keywords || null,
categories: task.categories || null,
color: task.color || null,
created: new Date().toISOString(),
updated: new Date().toISOString(),
recurrenceRules: task.recurrenceRules || null,
alerts: task.alerts || null,
relatedTo: task.relatedTo || null,
};
this.data.calendarTasks.push(full);
return full;
}
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>): Promise<void> {
const task = this.data.calendarTasks.find(t => t.id === taskId);
if (task) Object.assign(task, updates, { updated: new Date().toISOString() });
}
async deleteCalendarTask(taskId: string): Promise<void> {
this.data.calendarTasks = this.data.calendarTasks.filter(t => t.id !== taskId);
}
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string { return 'demo-account'; }
+4 -1
View File
@@ -3,12 +3,13 @@ import { createDemoMailboxes } from './fixtures/mailboxes';
import { createDemoEmails } from './fixtures/emails';
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
import { createDemoCalendarTasks } from './fixtures/tasks';
import { createDemoIdentities } from './fixtures/identities';
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
import { createDemoFileNodes } from './fixtures/files';
import { createDemoVacationResponse } from './fixtures/vacation';
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, CalendarTask, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
export interface DemoData {
@@ -18,6 +19,7 @@ export interface DemoData {
addressBooks: AddressBook[];
calendars: Calendar[];
calendarEvents: CalendarEvent[];
calendarTasks: CalendarTask[];
identities: Identity[];
sieveScripts: SieveScript[];
sieveCapabilities: SieveCapabilities;
@@ -35,6 +37,7 @@ export function getDemoData(): DemoData {
addressBooks: createDemoAddressBooks(),
calendars: createDemoCalendars(),
calendarEvents: createDemoCalendarEvents(),
calendarTasks: createDemoCalendarTasks(),
identities: createDemoIdentities(),
sieveScripts: createDemoSieveScripts(),
sieveCapabilities: createDemoSieveCapabilities(),
+140
View File
@@ -0,0 +1,140 @@
import type { CalendarTask } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
export function createDemoCalendarTasks(): CalendarTask[] {
return [
{
id: 'demo-task-1',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-1',
title: 'Buy groceries',
description: 'Milk, bread, eggs, and vegetables',
due: demoDate(0, 2),
start: null,
duration: null,
timeZone: null,
showWithoutTime: false,
progress: 'needs-action',
progressUpdated: null,
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-3),
updated: demoDate(-1),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-2',
calendarIds: { 'demo-calendar-work': true },
'@type': 'Task',
uid: 'demo-task-uid-2',
title: 'Prepare quarterly report',
description: 'Compile Q4 metrics and send to team',
due: demoDate(1, 4),
start: null,
duration: null,
timeZone: null,
showWithoutTime: false,
progress: 'in-process',
progressUpdated: demoDate(-1),
priority: 1,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-5),
updated: demoDate(0),
recurrenceRules: null,
alerts: {
'demo-alert-1': {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
action: 'display',
acknowledged: null,
relatedTo: null,
},
},
relatedTo: null,
},
{
id: 'demo-task-3',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-3',
title: 'Schedule dentist appointment',
description: '',
due: demoDate(3),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
progressUpdated: null,
priority: 5,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-2),
updated: demoDate(-2),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-4',
calendarIds: { 'demo-calendar-work': true },
'@type': 'Task',
uid: 'demo-task-uid-4',
title: 'Review pull requests',
description: 'Review open PRs from the team',
due: demoDate(-1),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'completed',
progressUpdated: demoDate(0),
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-4),
updated: demoDate(0),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-5',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-5',
title: 'Pay electricity bill',
description: '',
due: demoDate(-2),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
progressUpdated: null,
priority: 1,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-7),
updated: demoDate(-7),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
];
}
+7 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -201,6 +201,12 @@ export interface IJMAPClient {
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
// ── Calendar Tasks ────────────────────────────────────────────
getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]>;
createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask>;
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
+29 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -3173,6 +3173,34 @@ export class JMAPClient implements IJMAPClient {
return { destroyed, notDestroyed };
}
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
try {
const events = await this.getCalendarEvents(calendarIds, targetAccountId);
return events.filter((e): e is CalendarTask & CalendarEvent =>
(e as unknown as CalendarTask)['@type'] === 'Task'
) as unknown as CalendarTask[];
} catch (error) {
console.error('Failed to get calendar tasks:', error);
return [];
}
}
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>;
const created = await this.createCalendarEvent(event, false, targetAccountId);
return created as unknown as CalendarTask;
}
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
}
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
await this.deleteCalendarEvent(taskId, false, targetAccountId);
}
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
supportsFiles(): boolean {