From 69bd8f82bf73bcafc1b73950f6cb00c916656df7 Mon Sep 17 00:00:00 2001 From: Anyul Rivas Date: Thu, 18 Jun 2026 19:10:53 +0200 Subject: [PATCH 1/2] feat: group favorite session reminders --- __tests__/lib/session-notifications.test.ts | 177 ++++++++++++++++++++ lib/session-notifications.ts | 128 ++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 __tests__/lib/session-notifications.test.ts create mode 100644 lib/session-notifications.ts 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/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)); + }; +}; From 1c870d5c5ef956ca4ff7d0915b417839e1782232 Mon Sep 17 00:00:00 2001 From: Anyul Rivas Date: Thu, 18 Jun 2026 19:20:33 +0200 Subject: [PATCH 2/2] feat: wire schedule reminders --- app/[year]/schedule/page.tsx | 2 +- context/ScheduleContext.tsx | 41 ++++++++++++++++++++++++++++------- styles/components/_index.scss | 1 + 3 files changed, 35 insertions(+), 9 deletions(-) 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/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";