From cdad188233f317b776339740e23c1d9c9b8d6383 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 27 Feb 2025 15:42:59 +0100 Subject: [PATCH] Implement EventStream for live schedule updates --- app/api/events/route.ts | 69 +++++++++++++++++++ app/schedule/context.tsx | 41 +++++++++++ app/schedule/page.tsx | 54 ++++++--------- .../page.module.css => ui/events.module.css | 0 ui/events.tsx | 24 +++++++ ui/locations.tsx | 12 ++++ ui/timetable.tsx | 8 ++- 7 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 app/api/events/route.ts create mode 100644 app/schedule/context.tsx rename app/schedule/page.module.css => ui/events.module.css (100%) create mode 100644 ui/events.tsx create mode 100644 ui/locations.tsx diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..fed8ce0 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,69 @@ +import { Schedule } from "@/app/schedule/types"; + +function sendMessage( + stream: WritableStream, + message: string, +) { + const writer = stream.getWriter(); + writer.ready + .then(() => writer.write(message)) + .catch(console.error) + .finally(() => writer.releaseLock()) + ; +} + +let streams = new Set>(); +let lastBroadcastData: string | null = null; +let lastBroadcastId = 0; +export function broadcastUpdate(schedule: Schedule) { + const id = Date.now(); + const data = JSON.stringify(schedule); + lastBroadcastId = id; + lastBroadcastData = data; + const message = `id: ${id}\nevent: update\ndata: ${data}\n\n` + for (const stream of streams) { + sendMessage(stream, message); + } +} + +setInterval(() => { + for (const stream of streams) { + sendMessage(stream, "data: keepalive\n\n"); + } +}, 10e3) + +export async function GET(request: Request) { + const encoder = new TextEncoder(); + const source = request.headers.get("x-forwarded-for"); + console.log(`starting event stream for ${source}`) + const stream = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(encoder.encode(chunk)); + }, + flush(controller) { + console.log(`finished event stream for ${source}`); + streams.delete(stream.writable); + }, + // @ts-expect-error experimental API + cancel(reason) { + console.log(`cancelled event stream for ${source}`); + streams.delete(stream.writable); + } + }) + streams.add(stream.writable); + if (lastBroadcastId) { + sendMessage( + stream.writable, + `id: ${lastBroadcastData}\nevent: update\ndata: ${lastBroadcastData}\n\n` + ); + } + return new Response( + stream.readable, + { + headers: { + "Access-Control-Allow-Origin": "*", + "Content-Type": "text/event-stream", + } + } + ); +} diff --git a/app/schedule/context.tsx b/app/schedule/context.tsx new file mode 100644 index 0000000..edba881 --- /dev/null +++ b/app/schedule/context.tsx @@ -0,0 +1,41 @@ +"use client"; +import { createContext, useContext, useEffect, useState } from "react"; +import { Schedule } from "./types"; + +const ScheduleContext = createContext(null); + +interface ScheduleProviderProps { + children: React.ReactElement; + schedule: Schedule; +} + +export function ScheduleProvider(props: ScheduleProviderProps) { + const [schedule, setSchedule] = useState(props.schedule); + useEffect(() => { + console.log("Opening event source") + const source = new EventSource("/api/events"); + source.addEventListener("message", (message) => { + console.log("Message", message.data); + setSchedule(old => { + const copy: Schedule = JSON.parse(JSON.stringify(old)); + const ts = copy.events[0].slots[0].start; + copy.events[0].slots[0].start = new Date(Date.parse(ts) + 36e5).toISOString(); + return copy; + }) + }); + source.addEventListener("update", (message) => console.log("Update", message.data)); + return () => { + console.log("Closing event source") + source.close(); + } + }, []) + return ( + + {props.children} + + ); +} + +export function useSchedule() { + return useContext(ScheduleContext); +} diff --git a/app/schedule/page.tsx b/app/schedule/page.tsx index 828a5c8..8fa3726 100644 --- a/app/schedule/page.tsx +++ b/app/schedule/page.tsx @@ -1,38 +1,26 @@ import Timetable from "@/ui/timetable" -import styles from "./page.module.css" -import { Schedule, ScheduleEvent } from "./types" +import { Schedule } from "./types" import { readFile } from "fs/promises" +import { ScheduleProvider } from "./context" +import { Events } from "@/ui/events"; +import { Locations } from "@/ui/locations"; -function EventInfo(props: { event: ScheduleEvent }) { - return
-

{props.event.name}

-

{props.event.description ?? "No description provided"}

-

Timeslots

-
    - {props.event.slots.map(slot =>
  • - {slot.start} - {slot.end} -
  • )} -
-
-} - -export default async function schedule() { +export default async function page() { const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8")); - return
-

Schedule & Events

-

- Study carefully, we only hold these events once a year. -

-

Schedule

- -

Events

- {schedule.events.map(event => )} -

Locations

-
    - {schedule.locations.map(location =>
  • -

    {location.name}

    - {location.description ?? "No description provided"} -
  • )} -
-
+ return ( + +
+

Schedule & Events

+

+ Study carefully, we only hold these events once a year. +

+

Schedule

+ +

Events

+ +

Locations

+ +
+
+ ); } diff --git a/app/schedule/page.module.css b/ui/events.module.css similarity index 100% rename from app/schedule/page.module.css rename to ui/events.module.css diff --git a/ui/events.tsx b/ui/events.tsx new file mode 100644 index 0000000..427c2ed --- /dev/null +++ b/ui/events.tsx @@ -0,0 +1,24 @@ +"use client"; +import styles from "./events.module.css" +import { useSchedule } from "@/app/schedule/context"; +import { ScheduleEvent } from "@/app/schedule/types"; + +function EventInfo(props: { event: ScheduleEvent }) { + return
+

{props.event.name}

+

{props.event.description ?? "No description provided"}

+

Timeslots

+
    + {props.event.slots.map(slot =>
  • + {slot.start} - {slot.end} +
  • )} +
+
+} + +export function Events() { + const schedule = useSchedule(); + return <> + {schedule!.events.map(event => )} + ; +} diff --git a/ui/locations.tsx b/ui/locations.tsx new file mode 100644 index 0000000..05b079d --- /dev/null +++ b/ui/locations.tsx @@ -0,0 +1,12 @@ +"use client"; +import { useSchedule } from "@/app/schedule/context"; + +export function Locations() { + const schedule = useSchedule(); + return
    + {schedule!.locations.map(location =>
  • +

    {location.name}

    + {location.description ?? "No description provided"} +
  • )} +
; +} diff --git a/ui/timetable.tsx b/ui/timetable.tsx index b3a7843..82b3385 100644 --- a/ui/timetable.tsx +++ b/ui/timetable.tsx @@ -1,5 +1,7 @@ -import { Schedule, ScheduleEvent, ScheduleLocation, TimeSlot } from "@/app/schedule/types"; +"use client"; +import { ScheduleEvent, ScheduleLocation, TimeSlot } from "@/app/schedule/types"; import styles from "./timetable.module.css"; +import { useSchedule } from "@/app/schedule/context"; const oneDayMs = 24 * 60 * 60 * 1000; const oneHourMs = 60 * 60 * 1000; @@ -318,8 +320,8 @@ function tableElementsFromStretches( }; } -export default function Timetable(props: { schedule: Schedule }) { - const { locations, events } = props.schedule; +export default function Timetable() { + const { locations, events } = useSchedule()!; const junctions = junctionsFromEdges(edgesFromEvents(events)); const stretches = [...stretchesFromSpans(spansFromJunctions(junctions, locations), oneHourMs * 5)]; const {