diff --git a/__tests__/lib/session-notifications.test.ts b/__tests__/lib/session-notifications.test.ts
new file mode 100644
index 00000000..f32d4d91
--- /dev/null
+++ b/__tests__/lib/session-notifications.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals";
+import { buildFavoriteSessionNotifications, scheduleFavoriteSessionNotifications } from "@/lib/session-notifications";
+import type { DailySchedule } from "@/hooks/useSchedule";
+
+const createSchedule = (startsAt: string): DailySchedule[] => [
+ {
+ date: "2026-06-17",
+ rooms: [
+ {
+ id: 1,
+ name: "Auditorium",
+ hasOnlyPlenumSessions: false,
+ sessions: [
+ {
+ id: "session-1",
+ title: "Practical React",
+ description: null,
+ startsAt,
+ endsAt: "2026-06-17T10:50:00.000Z",
+ isServiceSession: false,
+ isPlenumSession: false,
+ speakers: [],
+ roomId: 1,
+ room: "Auditorium",
+ status: "accepted",
+ },
+ ],
+ },
+ ],
+ timeSlots: [],
+ },
+];
+
+const createGroupedSchedule = (): DailySchedule[] => [
+ {
+ date: "2026-06-17",
+ rooms: [
+ {
+ id: 1,
+ name: "Auditorium",
+ hasOnlyPlenumSessions: false,
+ sessions: [
+ {
+ id: "session-1",
+ title: "Practical React",
+ description: null,
+ startsAt: "2026-06-17T10:00:00.000Z",
+ endsAt: "2026-06-17T10:50:00.000Z",
+ isServiceSession: false,
+ isPlenumSession: false,
+ speakers: [],
+ roomId: 1,
+ room: "Auditorium",
+ status: "accepted",
+ },
+ {
+ id: "session-2",
+ title: "Advanced GraphQL",
+ description: null,
+ startsAt: "2026-06-17T10:00:00.000Z",
+ endsAt: "2026-06-17T10:50:00.000Z",
+ isServiceSession: false,
+ isPlenumSession: false,
+ speakers: [],
+ roomId: 1,
+ room: "Auditorium",
+ status: "accepted",
+ },
+ ],
+ },
+ ],
+ timeSlots: [],
+ },
+];
+
+describe("session notifications", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2026-06-17T09:58:30.000Z"));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ it("builds reminders one minute before favorite sessions start", () => {
+ const notifications = buildFavoriteSessionNotifications(createSchedule("2026-06-17T10:00:00.000Z"), ["session-1"], Date.now());
+
+ expect(notifications).toEqual([
+ {
+ id: "session-1",
+ title: "Practical React starts in 1 minute",
+ body: "Auditorium · 12:00",
+ notifyAt: new Date("2026-06-17T09:59:00.000Z").getTime(),
+ },
+ ]);
+ });
+
+ it("groups favorite sessions that start at the same time into one reminder", () => {
+ const notifications = buildFavoriteSessionNotifications(createGroupedSchedule(), ["session-1", "session-2"], Date.now());
+
+ expect(notifications).toEqual([
+ {
+ id: "session-1|session-2",
+ title: "2 favorite sessions start in 1 minute",
+ body: "Practical React, Advanced GraphQL · 12:00",
+ notifyAt: new Date("2026-06-17T09:59:00.000Z").getTime(),
+ },
+ ]);
+ });
+
+ it("ignores non-favorites, service sessions, and reminders that are no longer in the future", () => {
+ const schedule = createSchedule("2026-06-17T09:59:00.000Z");
+ schedule[0].rooms[0].sessions.push({ ...schedule[0].rooms[0].sessions[0], id: "session-2", isServiceSession: true });
+
+ expect(buildFavoriteSessionNotifications(schedule, ["session-1", "session-2"], Date.now())).toEqual([]);
+ });
+
+ it("schedules browser notifications and returns a cleanup function", () => {
+ const notificationConstructor = jest.fn();
+ class MockNotification {
+ static permission: NotificationPermission = "granted";
+
+ constructor(title: string, options?: NotificationOptions) {
+ notificationConstructor(title, options);
+ }
+ }
+
+ const cleanup = scheduleFavoriteSessionNotifications(createSchedule("2026-06-17T10:00:00.000Z"), ["session-1"], MockNotification, Date.now());
+
+ jest.advanceTimersByTime(29_999);
+ expect(notificationConstructor).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1);
+ expect(notificationConstructor).toHaveBeenCalledWith("Practical React starts in 1 minute", { body: "Auditorium · 12:00", tag: "devbcn-session-session-1" });
+
+ cleanup();
+ });
+
+ it("schedules a single browser notification for simultaneous favorite sessions", () => {
+ const notificationConstructor = jest.fn();
+ class MockNotification {
+ static permission: NotificationPermission = "granted";
+
+ constructor(title: string, options?: NotificationOptions) {
+ notificationConstructor(title, options);
+ }
+ }
+
+ scheduleFavoriteSessionNotifications(createGroupedSchedule(), ["session-1", "session-2"], MockNotification, Date.now());
+
+ jest.advanceTimersByTime(30_000);
+
+ expect(notificationConstructor).toHaveBeenCalledTimes(1);
+ expect(notificationConstructor).toHaveBeenCalledWith("2 favorite sessions start in 1 minute", {
+ body: "Practical React, Advanced GraphQL · 12:00",
+ tag: "devbcn-session-session-1|session-2",
+ });
+ });
+
+ it("does not schedule notifications without browser permission", () => {
+ const notificationConstructor = jest.fn();
+ class MockNotification {
+ static permission: NotificationPermission = "default";
+
+ constructor(title: string, options?: NotificationOptions) {
+ notificationConstructor(title, options);
+ }
+ }
+
+ scheduleFavoriteSessionNotifications(createSchedule("2026-06-17T10:00:00.000Z"), ["session-1"], MockNotification, Date.now());
+
+ jest.advanceTimersByTime(30_000);
+ expect(notificationConstructor).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/[year]/schedule/page.tsx b/app/[year]/schedule/page.tsx
index bd0f186a..2c272254 100644
--- a/app/[year]/schedule/page.tsx
+++ b/app/[year]/schedule/page.tsx
@@ -58,7 +58,7 @@ export default async function Schedule({ params }: ScheduleProps) {
-
+
diff --git a/context/ScheduleContext.tsx b/context/ScheduleContext.tsx
index 56ae4498..a5187a66 100644
--- a/context/ScheduleContext.tsx
+++ b/context/ScheduleContext.tsx
@@ -1,6 +1,8 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState, ReactNode, useCallback } from "react";
+import type { DailySchedule } from "@/hooks/useSchedule";
+import { scheduleFavoriteSessionNotifications } from "@/lib/session-notifications";
interface ScheduleContextType {
savedSessionIds: string[];
@@ -10,7 +12,12 @@ interface ScheduleContextType {
const ScheduleContext = createContext
(undefined);
-export function ScheduleProvider({ children }: { readonly children: ReactNode }) {
+interface ScheduleProviderProps {
+ readonly children: ReactNode;
+ readonly schedule?: DailySchedule[];
+}
+
+export function ScheduleProvider({ children, schedule = [] }: ScheduleProviderProps) {
const [savedSessionIds, setSavedSessionIds] = useState([]);
const [isLoaded, setIsLoaded] = useState(false);
@@ -32,16 +39,34 @@ export function ScheduleProvider({ children }: { readonly children: ReactNode })
}
}, [savedSessionIds, isLoaded]);
- const toggleSession = useCallback((sessionId: string) => {
- setSavedSessionIds((prev) => {
- if (prev.includes(sessionId)) {
- return prev.filter((id) => id !== sessionId);
- } else {
- return [...prev, sessionId];
- }
+ useEffect(() => {
+ return scheduleFavoriteSessionNotifications(schedule, savedSessionIds, typeof Notification === "undefined" ? undefined : Notification);
+ }, [schedule, savedSessionIds]);
+
+ const requestNotificationPermission = useCallback(() => {
+ if (typeof Notification === "undefined" || Notification.permission !== "default") {
+ return;
+ }
+
+ Notification.requestPermission().catch((error: unknown) => {
+ console.error("Failed to request notification permission", error);
});
}, []);
+ const toggleSession = useCallback(
+ (sessionId: string) => {
+ setSavedSessionIds((prev) => {
+ if (prev.includes(sessionId)) {
+ return prev.filter((id) => id !== sessionId);
+ }
+
+ requestNotificationPermission();
+ return [...prev, sessionId];
+ });
+ },
+ [requestNotificationPermission]
+ );
+
const isSaved = useCallback((sessionId: string) => savedSessionIds.includes(sessionId), [savedSessionIds]);
const contextValue = useMemo(() => ({ savedSessionIds, toggleSession, isSaved }), [savedSessionIds, toggleSession, isSaved]);
diff --git a/lib/session-notifications.ts b/lib/session-notifications.ts
new file mode 100644
index 00000000..30bdff9d
--- /dev/null
+++ b/lib/session-notifications.ts
@@ -0,0 +1,128 @@
+import { format, parseISO } from "date-fns";
+import type { DailySchedule, GridSession } from "@/hooks/useSchedule";
+
+const SESSION_REMINDER_LEAD_TIME_MS = 60_000;
+
+export interface FavoriteSessionNotification {
+ id: string;
+ title: string;
+ body: string;
+ notifyAt: number;
+}
+
+type BrowserNotification = Pick & {
+ new (title: string, options?: NotificationOptions): unknown;
+};
+
+interface GroupedFavoriteSessionNotification {
+ ids: string[];
+ sessionCount: number;
+ titles: string[];
+ rooms: string[];
+ timeLabel: string;
+ notifyAt: number;
+}
+
+const getUniqueSessions = (schedule: DailySchedule[]): GridSession[] => {
+ const sessionsById = new Map();
+ const rooms = schedule.flatMap((day) => day.rooms);
+
+ for (const room of rooms) {
+ for (const session of room.sessions) {
+ if (!sessionsById.has(session.id)) {
+ sessionsById.set(session.id, session);
+ }
+ }
+ }
+
+ return Array.from(sessionsById.values());
+};
+
+const addSessionToNotificationGroup = (notificationsByNotifyAt: Map, session: GridSession, now: number): void => {
+ const startsAt = parseISO(session.startsAt);
+ const notifyAt = startsAt.getTime() - SESSION_REMINDER_LEAD_TIME_MS;
+
+ if (notifyAt <= now) {
+ return;
+ }
+
+ const existingNotification = notificationsByNotifyAt.get(notifyAt);
+ if (existingNotification === undefined) {
+ notificationsByNotifyAt.set(notifyAt, {
+ ids: [session.id],
+ sessionCount: 1,
+ titles: [session.title],
+ rooms: [session.room],
+ timeLabel: format(startsAt, "HH:mm"),
+ notifyAt,
+ });
+ return;
+ }
+
+ existingNotification.ids.push(session.id);
+ existingNotification.sessionCount += 1;
+ existingNotification.titles.push(session.title);
+ existingNotification.rooms.push(session.room);
+};
+
+export const buildFavoriteSessionNotifications = (schedule: DailySchedule[], savedSessionIds: string[], now: number): FavoriteSessionNotification[] => {
+ const savedIds = new Set(savedSessionIds);
+ const notificationsByNotifyAt = new Map();
+
+ for (const session of getUniqueSessions(schedule)) {
+ if (!savedIds.has(session.id) || session.isServiceSession) {
+ continue;
+ }
+
+ addSessionToNotificationGroup(notificationsByNotifyAt, session, now);
+ }
+
+ return Array.from(notificationsByNotifyAt.entries())
+ .sort(([leftNotifyAt], [rightNotifyAt]) => leftNotifyAt - rightNotifyAt)
+ .map(([notifyAt, notification]) => {
+ if (notification.sessionCount === 1) {
+ return {
+ id: notification.ids[0],
+ title: `${notification.titles[0]} starts in 1 minute`,
+ body: `${notification.rooms[0]} · ${notification.timeLabel}`,
+ notifyAt,
+ };
+ }
+
+ const sessionSummary =
+ notification.sessionCount <= 2
+ ? notification.titles.join(", ")
+ : `${notification.titles.slice(0, 2).join(", ")} +${notification.sessionCount - 2} more`;
+
+ return {
+ id: notification.ids.slice().sort().join("|"),
+ title: `${notification.sessionCount} favorite sessions start in 1 minute`,
+ body: `${sessionSummary} · ${notification.timeLabel}`,
+ notifyAt,
+ };
+ });
+};
+
+export const scheduleFavoriteSessionNotifications = (
+ schedule: DailySchedule[],
+ savedSessionIds: string[],
+ notificationApi: BrowserNotification | undefined,
+ now: number = Date.now()
+): (() => void) => {
+ if (!notificationApi || notificationApi.permission !== "granted") {
+ return () => undefined;
+ }
+
+ const timeoutIds = buildFavoriteSessionNotifications(schedule, savedSessionIds, now).map((notification) =>
+ window.setTimeout(() => {
+ new notificationApi(notification.title, {
+ body: notification.body,
+ tag: `devbcn-session-${notification.id}`,
+ });
+ }, notification.notifyAt - now)
+ );
+
+ return () => {
+ timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId));
+ };
+};
diff --git a/styles/components/_index.scss b/styles/components/_index.scss
index c5423220..a9a49d0a 100644
--- a/styles/components/_index.scss
+++ b/styles/components/_index.scss
@@ -9,3 +9,4 @@
@forward "venue-wtc";
@forward "convince-your-boss";
@forward "features";
+@forward "live-schedule";