- Fix buildDuration() trailing "T" producing invalid ISO 8601 durations - Fix DURATION_RE missing week (W) support in alerts and invitation parsing - Fix computeFireTime() end fallback when utcEnd is missing - Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent) - Fix layoutOverlappingEvents endMin overflow past 1440 - Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs - Fix getEffectiveAlerts() null guard on calendarIds - Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays - Fix participant matching to check calendarAddress and sendTo (not just email) - Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs - Fix overnight preview negative endMin in week view - Fix sendImipInvitation() to emit DURATION when utcEnd is absent - Fix sendImipCancellation() to validate status before sending - Fix createEvent() to remap all calendarIds for shared calendars - Fix getCalendarTasks() to clone before mutating @type - Fix importEvents() error matching to include 'duplicate' and 'conflict' - Fix looksLikeReply() false positive by requiring organizer + responded attendee - Fix alert offset regex to require T before minutes - Fix handleDuplicate() to generate new UID - Fix formatSnapTime() input clamping - Replace console.log/error with debug.log/error/warn in iMIP functions
199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
import type {
|
|
CalendarEvent,
|
|
CalendarEventAlert,
|
|
CalendarOffsetTrigger,
|
|
CalendarAbsoluteTrigger,
|
|
Calendar,
|
|
CalendarTask,
|
|
} from '@/lib/jmap/types';
|
|
import { parseDuration } from '@/components/calendar/event-card';
|
|
|
|
export interface PendingAlert {
|
|
eventId: string;
|
|
alertId: string;
|
|
fireTimeMs: number;
|
|
event: CalendarEvent;
|
|
calendarName: string | null;
|
|
}
|
|
|
|
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
|
const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
|
|
export function parseAlertOffset(offset: string): number | null {
|
|
const match = DURATION_RE.exec(offset);
|
|
if (!match) return null;
|
|
|
|
const negative = match[1] === '-';
|
|
const weeks = parseInt(match[2] || '0', 10);
|
|
const days = parseInt(match[3] || '0', 10);
|
|
const hours = parseInt(match[4] || '0', 10);
|
|
const minutes = parseInt(match[5] || '0', 10);
|
|
const seconds = parseInt(match[6] || '0', 10);
|
|
|
|
const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000;
|
|
return negative ? -ms : ms;
|
|
}
|
|
|
|
export function computeFireTime(
|
|
event: CalendarEvent,
|
|
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;
|
|
|
|
let baseTime: number;
|
|
if (trigger.relativeTo === 'end') {
|
|
if (event.utcEnd) {
|
|
baseTime = new Date(event.utcEnd).getTime();
|
|
} else {
|
|
// Compute end from start + duration
|
|
const startMs = new Date(event.start).getTime();
|
|
if (Number.isNaN(startMs)) return null;
|
|
const durationMin = parseDuration(event.duration);
|
|
baseTime = startMs + durationMin * 60000;
|
|
}
|
|
} else {
|
|
baseTime = event.utcStart
|
|
? new Date(event.utcStart).getTime()
|
|
: new Date(event.start).getTime();
|
|
}
|
|
|
|
if (Number.isNaN(baseTime)) return null;
|
|
return baseTime + offsetMs;
|
|
}
|
|
|
|
export function getEffectiveAlerts(
|
|
event: CalendarEvent,
|
|
calendars: Calendar[]
|
|
): Record<string, CalendarEventAlert> | null {
|
|
if (!event.useDefaultAlerts) {
|
|
return event.alerts;
|
|
}
|
|
|
|
if (!event.calendarIds) return null;
|
|
const calendarId = Object.keys(event.calendarIds)[0];
|
|
if (!calendarId) return null;
|
|
|
|
const calendar = calendars.find(c => c.id === calendarId);
|
|
if (!calendar) return null;
|
|
|
|
if (event.showWithoutTime) {
|
|
return calendar.defaultAlertsWithoutTime;
|
|
}
|
|
return calendar.defaultAlertsWithTime;
|
|
}
|
|
|
|
export function buildAlertKey(eventId: string, alertId: string, fireTimeMs: number): string {
|
|
return `${eventId}:${alertId}:${fireTimeMs}`;
|
|
}
|
|
|
|
export function getPendingAlerts(
|
|
events: CalendarEvent[],
|
|
calendars: Calendar[],
|
|
acknowledgedKeys: Set<string>,
|
|
now: number
|
|
): PendingAlert[] {
|
|
const pending: PendingAlert[] = [];
|
|
|
|
for (const event of events) {
|
|
const alerts = getEffectiveAlerts(event, calendars);
|
|
if (!alerts) continue;
|
|
|
|
const calendar = calendars.find(c => c.id === Object.keys(event.calendarIds)[0]) ?? null;
|
|
|
|
for (const [alertId, alert] of Object.entries(alerts)) {
|
|
if (alert.action !== 'display') continue;
|
|
if (alert.acknowledged) continue;
|
|
|
|
const fireTimeMs = computeFireTime(event, alert.trigger);
|
|
if (fireTimeMs === null) continue;
|
|
if (fireTimeMs > now) continue;
|
|
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
|
|
|
|
const key = buildAlertKey(event.id, alertId, fireTimeMs);
|
|
if (acknowledgedKeys.has(key)) continue;
|
|
|
|
pending.push({
|
|
eventId: event.id,
|
|
alertId,
|
|
fireTimeMs,
|
|
event,
|
|
calendarName: calendar?.name ?? null,
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|