feat: add task management features to calendar
This commit is contained in:
@@ -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
@@ -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'; }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user