owltide/components/Timetable.vue

494 lines
13 KiB
Vue
Raw Normal View History

<template>
<figure class="timetable">
<details>
<summary>Debug</summary>
<details>
<summary><b>Junctions</b></summary>
<div v-for="j in junctions" :key="j.ts">
{{ j.ts }}: {{ j.edges.map(e => `${e.type} ${e.slot.id}`).join(", ") }}
</div>
</details>
<details>
<summary><b>Stretches</b></summary>
<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>
<p>
<label>
Timezone:
<input type="text" v-model="timezone">
</label>
</p>
</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 { DateTime } from "luxon";
import type { ScheduleEvent, ScheduleLocation, TimeSlot } from "~/shared/types/schedule";
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
/** 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>>,
};
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.
*/
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)!);
}
2025-02-26 23:56:19 +01:00
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 {
return {
spans,
start: spans[0].start.ts,
end: spans[spans.length - 1].end.ts,
}
}
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, timezone: string): Generator<Span> {
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone })
.startOf("hour")
;
const end = span.end.ts;
let currentStart = startHour;
let currentEnd = startHour.plus({ hours: 1 });
if (!startHour.isValid || currentEnd.toMillis() >= end) {
yield span;
return;
}
yield {
start: span.start,
end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations,
}
while (true) {
currentStart = currentEnd;
currentEnd = currentEnd.plus({ hours: 1 });
if (currentEnd.toMillis() >= end) {
break;
}
yield {
start: { ts: currentStart.toMillis(), edges: [] },
end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations,
}
}
yield {
start: { ts: currentStart.toMillis(), edges: [] },
end: span.end,
locations: span.locations,
}
}
function padStretch(stretch: Stretch, timezone: string): Stretch {
// Pad by one hour and extend it to the nearest whole hour.
let start = DateTime.fromMillis(stretch.start, { zone: timezone })
.minus(oneHourMs)
.startOf("hour")
;
let end = DateTime.fromMillis(stretch.end, { zone: timezone })
.plus(2 * oneHourMs - 1)
.startOf("hour")
;
return {
spans: [
{
start: { ts: start.toMillis(), edges: [] },
end: stretch.spans[0].start,
locations: new Map(),
},
...stretch.spans,
{
start: stretch.spans[stretch.spans.length - 1].end,
end: { ts: end.toMillis(), edges: [] },
locations: new Map(),
},
],
start: start.toMillis(),
end: end.toMillis(),
}
}
2025-02-26 23:56:19 +01:00
function tableElementsFromStretches(
stretches: Iterable<Stretch>, locations: ScheduleLocation[], timezone: string,
2025-02-26 23:56:19 +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 (let stretch of stretches) {
stretch = padStretch(stretch, timezone);
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone });
if (first) {
first = false;
startColumnGroup();
startDay(startDate.toFormat("yyyy-LL-dd"));
startHour(startDate.toFormat("HH:mm"));
for(const location of locations) {
startLocation(location.id);
}
} else {
startColumnGroup("break");
const dayName = startDate.toFormat("yyyy-LL-dd");
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(startDate.toFormat("HH:mm"));
for(const location of locations) {
startLocation(location.id);
}
}
for (const span of stretch.spans) {
for (const cutSpan of cutSpansByHours(span, timezone)) {
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);
const endDate = DateTime.fromMillis(end, { zone: timezone });
if (end === endDate.startOf("day").toMillis()) {
startDay(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone })
.toFormat("yyyy-LL-dd")
);
}
if (end === endDate.startOf("hour").toMillis()) {
startHour(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone })
.toFormat("HH:mm")
);
}
}
}
}
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 runtimeConfig = useRuntimeConfig();
const { data: session } = await useAccountSession();
const debugTimezone = ref<undefined | string>();
const timezone = computed({
get: () => debugTimezone.value ?? session.value?.account.timezone ?? runtimeConfig.public.defaultTimezone,
set: (value: string) => { debugTimezone.value = value },
});
const elements = computed(() => tableElementsFromStretches(stretches.value, schedule.value.locations, timezone.value));
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>