Implement EventStream for live schedule updates
This commit is contained in:
parent
e5aac858e4
commit
cdad188233
7 changed files with 172 additions and 36 deletions
69
app/api/events/route.ts
Normal file
69
app/api/events/route.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { Schedule } from "@/app/schedule/types";
|
||||||
|
|
||||||
|
function sendMessage(
|
||||||
|
stream: WritableStream<string>,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
const writer = stream.getWriter();
|
||||||
|
writer.ready
|
||||||
|
.then(() => writer.write(message))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => writer.releaseLock())
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
let streams = new Set<WritableStream<string>>();
|
||||||
|
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<string, Uint8Array>({
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
41
app/schedule/context.tsx
Normal file
41
app/schedule/context.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { Schedule } from "./types";
|
||||||
|
|
||||||
|
const ScheduleContext = createContext<Schedule | null>(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 (
|
||||||
|
<ScheduleContext.Provider value={schedule}>
|
||||||
|
{props.children}
|
||||||
|
</ScheduleContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSchedule() {
|
||||||
|
return useContext(ScheduleContext);
|
||||||
|
}
|
|
@ -1,38 +1,26 @@
|
||||||
import Timetable from "@/ui/timetable"
|
import Timetable from "@/ui/timetable"
|
||||||
import styles from "./page.module.css"
|
import { Schedule } from "./types"
|
||||||
import { Schedule, ScheduleEvent } from "./types"
|
|
||||||
import { readFile } from "fs/promises"
|
import { readFile } from "fs/promises"
|
||||||
|
import { ScheduleProvider } from "./context"
|
||||||
|
import { Events } from "@/ui/events";
|
||||||
|
import { Locations } from "@/ui/locations";
|
||||||
|
|
||||||
function EventInfo(props: { event: ScheduleEvent }) {
|
export default async function page() {
|
||||||
return <section className={styles.event}>
|
|
||||||
<h3>{props.event.name}</h3>
|
|
||||||
<p>{props.event.description ?? "No description provided"}</p>
|
|
||||||
<h4>Timeslots</h4>
|
|
||||||
<ul>
|
|
||||||
{props.event.slots.map(slot => <li key={slot.id}>
|
|
||||||
{slot.start} - {slot.end}
|
|
||||||
</li>)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function schedule() {
|
|
||||||
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
|
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
|
||||||
return <main className={styles.schedule}>
|
return (
|
||||||
<h1>Schedule & Events</h1>
|
<ScheduleProvider schedule={schedule}>
|
||||||
<p>
|
<main>
|
||||||
Study carefully, we only hold these events once a year.
|
<h1>Schedule & Events</h1>
|
||||||
</p>
|
<p>
|
||||||
<h2>Schedule</h2>
|
Study carefully, we only hold these events once a year.
|
||||||
<Timetable schedule={schedule} />
|
</p>
|
||||||
<h2>Events</h2>
|
<h2>Schedule</h2>
|
||||||
{schedule.events.map(event => <EventInfo event={event} key={event.id}/>)}
|
<Timetable />
|
||||||
<h2>Locations</h2>
|
<h2>Events</h2>
|
||||||
<ul>
|
<Events />
|
||||||
{schedule.locations.map(location => <li key={location.id}>
|
<h2>Locations</h2>
|
||||||
<h3>{location.name}</h3>
|
<Locations />
|
||||||
{location.description ?? "No description provided"}
|
</main>
|
||||||
</li>)}
|
</ScheduleProvider>
|
||||||
</ul>
|
);
|
||||||
</main>
|
|
||||||
}
|
}
|
||||||
|
|
24
ui/events.tsx
Normal file
24
ui/events.tsx
Normal file
|
@ -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 <section className={styles.event}>
|
||||||
|
<h3>{props.event.name}</h3>
|
||||||
|
<p>{props.event.description ?? "No description provided"}</p>
|
||||||
|
<h4>Timeslots</h4>
|
||||||
|
<ul>
|
||||||
|
{props.event.slots.map(slot => <li key={slot.id}>
|
||||||
|
{slot.start} - {slot.end}
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Events() {
|
||||||
|
const schedule = useSchedule();
|
||||||
|
return <>
|
||||||
|
{schedule!.events.map(event => <EventInfo event={event} key={event.id}/>)}
|
||||||
|
</>;
|
||||||
|
}
|
12
ui/locations.tsx
Normal file
12
ui/locations.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
"use client";
|
||||||
|
import { useSchedule } from "@/app/schedule/context";
|
||||||
|
|
||||||
|
export function Locations() {
|
||||||
|
const schedule = useSchedule();
|
||||||
|
return <ul>
|
||||||
|
{schedule!.locations.map(location => <li key={location.id}>
|
||||||
|
<h3>{location.name}</h3>
|
||||||
|
{location.description ?? "No description provided"}
|
||||||
|
</li>)}
|
||||||
|
</ul>;
|
||||||
|
}
|
|
@ -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 styles from "./timetable.module.css";
|
||||||
|
import { useSchedule } from "@/app/schedule/context";
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
const oneHourMs = 60 * 60 * 1000;
|
const oneHourMs = 60 * 60 * 1000;
|
||||||
|
@ -318,8 +320,8 @@ function tableElementsFromStretches(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timetable(props: { schedule: Schedule }) {
|
export default function Timetable() {
|
||||||
const { locations, events } = props.schedule;
|
const { locations, events } = useSchedule()!;
|
||||||
const junctions = junctionsFromEdges(edgesFromEvents(events));
|
const junctions = junctionsFromEdges(edgesFromEvents(events));
|
||||||
const stretches = [...stretchesFromSpans(spansFromJunctions(junctions, locations), oneHourMs * 5)];
|
const stretches = [...stretchesFromSpans(spansFromJunctions(junctions, locations), oneHourMs * 5)];
|
||||||
const {
|
const {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue