2025-02-27 15:42:59 +01:00
|
|
|
"use client";
|
|
|
|
import { ScheduleEvent, ScheduleLocation, TimeSlot } from "@/app/schedule/types";
|
2025-02-26 22:53:56 +01:00
|
|
|
import styles from "./timetable.module.css";
|
2025-02-27 15:42:59 +01:00
|
|
|
import { useSchedule } from "@/app/schedule/context";
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
|
|
const oneHourMs = 60 * 60 * 1000;
|
|
|
|
const oneMinMs = 60 * 1000;
|
|
|
|
|
2025-02-27 15:01:30 +01:00
|
|
|
// See timetable-terminology.png for an illustration of how these terms are related
|
|
|
|
|
2025-02-26 22:53:56 +01:00
|
|
|
/** 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>>,
|
|
|
|
};
|
|
|
|
|
2025-02-27 15:01:30 +01:00
|
|
|
/**
|
|
|
|
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.
|
|
|
|
*/
|
2025-02-26 22:53:56 +01:00
|
|
|
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)!);
|
|
|
|
}
|
|
|
|
|
2025-02-26 23:56:19 +01:00
|
|
|
function* spansFromJunctions(
|
|
|
|
junctions: Iterable<Junction>, locations: ScheduleLocation[]
|
|
|
|
): Generator<Span> {
|
2025-02-26 22:53:56 +01:00
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-26 23:56:19 +01:00
|
|
|
function tableElementsFromStretches(
|
|
|
|
stretches: Iterable<Stretch>, locations: ScheduleLocation[]
|
|
|
|
) {
|
2025-02-26 22:53:56 +01:00
|
|
|
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)])),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-02-27 15:42:59 +01:00
|
|
|
export default function Timetable() {
|
|
|
|
const { locations, events } = useSchedule()!;
|
2025-02-26 23:56:19 +01:00
|
|
|
const junctions = junctionsFromEdges(edgesFromEvents(events));
|
|
|
|
const stretches = [...stretchesFromSpans(spansFromJunctions(junctions, locations), oneHourMs * 5)];
|
2025-02-26 22:53:56 +01:00
|
|
|
const {
|
|
|
|
columnGroups,
|
|
|
|
dayHeaders,
|
|
|
|
hourHeaders,
|
|
|
|
locationRows,
|
2025-02-26 23:56:19 +01:00
|
|
|
} = tableElementsFromStretches(stretches, locations);
|
2025-02-26 22:53:56 +01:00
|
|
|
const eventBySlotId = new Map(
|
2025-02-26 23:56:19 +01:00
|
|
|
events.flatMap(
|
2025-02-26 22:53:56 +01:00
|
|
|
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}>
|
|
|
|
{[...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ")}
|
|
|
|
</td>)}
|
|
|
|
</tr>)}
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</figure>
|
|
|
|
}
|