feat: support multiple flexible event reminders #170

This commit is contained in:
Linus Rath
2026-05-18 17:31:44 +02:00
parent dcd2f4b079
commit 3d2ed71f3a
2 changed files with 202 additions and 73 deletions
+190 -70
View File
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { 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 { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; 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 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<typeof useTranslations>
): 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 { function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`; if (minutes < 60) return `${minutes}min`;
@@ -88,17 +146,15 @@ function formatDurationDisplay(minutes: number): string {
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null; if (!event.alerts) return null;
const first = Object.values(event.alerts)[0]; const labels: string[] = [];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null; for (const alert of Object.values(event.alerts)) {
const offset = first.trigger.offset; if (alert.trigger["@type"] !== "OffsetTrigger") continue;
if (offset === "PT0S") return t("alerts.at_time"); const row = offsetToAlertRow(alert.trigger.offset);
const minMatch = offset.match(/-?PT(\d+)M$/); if (!row) continue;
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) }); labels.push(formatAlertRowLabel(row, t));
const hourMatch = offset.match(/-?PT(\d+)H$/); }
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) }); if (labels.length === 0) return null;
const dayMatch = offset.match(/-?P(\d+)D/); return labels.join(", ");
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
} }
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null { function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
@@ -216,22 +272,36 @@ export function EventModal({
if (!event?.recurrenceRules?.length) return "none"; if (!event?.recurrenceRules?.length) return "none";
return event.recurrenceRules[0].frequency as RecurrenceOption; return event.recurrenceRules[0].frequency as RecurrenceOption;
}); });
const [alert, setAlert] = useState<AlertOption>(() => { const preservedAlertsRef = useRef<Record<string, CalendarEventAlert>>({});
if (!event?.alerts) return "none"; const [alertRows, setAlertRows] = useState<AlertRow[]>(() => {
const first = Object.values(event.alerts)[0]; if (!event?.alerts) return [];
if (!first) return "none"; const rows: AlertRow[] = [];
if (first.trigger["@type"] === "OffsetTrigger") { for (const [id, alert] of Object.entries(event.alerts)) {
const offset = first.trigger.offset; // Preserve alerts we can't represent in this UI (absolute triggers,
if (offset === "PT0S") return "at_time"; // email actions, offsets with non-canonical shapes) so they survive a save.
const minMatch = offset.match(/-?PT(\d+)M$/); if (alert.trigger["@type"] !== "OffsetTrigger" || alert.action !== "display") {
if (minMatch) return minMatch[1] as AlertOption; preservedAlertsRef.current[id] = alert;
const hourMatch = offset.match(/-?PT(\d+)H$/); continue;
if (hourMatch) return String(parseInt(hourMatch[1]) * 60) as AlertOption; }
const dayMatch = offset.match(/-?P(\d+)D/); const row = offsetToAlertRow(alert.trigger.offset);
if (dayMatch) return String(parseInt(dayMatch[1]) * 1440) as AlertOption; 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<Omit<AlertRow, "id">>) => {
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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -400,17 +470,23 @@ export function EventModal({
if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null; if (event.excludedRecurrenceRules) data.excludedRecurrenceRules = null;
} }
if (alert !== "none") { const builtAlerts: Record<string, CalendarEventAlert> = { ...preservedAlertsRef.current };
const offset = alert === "at_time" ? "PT0S" : `-PT${alert}M`; let alertIdx = 0;
data.alerts = { for (const row of alertRows) {
alert1: { const offset = alertRowToOffset(row);
"@type": "Alert", if (offset === null) continue;
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" }, let key = `alert${++alertIdx}`;
action: "display", while (key in builtAlerts) key = `alert${++alertIdx}`;
acknowledged: null, builtAlerts[key] = {
relatedTo: null, "@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) { } else if (event && event.alerts && Object.keys(event.alerts).length > 0) {
data.alerts = null; data.alerts = null;
} }
@@ -435,7 +511,7 @@ export function EventModal({
} finally { } finally {
setIsSaving(false); 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']) => { const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
if (!event || !userParticipantId || !onRsvp) return; if (!event || !userParticipantId || !onRsvp) return;
@@ -987,37 +1063,81 @@ export function EventModal({
</div> </div>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div>
<div> <label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label> <select
<select value={recurrence}
value={recurrence} onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}
onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" >
> <option value="none">{t("recurrence.none")}</option>
<option value="none">{t("recurrence.none")}</option> <option value="daily">{t("recurrence.daily")}</option>
<option value="daily">{t("recurrence.daily")}</option> <option value="weekly">{t("recurrence.weekly")}</option>
<option value="weekly">{t("recurrence.weekly")}</option> <option value="monthly">{t("recurrence.monthly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option> <option value="yearly">{t("recurrence.yearly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option> </select>
</select> </div>
</div>
<div> <div>
<label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label> <label className="text-sm font-medium mb-1 block">{t("alerts.title")}</label>
<select {alertRows.length === 0 ? (
value={alert} <p className="text-sm text-muted-foreground">{t("alerts.none")}</p>
onChange={(e) => setAlert(e.target.value as AlertOption)} ) : (
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" <div className="space-y-2">
> {alertRows.map((row) => (
<option value="none">{t("alerts.none")}</option> <div key={row.id} className="flex items-center gap-2">
<option value="at_time">{t("alerts.at_time")}</option> {row.unit !== "at_time" && (
<option value="5">{t("alerts.minutes_before", { count: 5 })}</option> <Input
<option value="15">{t("alerts.minutes_before", { count: 15 })}</option> type="number"
<option value="30">{t("alerts.minutes_before", { count: 30 })}</option> min={1}
<option value="60">{t("alerts.hours_before", { count: 1 })}</option> max={999}
<option value="1440">{t("alerts.days_before", { count: 1 })}</option> value={row.value}
</select> onChange={(e) => {
</div> 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")}
/>
)}
<select
value={row.unit}
onChange={(e) => {
const unit = e.target.value as AlertUnit;
updateAlertRow(row.id, {
unit,
value: unit === "at_time" ? 0 : (row.value || 1),
});
}}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("alerts.unit")}
>
<option value="at_time">{t("alerts.at_time")}</option>
<option value="minutes">{t("alerts.unit_minutes_before")}</option>
<option value="hours">{t("alerts.unit_hours_before")}</option>
<option value="days">{t("alerts.unit_days_before")}</option>
<option value="weeks">{t("alerts.unit_weeks_before")}</option>
</select>
<button
type="button"
onClick={() => removeAlertRow(row.id)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("alerts.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<button
type="button"
onClick={addAlertRow}
className="mt-2 inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
{t("alerts.add")}
</button>
</div> </div>
{attendees.length > 0 && ( {attendees.length > 0 && (
+12 -3
View File
@@ -2357,12 +2357,21 @@
"delete": "Delete" "delete": "Delete"
}, },
"alerts": { "alerts": {
"title": "Reminder", "title": "Reminders",
"none": "No reminder", "none": "No reminders",
"at_time": "At time of event", "at_time": "At time of event",
"minutes_before": "{count, plural, one {# minute before} other {# minutes before}}", "minutes_before": "{count, plural, one {# minute before} other {# minutes before}}",
"hours_before": "{count, plural, one {# hour before} other {# hours 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": { "settings": {
"title": "Calendar settings", "title": "Calendar settings",