owltide/components/Timetable.vue
Hornwitser fe06d0d6bd
All checks were successful
/ build (push) Successful in 2m5s
/ deploy (push) Successful in 16s
Refactor API types and sync logic
Rename and refactor the types passed over the API to be based on an
entity that's either living or a tombstone.  A living entity has a
deleted property that's either undefined or false, while a tombstone
has a deleted property set to true.  All entities have a numeric id
and an updatedAt timestamp.

To sync entities, an array of replacements are passed around. Living
entities are replaced with tombstones when they're deleted. And
tombstones are replaced with living entities when restored.
2025-06-11 21:05:17 +02:00

614 lines
18 KiB
Vue

<template>
<figure class="timetable" v-if="schedule.deleted">
<p>
Error: Schedule is deleted.
</p>
</figure>
<figure class="timetable" v-else>
<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>
<template v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
<tr v-if="locationRows.has(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, "crew": row.crew }'
:title="row.title"
>
{{ row.title }}
</td>
</tr>
</template>
<template v-if="schedule.roles">
<tr>
<th>Shifts</th>
<td :colSpan="totalColumns"></td>
</tr>
<template v-for="role in schedule.roles?.filter(r => !r.deleted)" :key="role.id">
<tr v-if="roleRows.has(role.id)">
<th>{{ role.name }}</th>
<td
v-for="row, index in roleRows.get(role.id)"
:key="index"
:colSpan="row.span"
:class='{"shift": row.slots.size }'
:title="row.title"
>
{{ row.title }}
</td>
</tr>
</template>
</template>
</tbody>
</table>
</figure>
</template>
<script setup lang="ts">
import { DateTime } from "luxon";
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleLocation, ApiScheduleRole, ApiScheduleShift, ApiScheduleShiftSlot } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
import { pairs, setEquals } from "~/shared/utils/functions";
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", source: "event", slot: ApiScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
;
/** 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<number, Set<ApiScheduleEventSlot>>,
roles: Map<number, Set<ApiScheduleShiftSlot>>,
};
/**
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[];
}
function* edgesFromEvents(
events: Iterable<Extract<ApiScheduleEvent, { deleted?: false }>>,
filter = (slot: ApiScheduleEventSlot) => true,
): Generator<Edge> {
for (const event of events) {
for (const slot of event.slots.filter(filter)) {
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", source: "event", slot }
yield { type: "end", source: "event", slot }
}
}
}
function* edgesFromShifts(
shifts: Iterable<Extract<ApiScheduleShift, { deleted?: false }>>,
filter = (slot: ApiScheduleShiftSlot) => true,
): Generator<Edge> {
for (const shift of shifts) {
for (const slot of shift.slots.filter(filter)) {
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
yield { type: "end", source: "shift", roleId: shift.roleId, 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: Extract<ApiScheduleLocation, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
): Generator<Span> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
);
const activeRoles = new Map(
roles?.map(role => [role.id, new Set<ApiScheduleShiftSlot>()])
);
for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) {
if (edge.type === "start") {
if (edge.source === "event") {
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.add(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.add(edge.slot)
}
}
}
yield {
start,
end,
locations: new Map(
[...activeLocations]
.filter(([_, slots]) => slots.size)
.map(([location, slots]) => [location, new Set(slots)])
),
roles: new Map(
[...activeRoles]
.filter(([_, slots]) => slots.size)
.map(([role, slots]) => [role, new Set(slots)])
),
}
for (const edge of end.edges) {
if (edge.type === "end") {
if (edge.source === "event") {
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.delete(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.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.roles.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, locale: "en-US" })
.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,
roles: span.roles,
}
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,
roles: span.roles,
}
}
yield {
start: { ts: currentStart.toMillis(), edges: [] },
end: span.end,
locations: span.locations,
roles: span.roles,
}
}
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, locale: "en-US" })
.minus(oneHourMs)
.startOf("hour")
;
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: "en-US" })
.plus(2 * oneHourMs - 1)
.startOf("hour")
;
return {
spans: [
{
start: { ts: start.toMillis(), edges: [] },
end: stretch.spans[0].start,
locations: new Map(),
roles: new Map(),
},
...stretch.spans,
{
start: stretch.spans[stretch.spans.length - 1].end,
end: { ts: end.toMillis(), edges: [] },
locations: new Map(),
roles: new Map(),
},
],
start: start.toMillis(),
end: end.toMillis(),
}
}
function tableElementsFromStretches(
stretches: Iterable<Stretch>,
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
timezone: string,
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, title: string };
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
const locationRows = new Map<number, LocationCell[]>(locations.map(location => [location.id, []]));
const roleRows = new Map<number, RoleCell[]>(roles.map(role => [role.id, []]));
const eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
const shiftBySlotId = new Map(shifts?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
let totalColumns = 0;
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: number, slots = new Set<ApiScheduleEventSlot>()) {
const rows = locationRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
row.title = [...row.slots].map(slot => eventBySlotId.get(slot.id)!.name).join(", ");
row.crew = [...row.slots].every(slot => eventBySlotId.get(slot.id)!.crew);
}
rows.push({ span: 0, slots, title: "" });
}
function startRole(id: number, slots = new Set<ApiScheduleShiftSlot>()) {
const rows = roleRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
row.title = [...row.slots].map(slot => shiftBySlotId.get(slot.id)!.name).join(", ");
}
rows.push({ span: 0, slots, title: "" });
}
function pushColumn(minutes?: number) {
totalColumns += 1;
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;
}
for(const role of roles ?? []) {
const row = roleRows.get(role.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, locale: "en-US" });
if (first) {
first = false;
startColumnGroup();
startDay(startDate.toFormat("yyyy-LL-dd"));
startHour(startDate.toFormat("HH:mm"));
for(const location of locations) {
startLocation(location.id);
}
for(const role of roles ?? []) {
startRole(role.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);
}
for(const role of roles ?? []) {
startRole(role.id);
}
pushColumn();
startColumnGroup();
if (!sameDay)
startDay(dayName);
startHour(startDate.toFormat("HH:mm"));
for(const location of locations) {
startLocation(location.id);
}
for(const role of roles ?? []) {
startRole(role.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);
}
}
for (const role of roles ?? []) {
const rows = roleRows.get(role.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.roles.get(role.id) ?? new Set();
if (!setEquals(slots, row.slots)) {
startRole(role.id, slots);
}
}
pushColumn(durationMs / oneMinMs);
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: "en-US" });
if (end === endDate.startOf("day").toMillis()) {
startDay(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
.toFormat("yyyy-LL-dd")
);
}
if (end === endDate.startOf("hour").toMillis()) {
startHour(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
.toFormat("HH:mm")
);
}
}
}
}
return {
totalColumns,
columnGroups,
dayHeaders: dayHeaders.filter(day => day.span),
hourHeaders: hourHeaders.filter(hour => hour.span),
locationRows: new Map([...locationRows]
.filter(([_, cells]) => cells.some(cell => cell.slots.size))
.map(([id, cells]) => [id, cells.filter(cell => cell.span)]))
,
roleRows: new Map([...roleRows]
.filter(([_, cells]) => cells.some(cell => cell.slots.size))
.map(([id, cells]) => [id, cells.filter(cell => cell.span)]))
,
eventBySlotId,
};
}
const props = defineProps<{
schedule: ApiSchedule,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
const junctions = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return junctionsFromEdges([
...edgesFromEvents(schedule.value.events?.filter(e => !e.deleted) ?? [], props.eventSlotFilter),
...edgesFromShifts(schedule.value.shifts?.filter(s => !s.deleted) ?? [], props.shiftSlotFilter),
])
});
const stretches = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return [
...stretchesFromSpans(
spansFromJunctions(
junctions.value,
schedule.value.locations?.filter(l => !l.deleted) ?? [],
schedule.value.roles?.filter(r => !r.deleted) ?? [],
),
oneHourMs * 5
)
]
})
const accountStore = useAccountStore();
const timezone = computed({
get: () => accountStore.activeTimezone,
set: (value: string) => { accountStore.timezone = value },
});
const elements = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return tableElementsFromStretches(
stretches.value,
schedule.value.events?.filter(e => !e.deleted) ?? [],
schedule.value.locations?.filter(l => !l.deleted) ?? [],
schedule.value.shifts?.filter(s => !s.deleted) ?? [],
schedule.value.roles?.filter(r => !r.deleted) ?? [],
accountStore.activeTimezone
);
});
const totalColumns = computed(() => elements.value.totalColumns);
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 roleRows = computed(() => elements.value.roleRows);
</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) {
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
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, .shift {
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
}
.event.crew {
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
}
</style>