diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index d8f1bb51..1d815207 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft } from "lucide-react"; +import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; import { format, parseISO, addHours, addDays } from "date-fns"; -import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; +import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -76,7 +76,65 @@ function buildDuration(startDate: Date, endDate: Date): string { } type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly"; -type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440"; + +type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks"; + +interface AlertRow { + id: string; + value: number; + unit: AlertUnit; +} + +let alertRowSeq = 0; +function newAlertRow(value: number, unit: AlertUnit): AlertRow { + alertRowSeq += 1; + return { id: `r${alertRowSeq}`, value, unit }; +} + +function alertRowToOffset(row: AlertRow): string | null { + if (row.unit === "at_time") return "PT0S"; + const v = Math.max(0, Math.floor(row.value)); + if (!Number.isFinite(v) || v <= 0) return null; + switch (row.unit) { + case "minutes": return `-PT${v}M`; + case "hours": return `-PT${v}H`; + case "days": return `-P${v}D`; + case "weeks": return `-P${v}W`; + } +} + +function offsetToAlertRow(offset: string): AlertRow | null { + if (offset === "PT0S" || offset === "P0D" || offset === "PT0M") { + return newAlertRow(0, "at_time"); + } + let m = offset.match(/^-?P(\d+)W$/); + if (m) return newAlertRow(parseInt(m[1], 10), "weeks"); + m = offset.match(/^-?P(\d+)D$/); + if (m) return newAlertRow(parseInt(m[1], 10), "days"); + m = offset.match(/^-?PT(\d+)H$/); + if (m) return newAlertRow(parseInt(m[1], 10), "hours"); + m = offset.match(/^-?PT(\d+)M$/); + if (m) { + const mins = parseInt(m[1], 10); + if (mins > 0 && mins % 1440 === 0) return newAlertRow(mins / 1440, "days"); + if (mins > 0 && mins % 60 === 0) return newAlertRow(mins / 60, "hours"); + return newAlertRow(mins, "minutes"); + } + return null; +} + +function formatAlertRowLabel( + row: { value: number; unit: AlertUnit }, + t: ReturnType +): string { + if (row.unit === "at_time") return t("alerts.at_time"); + switch (row.unit) { + case "minutes": return t("alerts.minutes_before", { count: row.value }); + case "hours": return t("alerts.hours_before", { count: row.value }); + case "days": return t("alerts.days_before", { count: row.value }); + case "weeks": return t("alerts.weeks_before", { count: row.value }); + } +} function formatDurationDisplay(minutes: number): string { if (minutes < 60) return `${minutes}min`; @@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string { function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { if (!event.alerts) return null; - const first = Object.values(event.alerts)[0]; - if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; - const offset = first.trigger.offset; - if (offset === "PT0S") return t("alerts.at_time"); - const minMatch = offset.match(/-?PT(\d+)M$/); - if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) }); - const hourMatch = offset.match(/-?PT(\d+)H$/); - if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) }); - const dayMatch = offset.match(/-?P(\d+)D/); - if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) }); - return null; + const labels: string[] = []; + for (const alert of Object.values(event.alerts)) { + if (alert.trigger["@type"] !== "OffsetTrigger") continue; + const row = offsetToAlertRow(alert.trigger.offset); + if (!row) continue; + labels.push(formatAlertRowLabel(row, t)); + } + if (labels.length === 0) return null; + return labels.join(", "); } function getRecurrenceLabel(event: CalendarEvent, t: ReturnType): string | null { @@ -216,22 +272,36 @@ export function EventModal({ if (!event?.recurrenceRules?.length) return "none"; return event.recurrenceRules[0].frequency as RecurrenceOption; }); - const [alert, setAlert] = useState(() => { - if (!event?.alerts) return "none"; - const first = Object.values(event.alerts)[0]; - if (!first) return "none"; - if (first.trigger["@type"] === "OffsetTrigger") { - const offset = first.trigger.offset; - if (offset === "PT0S") return "at_time"; - const minMatch = offset.match(/-?PT(\d+)M$/); - if (minMatch) return minMatch[1] as AlertOption; - const hourMatch = offset.match(/-?PT(\d+)H$/); - if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption; - const dayMatch = offset.match(/-?P(\d+)D/); - if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption; + const preservedAlertsRef = useRef>({}); + const [alertRows, setAlertRows] = useState(() => { + if (!event?.alerts) return []; + const rows: AlertRow[] = []; + for (const [id, alert] of Object.entries(event.alerts)) { + // Preserve alerts we can't represent in this UI (absolute triggers, + // email actions, offsets with non-canonical shapes) so they survive a save. + if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") { + preservedAlertsRef.current[id] = alert; + continue; + } + const row = offsetToAlertRow(alert.trigger.offset); + if (!row) { + preservedAlertsRef.current[id] = alert; + continue; + } + rows.push(row); } - return "none"; + return rows; }); + + const addAlertRow = useCallback(() => { + setAlertRows((prev) => [...prev, newAlertRow(10, "minutes")]); + }, []); + const updateAlertRow = useCallback((id: string, patch: Partial>) => { + setAlertRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }, []); + const removeAlertRow = useCallback((id: string) => { + setAlertRows((prev) => prev.filter((r) => r.id !== id)); + }, []); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -400,17 +470,23 @@ export function EventModal({ if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null; } - if (alert !== "none") { - const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`; - data.alerts = { - alert1: { - "@type": "Alert", - trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, - action: "display", - acknowledged: null, - relatedTo: null, - }, + const builtAlerts: Record = { ...preservedAlertsRef.current }; + let alertIdx = 0; + for (const row of alertRows) { + const offset = alertRowToOffset(row); + if (offset === null) continue; + let key = `alert${++alertIdx}`; + while (key in builtAlerts) key = `alert${++alertIdx}`; + builtAlerts[key] = { + "@type": "Alert", + trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, + action: "display", + acknowledged: null, + relatedTo: null, }; + } + if (Object.keys(builtAlerts).length > 0) { + data.alerts = builtAlerts; } else if (event && event.alerts && Object.keys(event.alerts).length > 0) { data.alerts = null; } @@ -435,7 +511,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alert, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -987,37 +1063,81 @@ export function EventModal({ )} -
-
- - -
-
- - -
+
+ + +
+ +
+ + {alertRows.length === 0 ? ( +

{t("alerts.none")}

+ ) : ( +
+ {alertRows.map((row) => ( +
+ {row.unit !== "at_time" && ( + { + const n = parseInt(e.target.value, 10); + updateAlertRow(row.id, { value: Number.isFinite(n) ? Math.max(1, n) : 1 }); + }} + className="w-20" + aria-label={t("alerts.amount")} + /> + )} + + +
+ ))} +
+ )} +
{attendees.length > 0 && ( diff --git a/locales/en/common.json b/locales/en/common.json index f4c53d62..91ddbdfe 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2357,12 +2357,21 @@ "delete": "Delete" }, "alerts": { - "title": "Reminder", - "none": "No reminder", + "title": "Reminders", + "none": "No reminders", "at_time": "At time of event", "minutes_before": "{count, plural, one {# minute before} other {# minutes before}}", "hours_before": "{count, plural, one {# hour before} other {# hours before}}", - "days_before": "{count, plural, one {# day before} other {# days before}}" + "days_before": "{count, plural, one {# day before} other {# days before}}", + "weeks_before": "{count, plural, one {# week before} other {# weeks before}}", + "unit_minutes_before": "minutes before", + "unit_hours_before": "hours before", + "unit_days_before": "days before", + "unit_weeks_before": "weeks before", + "add": "Add reminder", + "remove": "Remove reminder", + "amount": "Reminder amount", + "unit": "Reminder unit" }, "settings": { "title": "Calendar settings",