Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { ResourcesSection } from "./event-modal-sections/ResourcesSection";
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
import { VisibilitySection } from "./event-modal-sections/VisibilitySection";
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
import { RemindersSection } from "./event-modal-sections/RemindersSection";
import { ensureNotificationPermission } from "@/features/calendar/notifications/browserNotifications";
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
import { SectionPills } from "./event-modal-sections/SectionPills";
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
Expand All @@ -24,7 +26,7 @@ import { useConfig } from "@/features/config/ConfigProvider";
import { FeatureFlag, useFeatureFlag } from "@/hooks/useFeatureFlag";
import { SectionRow } from "./event-modal-sections/SectionRow";
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { Meet, Pin, Calendar, Edit, Lock } from "@gouvfr-lasuite/ui-kit/icons";
import { Meet, Pin, Calendar, Edit, Lock, Bell } from "@gouvfr-lasuite/ui-kit/icons";

import type { IcsEvent, IcsOrganizer } from "ts-ics";

Expand Down Expand Up @@ -178,6 +180,14 @@ export const EventModal = ({
try {
const icsEvent = form.toIcsEvent();

// Saving an event with a reminder is the user gesture we use to ask
// for notification permission (browsers ignore prompts that aren't
// tied to an interaction). Fire-and-forget — reminders still surface
// as in-app toasts if the user declines.
if (form.alarms.length > 0) {
void ensureNotificationPermission();
}

// Override organizer with the one matching the currently
// selected calendar (may differ from the initial prop).
if (organizer) {
Expand Down Expand Up @@ -307,6 +317,11 @@ export const EventModal = ({
icon: <Lock />,
label: t("calendar.event.visibility.label"),
},
{
id: "reminders" as const,
icon: <Bell />,
label: t("calendar.event.sections.addReminder"),
},
...(availableResources.length > 0
? [
{
Expand Down Expand Up @@ -510,6 +525,9 @@ export const EventModal = ({
alwaysOpen
/>
)}
{form.isSectionExpanded("reminders") && (
<RemindersSection alarms={form.alarms} onChange={form.setAlarms} alwaysOpen />
)}
{isInvited && mode === "edit" && onRespondToInvitation && (
<InvitationResponseSection
organizer={event?.organizer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const useEventForm = ({
if (cleaned.description) initialExpanded.add("description");
if (event?.recurrenceRule) initialExpanded.add("recurrence");
if (cleaned.url) initialExpanded.add("videoConference");
if (event?.alarms?.length) initialExpanded.add("reminders");
// Surface a non-default visibility (PRIVATE/CONFIDENTIAL) so it's
// never hidden behind a closed pill; PUBLIC stays collapsed.
if (event?.class && event.class !== "PUBLIC") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export type EventFormSectionId =
| "resources"
| "videoConference"
| "scheduling"
| "visibility";
| "visibility"
| "reminders";

/**
* Attachment metadata (UI only, no actual file upload).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useEventReminders } from "./useEventReminders";

/**
* Headless component that runs the event-reminder poller for as long as it
* is mounted. Render it once inside the authenticated calendar tree.
*/
export const NotificationManager = () => {
useEventReminders();
return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
buildAlarmMap,
collectDueReminders,
getAlarmTriggerMs,
getEventInstantMs,
reminderKey,
withMasterAlarms,
} from "../reminderUtils";

import type { IcsAlarm, IcsEvent } from "ts-ics";

const MIN = 60_000;
const BACKFILL = 5 * MIN;

const EVENT_START = new Date("2026-06-22T14:00:00Z");

const relativeAlarm = (
value: Partial<{ minutes: number; hours: number; days: number; weeks: number; before: boolean }>,
action = "DISPLAY",
): IcsAlarm => ({
action,
trigger: { type: "relative", value: { before: true, ...value } },
});

const makeEvent = (overrides: Partial<IcsEvent> = {}): IcsEvent =>
({
uid: "evt-1",
summary: "Standup",
start: { type: "DATE-TIME", date: EVENT_START },
...overrides,
}) as IcsEvent;

describe("getEventInstantMs", () => {
it("returns the start instant", () => {
expect(getEventInstantMs(makeEvent())).toBe(EVENT_START.getTime());
});

it("returns null when start is missing", () => {
expect(getEventInstantMs({ uid: "x" } as IcsEvent)).toBeNull();
});

it("treats an all-day (DATE) start as local midnight, not UTC midnight", () => {
// ts-ics stores all-day starts as UTC midnight; we want local midnight
// so the reminder isn't shifted by the viewer's UTC offset.
const utcMidnight = new Date(Date.UTC(2026, 5, 22));
const allDay = { uid: "ad", start: { type: "DATE", date: utcMidnight } } as IcsEvent;
const expected = new Date(2026, 5, 22).getTime();
expect(getEventInstantMs(allDay)).toBe(expected);
});
});

describe("getAlarmTriggerMs", () => {
const start = EVENT_START.getTime();

it("fires before the start for a 15-minute relative alarm", () => {
expect(getAlarmTriggerMs(relativeAlarm({ minutes: 15 }), start)).toBe(start - 15 * 60_000);
});

it("combines weeks/days/hours/minutes", () => {
const ms = getAlarmTriggerMs(relativeAlarm({ days: 1, hours: 2 }), start);
expect(ms).toBe(start - (86_400_000 + 2 * 3_600_000));
});

it("fires after the start when before is false", () => {
const alarm: IcsAlarm = {
action: "DISPLAY",
trigger: { type: "relative", value: { before: false, minutes: 10 } },
};
expect(getAlarmTriggerMs(alarm, start)).toBe(start + 10 * 60_000);
});

it("handles absolute triggers", () => {
const at = new Date("2026-06-22T13:00:00Z");
const alarm: IcsAlarm = {
action: "DISPLAY",
trigger: { type: "absolute", value: { type: "DATE-TIME", date: at } },
} as IcsAlarm;
expect(getAlarmTriggerMs(alarm, start)).toBe(at.getTime());
});
});

describe("reminderKey", () => {
it("is stable for the same alarm/event", () => {
const event = makeEvent();
const k1 = reminderKey("/cal/", event, 1000, EVENT_START.getTime());
const k2 = reminderKey("/cal/", event, 1000, EVENT_START.getTime());
expect(k1).toBe(k2);
});

it("differs across recurring occurrences", () => {
const base = makeEvent();
const occurrence = makeEvent({
recurrenceId: { value: { type: "DATE-TIME", date: new Date("2026-06-23T14:00:00Z") } },
} as Partial<IcsEvent>);
expect(reminderKey("/cal/", base, 1, 1)).not.toBe(reminderKey("/cal/", occurrence, 1, 1));
});
});

describe("collectDueReminders", () => {
const start = EVENT_START.getTime();
const triggerAt = (mins: number) => start - mins * MIN; // trigger for an N-min-before alarm

it("returns a reminder just after its trigger passes", () => {
const event = makeEvent({ alarms: [relativeAlarm({ minutes: 15 })] });
const now = triggerAt(15) + 30_000; // 30s past the trigger
const due = collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL);
expect(due).toHaveLength(1);
expect(due[0].triggerMs).toBe(triggerAt(15));
});

it("does not return a reminder before its trigger", () => {
const event = makeEvent({ alarms: [relativeAlarm({ minutes: 15 })] });
const now = start - 30 * MIN; // trigger (15 min before) is still in the future
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL)).toHaveLength(0);
});

it("does not back-fire a trigger that elapsed before the backfill window", () => {
const event = makeEvent({ alarms: [relativeAlarm({ days: 1 })] });
// "1 day before" trigger elapsed ~12h ago — far outside the 5-min backfill.
const now = triggerAt(0) - 12 * 60 * MIN + 1; // 12h before start
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL)).toHaveLength(0);
});

it("fires at the moment of start for a 0-minute alarm within backfill", () => {
const event = makeEvent({ alarms: [relativeAlarm({ minutes: 0 })] });
const now = start + 30_000; // 30s after start
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL)).toHaveLength(1);
});

it("ignores EMAIL alarms (delivered server-side)", () => {
const event = makeEvent({ alarms: [relativeAlarm({ minutes: 15 }, "EMAIL")] });
const now = triggerAt(15) + 30_000;
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL)).toHaveLength(0);
});

it("returns one entry per due alarm on the same event", () => {
const event = makeEvent({
alarms: [relativeAlarm({ minutes: 5 }), relativeAlarm({ minutes: 3 })],
});
const now = triggerAt(3) + 30_000; // both 5-min and 3-min triggers within backfill
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], now, BACKFILL)).toHaveLength(2);
});

it("ignores events without alarms", () => {
const event = makeEvent();
expect(collectDueReminders([{ event, calendarUrl: "/c/" }], start, BACKFILL)).toHaveLength(0);
});
});

describe("buildAlarmMap / withMasterAlarms", () => {
const alarms = [relativeAlarm({ minutes: 15 })];

it("indexes alarms by uid, skipping events without alarms", () => {
const master = makeEvent({ uid: "series-1", alarms });
const noAlarm = makeEvent({ uid: "series-2" });
const map = buildAlarmMap([master, noAlarm]);
expect(map.get("series-1")).toBe(alarms);
expect(map.has("series-2")).toBe(false);
});

it("re-attaches master alarms to expanded instances that lost them", () => {
const instance = makeEvent({ uid: "series-1", alarms: undefined });
const map = buildAlarmMap([makeEvent({ uid: "series-1", alarms })]);
const merged = withMasterAlarms([{ event: instance, calendarUrl: "/c/" }], map);
expect(merged[0].event.alarms).toBe(alarms);
});

it("leaves instances that already have alarms untouched", () => {
const ownAlarms = [relativeAlarm({ minutes: 30 })];
const instance = makeEvent({ uid: "series-1", alarms: ownAlarms });
const map = buildAlarmMap([makeEvent({ uid: "series-1", alarms })]);
const merged = withMasterAlarms([{ event: instance, calendarUrl: "/c/" }], map);
expect(merged[0].event.alarms).toBe(ownAlarms);
});

it("leaves instances with no matching master unchanged", () => {
const instance = makeEvent({ uid: "orphan", alarms: undefined });
const merged = withMasterAlarms([{ event: instance, calendarUrl: "/c/" }], new Map());
expect(merged[0].event.alarms).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Thin wrapper around the Web Notifications API. Every entry point is
* guarded so the rest of the app can call these unconditionally, even in
* non-browser (test / SSR) environments or when the user denied access.
*/

const NOTIFICATION_ICON = "/favicon.png";

export const isNotificationSupported = (): boolean =>
typeof window !== "undefined" && "Notification" in window;

/**
* Ask for permission once. Resolves to the resulting (or already-decided)
* permission. Never throws — a rejected promise from older browsers is
* swallowed and the current permission is returned.
*/
export const ensureNotificationPermission = async (): Promise<NotificationPermission> => {
if (!isNotificationSupported()) return "denied";
if (Notification.permission !== "default") return Notification.permission;
try {
return await Notification.requestPermission();
} catch {
return Notification.permission;
}
};

/**
* Show a desktop notification. Returns true if one was actually shown.
* `tag` collapses duplicates at the OS level as a second line of defence
* on top of our own fired-reminder bookkeeping.
*/
export const showBrowserNotification = (title: string, body: string, tag?: string): boolean => {
if (!isNotificationSupported() || Notification.permission !== "granted") return false;
try {
new Notification(title, { body, tag, icon: NOTIFICATION_ICON });
return true;
} catch {
return false;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* localStorage-backed record of reminders we've already fired, so a page
* reload (or a second tab) doesn't replay a notification the user has
* already seen. Entries are pruned past a retention window to keep the
* blob small.
*/

const STORAGE_KEY = "calendar-fired-reminders";
// Must exceed the poller's lookahead window so a fired key isn't pruned
// while its event is still being polled (which would let it re-fire).
const RETENTION_MS = 9 * 86_400_000; // 9 days

type StoredMap = Record<string, number>; // reminderKey -> firedAt (ms)

/** Load the fired-reminder keys, dropping any older than the retention window. */
export const loadFiredReminders = (nowMs: number): Map<string, number> => {
const map = new Map<string, number>();
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return map;
const stored = JSON.parse(raw) as StoredMap;
for (const [key, firedAt] of Object.entries(stored)) {
if (typeof firedAt === "number" && nowMs - firedAt < RETENTION_MS) {
map.set(key, firedAt);
}
}
} catch {
// Corrupt / unavailable storage — start clean.
}
return map;
};

/** Persist the fired-reminder keys, best-effort. */
export const saveFiredReminders = (map: Map<string, number>): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(map)));
} catch {
// Quota / unavailable storage — ignore; in-memory set still dedups
// for this session.
}
};
Loading
Loading