"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>, }; /** 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(iterable: Iterable) { let index = 0; for (const item of iterable) { yield [index++, item] as [number, T]; } } /** Returns adjacent pairs from iterable */ function* pairs(iterable: Iterable) { 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(...sets: Set[]) { 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): Generator { 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) { const junctions = new Map(); 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, locations: ScheduleLocation[] ): Generator { const activeLocations = new Map( locations.map(location => [location.id, new Set()]) ); 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, minSeparation: number): Generator { 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 { 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, locations: ScheduleLocation[] ) { type Col = { minutes?: number }; type DayHead = { span: number, content?: string } type HourHead = { span: number, content?: string } type LocationCell = { span: number, slots: Set } const columnGroups: { className?: string, cols: Col[] }[] = []; const dayHeaders: DayHead[] = []; const hourHeaders: HourHead[]= []; const locationRows = new Map(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()) { 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 =
Debug

Junctions

{junctions.map(j =>
{j.ts}: {j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ")}
)}

Stretches

    {stretches.map(st =>
  1. Stretch from {st.start} to {st.end}.

    Spans:

      {st.spans.map(s =>
    • {s.start.ts} - {s.end.ts}:
        {[...s.locations].map(([id, slots]) =>
      • {id}: {[...slots].map(s => s.id).join(", ")}
      • )}
    • )}
  2. )}
; return
{debug} {columnGroups.map((group, groupIndex) => {group.cols.map((col, index) => )} )} {dayHeaders.map((day, index) => )} {hourHeaders.map((hour, index) => )} {locations.map(location => {locationRows.get(location.id)!.map((row, index) => )} )}
{day.content}
Location {hour.content}
{location.name} {[...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ")}
}