owltide/components/Timetable.vue
Hornwitser 1ac607a712 Use unix timestamps in timetable logic
Parse the iso date strings into millseconds from the unix epoch and use
that through the timetable logic instead of reparsing the strings over
and over.
2025-03-09 16:49:57 +01:00

446 lines
12 KiB
Vue

<template>
<figure class="timetable">
<details>
<summary>Debug</summary>
<p><b>Junctions</b></p>
<div v-for="j in junctions" :key="j.ts">
{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
</div>
<p><b>Stretches</b></p>
<ol>
<li v-for="st in stretches" :key="st.start">
<p>Stretch from {{ st.start }} to {{ st.end }}.</p>
<p>Spans:</p>
<ul>
<li v-for="s in st.spans" :key="s.start.ts">
{{ s.start.ts }} - {{ s.end.ts }}:
<ul>
<li v-for="[id, slots] in s.locations" :key="id">
{{ id }}: {{ [...slots].map(s => s.id).join(", ") }}
</li>
</ul>
</li>
</ul>
</li>
</ol>
</details>
<table>
<colgroup>
<col class="header" />
</colgroup>
<colgroup v-for="group, groupIndex in columnGroups" :key="groupIndex" :class="group.className">
<col v-for="col, index in group.cols" :key="index" :style='{"--minutes": col.minutes}' />
</colgroup>
<thead>
<tr>
<th></th>
<th v-for="day, index in dayHeaders" :key="index" :colSpan="day.span">
{{ day.content }}
</th>
</tr>
<tr>
<th>Location</th>
<th v-for="hour, index in hourHeaders" :key="index" :colSpan="hour.span">
{{ hour.content }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="location in schedule.locations" :key="location.id">
<th>{{ location.name }}</th>
<td
v-for="row, index in locationRows.get(location.id)"
:key="index"
:colSpan="row.span"
:class='{"event": row.slots.size }'
>
{{ [...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ") }}
</td>
</tr>
</tbody>
</table>
</figure>
</template>
<script setup lang="ts">
import type { ScheduleEvent, ScheduleLocation, TimeSlot } from "~/shared/types/schedule";
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: number, 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: number,
end: number,
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
*/
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<number, Junction>();
for (const edge of edges) {
const ts = Date.parse(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 start = spans[0].start.ts - oneHourMs;
let end = spans[spans.length - 1].end.ts + oneHourMs;
// Extend stretch to nearest whole hours
start = Math.floor(start / oneHourMs) * oneHourMs;
end = Math.ceil(end / oneHourMs) * oneHourMs;
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
&& span.end.ts - 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 = span.start.ts / oneHourMs;
const endHour = 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: currentEnd * oneHourMs, edges: [] },
locations: span.locations,
}
currentStart = currentEnd;
while (++currentEnd < endHour) {
yield {
start: { ts: currentStart * oneHourMs, edges: [] },
end: { ts: currentEnd * oneHourMs, edges: [] },
locations: span.locations,
}
currentStart += 1;
}
yield {
start: { ts: 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(isoStringFromTs(stretch.start).slice(0, 10));
startHour(isoStringFromTs(stretch.start).slice(11, 16));
for(const location of locations) {
startLocation(location.id);
}
} else {
startColumnGroup("break");
const dayName = isoStringFromTs(stretch.start).slice(0, 10)
const lastDayHeader = dayHeaders[dayHeaders.length - 1]
const sameDay = dayName === lastDayHeader.content && lastDayHeader.span;
if (!sameDay)
startDay();
startHour("break");
for(const location of locations) {
startLocation(location.id);
}
pushColumn();
startColumnGroup();
if (!sameDay)
startDay(dayName);
startHour(isoStringFromTs(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 end = cutSpan.end.ts;
const durationMs = end - cutSpan.start.ts;
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 (end % oneDayMs === 0) {
startDay(isoStringFromTs(cutSpan.end.ts).slice(0, 10));
}
if (end % oneHourMs === 0) {
startHour(isoStringFromTs(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)])),
};
}
const schedule = useSchedule();
const junctions = computed(() => junctionsFromEdges(edgesFromEvents(schedule.value.events)));
const stretches = computed(() => [
...stretchesFromSpans(spansFromJunctions(junctions.value, schedule.value.locations), oneHourMs * 5)
])
const elements = computed(() => tableElementsFromStretches(stretches.value, schedule.value.locations));
const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders);
const hourHeaders = computed(() => elements.value.hourHeaders);
const locationRows = computed(() => elements.value.locationRows);
const eventBySlotId = computed(() => new Map(
schedule.value.events.flatMap(
event => event.slots.map(slot => [slot.id, event])
)
));
</script>
<style scoped>
.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%);
}
</style>