Move all code to old/ to prepare for Nuxt rewrite

This commit is contained in:
Hornwitser 2025-03-01 16:32:51 +01:00
parent 6007f4caeb
commit 51ff27c569
33 changed files with 0 additions and 1 deletions

93
old/ui/events-edit.tsx Normal file
View file

@ -0,0 +1,93 @@
"use client";
import { createEvent, deleteEvent, modifyEvent } from "@/app/api/events/actions";
import { useSchedule } from "@/app/schedule/context";
import Form from "next/form";
import { useState } from "react";
export function EventsEdit() {
const schedule = useSchedule()!;
const event = schedule.events[0];
return <details>
<summary>Admin Edit</summary>
<h3>Create Event</h3>
<Form action={createEvent}>
<label>
Id:
<input type="text" name="id" required />
</label>
<label>
Name:
<input type="text" name="name" required />
</label>
<label>
Description:
<textarea name="description" />
</label>
<label>
Start:
<input type="datetime-local" name="start" defaultValue="2025-07-20T18:00" />
</label>
<label>
End:
<input type="datetime-local" name="end" defaultValue="2025-07-20T20:00" />
</label>
<label>
Location
<select name="location">
{schedule?.locations.map(location => <option key={location.id} value={location.id}>{location.name}</option>)}
</select>
</label>
<button type="submit">Create</button>
</Form>
<h3>Edit Event</h3>
<Form action={modifyEvent}>
<label>
Event
<select name="id" onChange={(event) => {
const newEvent = schedule.events.find(e => e.id === event.target.value)!;
const form = event.target.form!;
for (const element of form.elements as any) {
if (element.name === "name") {
element.value = newEvent.name;
} else if (element.name === "description") {
element.value = newEvent.description;
} else if (element.name === "start") {
element.value = newEvent.slots[0].start.replace("Z", "");
} else if (element.name === "end") {
element.value = newEvent.slots[0].end.replace("Z", "");
} else if (element.name === "location") {
element.value = newEvent.slots[0].locations[0];
}
}
}}>
{schedule?.events.map(event => <option key={event.id} value={event.id}>{event.name}</option>)}
</select>
</label>
<label>
Name:
<input type="text" name="name" defaultValue={event.name} />
</label>
<label>
Description:
<textarea name="description" defaultValue={event.description} />
</label>
<label>
Start:
<input type="datetime-local" name="start" defaultValue={event.slots[0].start.replace("Z", "")} />
</label>
<label>
End:
<input type="datetime-local" name="end" defaultValue={event.slots[0].end.replace("Z", "")} />
</label>
<label>
Location
<select name="location">
{schedule?.locations.map(location => <option key={location.id} value={location.id}>{location.name}</option>)}
</select>
</label>
<button type="submit">Edit</button>
<button type="submit" formAction={deleteEvent}>Delete</button>
</Form>
</details>;
}

11
old/ui/events.module.css Normal file
View file

@ -0,0 +1,11 @@
.event {
background: color-mix(in oklab, var(--background), grey 20%);
padding: 0.5rem;
border-radius: 0.5rem;
}
.event h3 {
margin: 0;
}
.event + .event {
margin-block-start: 0.5rem;
}

24
old/ui/events.tsx Normal file
View 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
old/ui/locations.tsx Normal file
View 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>;
}

View file

