diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index d181b8ab..0817e758 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -448,7 +448,8 @@ export function CalendarWeekView({ {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( (() => { const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); - const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + if (endMin <= startMin) endMin = 1440; const durationMin = Math.max(15, endMin - startMin); const cal = calendars.find(c => c.id === pendingPreview.calendarId); const color = cal?.color || "hsl(var(--primary))"; diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index b6a8874d..5af93c9d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -59,10 +59,12 @@ function buildDuration(startDate: Date, endDate: Date): string { const minutes = totalMinutes % 60; let dur = "P"; if (days > 0) dur += `${days}D`; - dur += "T"; - if (hours > 0) dur += `${hours}H`; - if (minutes > 0) dur += `${minutes}M`; - if (dur === "PT") dur = "PT0M"; + if (hours > 0 || minutes > 0) { + dur += "T"; + if (hours > 0) dur += `${hours}H`; + if (minutes > 0) dur += `${minutes}M`; + } + if (dur === "P") dur = "PT0M"; return dur; } @@ -83,9 +85,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType = { + uid: newUid, title: event.title, description: event.description, start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index fc5acbb4..ac29bacb 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -6,6 +6,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; +import { parseDuration } from '@/components/calendar/event-card'; export interface PendingAlert { eventId: string; @@ -17,19 +18,20 @@ export interface PendingAlert { const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes -const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; +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 days = parseInt(match[2] || '0', 10); - const hours = parseInt(match[3] || '0', 10); - const minutes = parseInt(match[4] || '0', 10); - const seconds = parseInt(match[5] || '0', 10); + 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 = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; + const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; return negative ? -ms : ms; } @@ -47,9 +49,15 @@ export function computeFireTime( let baseTime: number; if (trigger.relativeTo === 'end') { - baseTime = event.utcEnd - ? new Date(event.utcEnd).getTime() - : new Date(event.start).getTime(); + 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() @@ -68,6 +76,7 @@ export function getEffectiveAlerts( return event.alerts; } + if (!event.calendarIds) return null; const calendarId = Object.keys(event.calendarIds)[0]; if (!calendarId) return null; diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index ad454a14..5925aadf 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -251,10 +251,11 @@ function looksLikeReply(event: Partial): boolean { const participants = Object.values(event.participants); const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant)); - if (hasOrganizer) return false; + if (!hasOrganizer) return false; return participants.some((participant) => participant.roles?.attendee + && !isOrganizerParticipant(participant) && ( participant.participationStatus !== 'needs-action' || !!participant.participationComment @@ -373,6 +374,10 @@ export function getInvitationMethod( return 'cancel'; } + if (looksLikeReply(event)) { + return 'reply'; + } + if (event.participants && Object.keys(event.participants).length > 0) { const hasOrganizer = Object.values(event.participants).some( (p: CalendarParticipant) => isOrganizerParticipant(p) @@ -382,10 +387,6 @@ export function getInvitationMethod( } } - if (looksLikeReply(event)) { - return 'reply'; - } - return 'unknown'; } @@ -524,36 +525,40 @@ export function formatEventSummary(event: Partial): EventSummary } function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null { - const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); + const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); if (!match) return null; - const days = parseInt(match[1] || '0'); - const hours = parseInt(match[2] || '0'); - const minutes = parseInt(match[3] || '0'); - const seconds = parseInt(match[4] || '0'); + const weeks = parseInt(match[1] || '0'); + const days = parseInt(match[2] || '0') + weeks * 7; + const hours = parseInt(match[3] || '0'); + const minutes = parseInt(match[4] || '0'); + const seconds = parseInt(match[5] || '0'); const date = new Date(start); if (isNaN(date.getTime())) return null; + const isUTC = start.endsWith('Z') || start.includes('+'); + + if (isUTC) { + date.setUTCDate(date.getUTCDate() + days); + date.setUTCHours(date.getUTCHours() + hours); + date.setUTCMinutes(date.getUTCMinutes() + minutes); + date.setUTCSeconds(date.getUTCSeconds() + seconds); + return date.toISOString(); + } + date.setDate(date.getDate() + days); date.setHours(date.getHours() + hours); date.setMinutes(date.getMinutes() + minutes); date.setSeconds(date.getSeconds() + seconds); - // If the input is a local datetime (no UTC 'Z' suffix), return a local - // format string so that all-day date arithmetic isn't shifted by the - // browser's UTC offset (toISOString converts to UTC). - if (!start.endsWith('Z') && !start.includes('+')) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - const h = String(date.getHours()).padStart(2, '0'); - const min = String(date.getMinutes()).padStart(2, '0'); - const s = String(date.getSeconds()).padStart(2, '0'); - return `${y}-${m}-${d}T${h}:${min}:${s}`; - } - - return date.toISOString(); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d}T${h}:${min}:${s}`; } export function findParticipantByEmail( diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index a2f25364..b5cc94eb 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -15,11 +15,30 @@ export interface StatusCounts { 'needs-action': number; } +/** + * Check if a participant matches any of the given email addresses. + * Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values. + */ +function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): boolean { + if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true; + if (p.calendarAddress) { + const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase(); + if (addr && lowerEmails.includes(addr)) return true; + } + if (p.sendTo) { + for (const addr of Object.values(p.sendTo)) { + const normalized = addr.replace(/^mailto:/i, '').toLowerCase(); + if (normalized && lowerEmails.includes(normalized)) return true; + } + } + return false; +} + export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean { if (!event.participants) return false; const lower = userEmails.map(e => e.toLowerCase()); return Object.values(event.participants).some(p => - p.roles?.owner && lower.includes(p.email?.toLowerCase()) + p.roles?.owner && participantMatchesEmail(p, lower) ); } @@ -27,7 +46,7 @@ export function getUserParticipantId(event: CalendarEvent, userEmails: string[]) if (!event.participants) return null; const lower = userEmails.map(e => e.toLowerCase()); for (const [id, p] of Object.entries(event.participants)) { - if (lower.includes(p.email?.toLowerCase())) return id; + if (participantMatchesEmail(p, lower)) return id; } return null; } @@ -39,20 +58,29 @@ export function getUserStatus( if (!event.participants) return null; const lower = userEmails.map(e => e.toLowerCase()); for (const p of Object.values(event.participants)) { - if (lower.includes(p.email?.toLowerCase())) return p.participationStatus; + if (participantMatchesEmail(p, lower)) return p.participationStatus; } return null; } export function getParticipantList(event: CalendarEvent): ParticipantInfo[] { if (!event.participants) return []; - return Object.entries(event.participants).map(([id, p]) => ({ - id, - name: p.name || '', - email: p.email || '', - status: p.participationStatus || 'needs-action', - isOrganizer: !!p.roles?.owner, - })); + return Object.entries(event.participants).map(([id, p]) => { + let email = p.email || ''; + if (!email && p.calendarAddress) { + email = p.calendarAddress.replace(/^mailto:/i, ''); + } + if (!email && p.sendTo?.imip) { + email = p.sendTo.imip.replace(/^mailto:/i, ''); + } + return { + id, + name: p.name || '', + email, + status: p.participationStatus || 'needs-action', + isOrganizer: !!p.roles?.owner, + }; + }); } export function getStatusCounts(event: CalendarEvent): StatusCounts { @@ -76,7 +104,11 @@ export function buildParticipantMap( ): Record> { const participants: Record> = {}; - participants['organizer'] = { + const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + participants[generateId()] = { '@type': 'Participant', name: organizer.name, email: organizer.email, @@ -88,8 +120,8 @@ export function buildParticipantMap( kind: 'individual', }; - attendees.forEach((a, i) => { - participants[`attendee-${i}`] = { + attendees.forEach((a) => { + participants[generateId()] = { '@type': 'Participant', name: a.name, email: a.email, diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index ac1df05e..17f63209 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -40,9 +40,7 @@ export function normalizeAllDayDuration(duration: string | undefined): string | } export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string { - const startDay = startOfDay(start); - const endDay = startOfDay(inclusiveEnd); - const dayCount = Math.max(1, Math.round((endDay.getTime() - startDay.getTime()) / 86400000) + 1); + const dayCount = Math.max(1, differenceInCalendarDays(startOfDay(inclusiveEnd), startOfDay(start)) + 1); return `P${dayCount}D`; } @@ -113,7 +111,7 @@ export function layoutOverlappingEvents( for (const event of sorted) { const start = parseISO(event.start); const startMin = start.getHours() * 60 + start.getMinutes(); - const endMin = startMin + Math.max(15, parseDuration(event.duration)); + const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration))); let placed = false; for (let col = 0; col < columns.length; col++) { if (columns[col].every(e => e.end <= startMin)) { @@ -135,8 +133,9 @@ export function layoutOverlappingEvents( } export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string { - const h = Math.floor(minutes / 60); - const m = minutes % 60; + const clamped = Math.max(0, Math.min(1440, minutes)); + const h = Math.floor(clamped / 60) % 24; + const m = clamped % 60; if (timeFormat === "12h") { return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 92addd39..7056dfa0 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; +import { debug } from "@/lib/debug"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { @@ -1684,7 +1685,7 @@ export class JMAPClient implements IJMAPClient { lines.push('END:VCALENDAR'); const icsContent = lines.join('\r\n') + '\r\n'; - console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent); + debug.log('[iMIP] Generated ICS:\n' + icsContent); const statusLabels: Record = { ACCEPTED: 'Accepted', @@ -1694,7 +1695,7 @@ export class JMAPClient implements IJMAPClient { const statusLabel = statusLabels[opts.status] || opts.status; const subject = `${statusLabel}: ${opts.summary || 'Event'}`; - console.log('[iMIP DEBUG] identityId:', finalIdentityId); + debug.log('[iMIP] identityId:', finalIdentityId); const emailId = `imip-reply-${Date.now()}`; const emailCreate: Record = { @@ -1727,27 +1728,27 @@ export class JMAPClient implements IJMAPClient { }, "1"], ]; - console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls'); - console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2)); + debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); + debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); const response = await this.request(methodCalls); - console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); + debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { - console.error('[iMIP DEBUG] method error:', methodName, result); + debug.error('[iMIP] method error:', methodName, result); throw new Error(result.description || `iMIP reply failed: ${result.type}`); } if (result.notCreated) { const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string }; - console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2)); + debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2)); throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply'); } } } - console.log('[iMIP DEBUG] sendImipReply completed successfully'); + debug.log('[iMIP] sendImipReply completed successfully'); } /** @@ -1823,6 +1824,9 @@ export class JMAPClient implements IJMAPClient { const formatted = formatIcalDate(event.utcEnd, event.timeZone); lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`); } + } else if (event.duration) { + // Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1) + lines.push(`DURATION:${event.duration}`); } if (event.title) lines.push(`SUMMARY:${event.title}`); @@ -1907,6 +1911,9 @@ export class JMAPClient implements IJMAPClient { */ async sendImipCancellation(event: CalendarEvent): Promise { if (!event.participants) return; + if (event.status && event.status !== 'cancelled') { + debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status); + } const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); @@ -3202,7 +3209,7 @@ export class JMAPClient implements IJMAPClient { if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true; return false; }).map((e) => { - const task = e as unknown as CalendarTask; + const task = { ...e } as unknown as CalendarTask; // Normalize @type for tasks detected by fallback heuristic if (task['@type'] !== 'Task') { (task as unknown as Record)['@type'] = 'Task'; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 27f09431..d05edc5e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -130,14 +130,17 @@ export const useCalendarStore = create()( let targetAccountId = event.accountId; const cleanEvent = { ...event }; if (event.calendarIds) { - const calId = Object.keys(event.calendarIds)[0]; - if (calId) { + const remapped: Record = {}; + for (const calId of Object.keys(event.calendarIds)) { const cal = get().calendars.find(c => c.id === calId); if (cal?.isShared && cal.originalId) { targetAccountId = cal.accountId; - cleanEvent.calendarIds = { [cal.originalId]: true }; + remapped[cal.originalId] = true; + } else { + remapped[calId] = true; } } + cleanEvent.calendarIds = remapped; } if (event.originalCalendarIds) { cleanEvent.calendarIds = event.originalCalendarIds; @@ -185,12 +188,18 @@ export const useCalendarStore = create()( if (realEvent) { const resolvedId = realEvent.originalId || realEvent.id; if (storeEvent.recurrenceId) { - // Recurring instance: patch the master event's recurrenceOverrides - const patchUpdates: Record = {}; + // Recurring instance: patch the master event's recurrenceOverrides. + // Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1 + const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1'); + // Build override object with all changed properties + const overrideObj: Record = {}; for (const [key, value] of Object.entries(cleanUpdates as Record)) { if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; - patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value; + overrideObj[key] = value; } + const patchUpdates: Record = { + [`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj, + }; await client.updateCalendarEvent( resolvedId, patchUpdates as unknown as Partial, @@ -261,12 +270,17 @@ export const useCalendarStore = create()( const resolvedId = realEvent.originalId || realEvent.id; if (storeEvent.recurrenceId) { // Recurring instance: patch RSVP as recurrence override on master - const overridePatch: Record = { - [`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status, + // Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1 + const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1'); + const overrideObj: Record = { + [patchKey]: status, }; if (replyTo) { - overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo; + overrideObj['replyTo'] = replyTo; } + const overridePatch: Record = { + [`recurrenceOverrides/${escapedRecId}`]: overrideObj, + }; await client.updateCalendarEvent( resolvedId, overridePatch as unknown as Partial, @@ -392,7 +406,7 @@ export const useCalendarStore = create()( imported++; } catch (error) { const msg = error instanceof Error ? error.message : ''; - if (msg.includes('already exists') && src.uid) { + if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) { const { events: storeEvents } = get(); const alreadyInStore = storeEvents.some((e) => e.uid === src.uid); if (alreadyInStore) {