diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index d535674..12b8af3 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -19,6 +19,7 @@ */ import ICAL from 'ical.js'; +import { classifyValarms } from './vevent.js'; /** * A concrete calendar event occurrence ready for UI consumption. @@ -33,6 +34,11 @@ import ICAL from 'ical.js'; * Color routing (D-06, CAL-02): * The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId) * The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId. + * + * Phase 11 Plan 03 (D-10): reminderLeadMinutes is a series-level property — all occurrences + * of a recurring master inherit the master's value. Derived from the VEVENT's VALARM via + * classifyValarms (same logic as sync.ts so the scheduler and the GET response agree). + * NULL-vs-0 is preserved (D-06): null = no reminder; 0 = same-day all-day; positive = lead. */ export interface CalendarOccurrence { /** `ev--` — Schedule-X-safe stable id (see makeOccurrenceId) */ @@ -66,6 +72,14 @@ export interface CalendarOccurrence { description: string | null; /** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */ hasRrule: boolean; + /** + * Per-event reminder lead in minutes (Phase 11, CAL-13, D-06). + * Series-level: all occurrences inherit the master's value (D-10). + * null — no reminder / custom/absolute VALARM not reducible to a single lead + * 0 — same-day all-day (fire at 9 AM on event date) + * positive — N minutes before event start (timed) or N/1440 days before (all-day) + */ + reminderLeadMinutes: number | null; } /** @@ -223,6 +237,16 @@ export function expandOccurrences( // Capture once — used in both the non-recurring and recurring branches to populate hasRrule. const isRecurring = event.isRecurring(); + // Phase 11 Plan 03 (D-10): series-level reminderLeadMinutes — derived once from the master + // event's VALARM via classifyValarms. All occurrences inherit this value (series-level, D-10). + // preset/offlist → specific leadMinutes; custom/none → null (D-07/NOTIF-05). + // classifyValarms wraps ICAL.parse in try/catch (T-11-07 safe); safe on parse failure → null. + const alarmClass = classifyValarms(rawVevent); + const reminderLeadMinutes: number | null = + alarmClass.kind === 'preset' || alarmClass.kind === 'offlist' + ? alarmClass.leadMinutes + : null; + // --- 4. Non-recurring event: single occurrence check --- if (!isRecurring) { if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) { @@ -258,6 +282,7 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always false in the non-recurring branch + reminderLeadMinutes, // series-level (D-10) }); } return occurrences; @@ -305,6 +330,7 @@ export function expandOccurrences( location: event.location ?? null, description: event.description ?? null, hasRrule: isRecurring, // always true in the recurring branch + reminderLeadMinutes, // series-level — all occurrences inherit the master's value (D-10) }); } diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 9a0432a..82d9b68 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -177,6 +177,8 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { userId: users.id, userColor: users.color, ownerName: users.displayName, + // Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10) + reminderLeadMinutes: calendarEvents.reminderLeadMinutes, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))