@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
function notificationUnsupported() {
return (
!("serviceWorker" in navigator)
|| !("PushManager" in window)
|| !("showNotification" in ServiceWorkerRegistration.prototype)
)
}
async function registerAndSubscribe(
vapidPublicKey: string,
onSubscribe: (subs: PushSubscription | null ) => void,
) {
try {
await navigator.serviceWorker.register("/sw.js");
await subscribe(vapidPublicKey, onSubscribe);
} catch (err) {
console.error("Failed to register service worker:", err);
}
}
async function subscribe(
vapidPublicKey: string,
onSubscribe: (subs: PushSubscription | null) => void
) {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapidPublicKey,
});
console.log("Got subscription object", subscription.toJSON());
await submitSubscription(subscription);
onSubscribe(subscription);
} catch (err) {
console.error("Failed to subscribe:" , err);
}
}
async function unsubscribe(
subscription: PushSubscription,
onUnsubscribed: () => void,
) {
const body = JSON.stringify({ subscription });
await subscription.unsubscribe();
const res = await fetch("/api/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body,
});
const result = await res.json();
console.log("/api/unsubscribe returned", result);
onUnsubscribed();
}
async function submitSubscription(subscription: PushSubscription) {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ subscription }),
});
const result = await res.json();
console.log("/api/subscribe returned", result);
}
async function getSubscription(
onSubscribe: (subs: PushSubscription | null) => void,
) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
onSubscribe(subscription);
}
}
export function PushNotification(props: { vapidPublicKey: string }) {
const [unsupported, setUnsupported] = useState<boolean | undefined>(undefined);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
useEffect(() => {
const isUnsupported = notificationUnsupported();
setUnsupported(isUnsupported);
if (isUnsupported) {
return;
}
getSubscription(setSubscription);
}, [props.vapidPublicKey])
return <section>
Notifications are: <b>{subscription ? "Enabled" : "Disabled"}</b>
<br />
<button
disabled={unsupported}
onClick={() => {
if (!subscription)
registerAndSubscribe(props.vapidPublicKey, setSubscription)
else
unsubscribe(subscription, () => setSubscription(null))
}}
>
{unsupported === undefined && "Checking for support"}
{unsupported === true && "Notifications are not supported :(."}
{unsupported === false && subscription ? "Disable notifications" : "Enable notifications"}
</button>
<details>
<summary>Debug</summary>
<pre>
<code>
{JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set"}
</code>
</pre>
</details>
</section>;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -0,0 +1,46 @@
.timetable {
overflow-x: auto;
}
.timetable table {
border-spacing: 0;
table-layout: fixed;
width: 100%;
font-size: 0.8rem;
--row-header-width: 6rem;
--cell-size: 3rem;
}
.timetable col {
width: calc(var(--cell-size) * var(--minutes, 60) / 60);
}
.timetable col.header {
width: var(--row-header-width);
}
.timetable th:first-child {
background-color: var(--background);
position: sticky;
left: 0;
}
.timetable :is(td, th) {
padding: 0.1rem;
border-top: 1px solid var(--foreground);
border-right: 1px solid var(--foreground);
}
.timetable tr th:first-child {
border-left: 1px solid var(--foreground);
}
.timetable tbody tr:last-child :is(td, th) {
border-bottom: 1px solid var(--foreground);
}
.break {
background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
}
.event {
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
}

401
old/ui/timetable.tsx Normal file
View file

@ -0,0 +1,401 @@
"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;
const oneMinMs = 60 * 1000;
// See timetable-terminology.png for an illustration of how these terms are related
/** Point in time where a time slots starts or ends. */
type Edge = { type: "start" | "end", slot: TimeSlot };
/** Point in time where multiple edges meet. */
type Junction = { ts: string, edges: Edge[] };
/** Span of time between two adjacent junctions */
type Span = {
start: Junction;
end: Junction,
locations: Map<string, Set<TimeSlot>>,
};
/**
Collection of adjacent spans containing TimeSlots that are close to each
other in time. The start and end of the stretch is aligned to a whole hour
and the endpoint spans are always empty and at least one hour.
*/
type Stretch = {
start: string,
end: string,
spans: Span[];
}
/** Returns a tuple consisting of a running index starting from 0, and the item of the iterable */
function* enumerate<T>(iterable: Iterable<T>) {
let index = 0;
for (const item of iterable) {
yield [index++, item] as [number, T];
}
}
/** Returns adjacent pairs from iterable */
function* pairs<T>(iterable: Iterable<T>) {
let first;
let second;
for (const [index, item] of enumerate(iterable)) {
[first, second] = [second, item];
if (index >= 1) {
yield [first, second] as [T, T];
}
}
}
/**
Returns true if all sets are equal
@param sets set to compare
@returns true if all sets are the same size and have the same elements
*/
export function setEquals<T>(...sets: Set<T>[]) {
if (sets.length < 2) {
throw TypeError("At least two sets must be passed to setEquals");
}
const ref = sets[0];
const rest = sets.slice(1);
if (rest.some(set => set.size !== ref.size)) {
return false;
}
for (const set of rest) {
for (const el of set) {
if (!ref.has(el)) {
return false;
}
}
}
return true;
}
function isoStringFromTs(ts: number) {
return new Date(ts).toISOString().replace(":00.000Z", "Z");
}
function* edgesFromEvents(events: Iterable<ScheduleEvent>): Generator<Edge> {
for (const event of events) {
for (const slot of event.slots) {
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", slot }
yield { type: "end", slot }
}
}
}
function junctionsFromEdges(edges: Iterable<Edge>) {
const junctions = new Map<string, Junction>();
for (const edge of edges) {
const ts = edge.slot[edge.type];
const junction = junctions.get(ts);
if (junction) {
junction.edges.push(edge);
} else {
junctions.set(ts, { ts, edges: [edge] });
}
}
const keys = [...junctions.keys()].sort();
return keys.map(key => junctions.get(key)!);
}
function* spansFromJunctions(
junctions: Iterable<Junction>, locations: ScheduleLocation[]
): Generator<Span> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<TimeSlot>()])
);
for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) {
if (edge.type === "start") {
for (const location of edge.slot.locations) {
activeLocations.get(location)!.add(edge.slot)
}
}
}
yield {
start,
end,
locations: new Map(
[...activeLocations]
.filter(([_, slots]) => slots.size)
.map(([location, slots]) => [location, new Set(slots)])
),
}
for (const edge of end.edges) {
if (edge.type === "end") {
for (const location of edge.slot.locations) {
activeLocations.get(location)!.delete(edge.slot)
}
}
}
}
}
function createStretch(spans: Span[]): Stretch {
let startTs = Date.parse(spans[0].start.ts) - oneHourMs;
let endTs = Date.parse(spans[spans.length - 1].end.ts) + oneHourMs;
// Extend stretch to nearest whole hours
startTs = Math.floor(startTs / oneHourMs) * oneHourMs;
endTs = Math.ceil(endTs / oneHourMs) * oneHourMs;
// Convert back to ISO date string
let start = isoStringFromTs(startTs);
let end = isoStringFromTs(endTs);
return {
spans: [
{
start: { ts: start, edges: [] },
end: spans[0].start,
locations: new Map(),
},
...spans,
{
start: spans[spans.length - 1].end,
end: { ts: end, edges: [] },
locations: new Map(),
},
],
start,
end,
}
}
function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Generator<Stretch> {
let currentSpans: Span[] = [];
for (const span of spans) {
// Based on how spans are generated I can assume that an empty span
// will only occur between two spans with timeslots in them.
if (span.locations.size === 0
&& Date.parse(span.end.ts) - Date.parse(span.start.ts) >= minSeparation
) {
yield createStretch(currentSpans);
currentSpans = [];
} else {
currentSpans.push(span);
}
}
if (currentSpans.length)
yield createStretch(currentSpans);
}
/** Cuts up a span by whole hours that crosses it */
function* cutSpansByHours(span: Span): Generator<Span> {
const startHour = Date.parse(span.start.ts) / oneHourMs;
const endHour = Date.parse(span.end.ts) / oneHourMs;
let currentStart = startHour;
let currentEnd = Math.min(Math.floor(startHour + 1), endHour);
if (currentEnd === endHour) {
yield span;
return;
}
yield {
start: span.start,
end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
locations: span.locations,
}
currentStart = currentEnd;
while (++currentEnd < endHour) {
yield {
start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
end: { ts: isoStringFromTs(currentEnd * oneHourMs), edges: [] },
locations: span.locations,
}
currentStart += 1;
}
yield {
start: { ts: isoStringFromTs(currentStart * oneHourMs), edges: [] },
end: span.end,
locations: span.locations,
}
}
function tableElementsFromStretches(
stretches: Iterable<Stretch>, locations: ScheduleLocation[]
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<TimeSlot> }
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
function startColumnGroup(className?: string) {
columnGroups.push({ className, cols: []})
}
function startDay(content?: string) {
dayHeaders.push({ span: 0, content })
}
function startHour(content?: string) {
hourHeaders.push({ span: 0, content })
}
function startLocation(id: string, slots = new Set<TimeSlot>()) {
locationRows.get(id)!.push({ span: 0, slots });
}
function pushColumn(minutes?: number) {
columnGroups[columnGroups.length - 1].cols.push({ minutes })
dayHeaders[dayHeaders.length - 1].span += 1;
hourHeaders[hourHeaders.length - 1].span += 1;
for(const location of locations) {
const row = locationRows.get(location.id)!;
row[row.length - 1].span += 1;
}
}
let first = true;
for (const stretch of stretches) {
if (first) {
first = false;
startColumnGroup();
startDay(stretch.start.slice(0, 10));
startHour(stretch.start.slice(11, 16));
for(const location of locations) {
startLocation(location.id);
}
} else {
startColumnGroup(styles.break);
const dayName = stretch.start.slice(0, 10)
const sameDay = dayName === dayHeaders[dayHeaders.length - 1].content;
if (!sameDay)
startDay();
startHour("break");
for(const location of locations) {
startLocation(location.id);
}
pushColumn();
startColumnGroup();
if (!sameDay)
startDay(dayName);
startHour(stretch.start.slice(11, 16));
for(const location of locations) {
startLocation(location.id);
}
}
for (const span of stretch.spans) {
for (const cutSpan of cutSpansByHours(span)) {
const startTs = Date.parse(cutSpan.start.ts);
const endTs = Date.parse(cutSpan.end.ts);
const durationMs = endTs - startTs;
for (const location of locations) {
const rows = locationRows.get(location.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.locations.get(location.id) ?? new Set();
if (!setEquals(slots, row.slots)) {
startLocation(location.id, slots);
}
}
pushColumn(durationMs / oneMinMs);
if (endTs % oneDayMs === 0) {
startDay(cutSpan.end.ts.slice(0, 10));
}
if (endTs % oneHourMs === 0) {
startHour(cutSpan.end.ts.slice(11, 16));
}
}
}
}
return {
columnGroups,
dayHeaders: dayHeaders.filter(day => day.span),
hourHeaders: hourHeaders.filter(hour => hour.span),
locationRows: new Map([...locationRows].map(([id, cells]) => [id, cells.filter(cell => cell.span)])),
};
}
export default function Timetable() {
const { locations, events } = useSchedule()!;
const junctions = junctionsFromEdges(edgesFromEvents(events));
const stretches = [...stretchesFromSpans(spansFromJunctions(junctions, locations), oneHourMs * 5)];
const {
columnGroups,
dayHeaders,
hourHeaders,
locationRows,
} = tableElementsFromStretches(stretches, locations);
const eventBySlotId = new Map(
events.flatMap(
event => event.slots.map(slot => [slot.id, event])
)
);
const debug = <details>
<summary>Debug</summary>
<p><b>Junctions</b></p>
{junctions.map(j => <div key={j.ts}>
{j.ts}: {j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ")}
</div>)}
<p><b>Stretches</b></p>
<ol>
{stretches.map(st => <li key={st.start}>
<p>Stretch from {st.start} to {st.end}.</p>
<p>Spans:</p>
<ul>
{st.spans.map(s => <li key={s.start.ts}>
{s.start.ts} - {s.end.ts}:
<ul>
{[...s.locations].map(([id, slots]) => <li key={id}>
{id}: {[...slots].map(s => s.id).join(", ")}
</li>)}
</ul>
</li>)}
</ul>
</li>)}
</ol>
</details>;
return <figure className={styles.timetable}>
{debug}
<table>
<colgroup>
<col className={styles.header} />
</colgroup>
{columnGroups.map((group, groupIndex) => <colgroup key={groupIndex} className={group.className}>
{group.cols.map((col, index) => <col key={index} style={{ "--minutes": col.minutes}} />)}
</colgroup>)}
<thead>
<tr>
<th></th>
{dayHeaders.map((day, index) => <th key={index} colSpan={day.span}>
{day.content}
</th>)}
</tr>
<tr>
<th>Location</th>
{hourHeaders.map((hour, index) => <th key={index} colSpan={hour.span}>
{hour.content}
</th>)}
</tr>
</thead>
<tbody>
{locations.map(location => <tr key={location.id}>
<th>{location.name}</th>
{locationRows.get(location.id)!.map((row, index) => <td
key={index}
colSpan={row.span}
className={row.slots.size ? styles.event : undefined}
>
{[...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ")}
</td>)}
</tr>)}
</tbody>
</table>
</figure>
}