feat: support multiple flexible event reminders #170
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user