owltide/components/Timetable.vue
Hornwitser bb450fd583 Refactor to use ClientSchedule on client
Use the ClientSchedule data structure for deserialising and tracking
edit state on the client instead of trying to directly deal with the
ApiSchedule type which is not build for ease of edits or rendering.
2025-06-14 19:22:53 +02:00

607 lines
17 KiB
Vue

<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>
<template v-for="location in schedule.locations.values()" :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.size">
<tr>
<th>Shifts</th>
<td :colSpan="totalColumns"></td>
</tr>
<template v-for="role in schedule.roles.values()" :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 { 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: ClientScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ClientScheduleShiftSlot }
;
/** 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<ClientScheduleEventSlot>>,
roles: Map<number, Set<ClientScheduleShiftSlot>>,
};
/**
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<ClientScheduleEvent>,
filter = (slot: ClientScheduleEventSlot) => true,
): Generator<Edge> {
for (const event of events) {
if (event.deleted)
continue;
for (const slot of event.slots.values()) {
if (!filter(slot) || slot.deleted)
continue;
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<ClientScheduleShift>,
filter = (slot: ClientScheduleShiftSlot) => true,
): Generator<Edge> {
for (const shift of shifts) {
if (shift.deleted)
continue;
for (const slot of shift.slots.values()) {
if (!filter(slot) || slot.deleted)
continue;
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", source: "shift", roleId: shift.role.id, slot };
yield { type: "end", source: "shift", roleId: shift.role.id, slot };
}
}
}
function junctionsFromEdges(edges: Iterable<Edge>) {
const junctions = new Map<number, Junction>();
for (const edge of edges) {
const ts = edge.slot[edge.type].toMillis();
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: Map<Id, ClientScheduleLocation>,
roles: Map<Id, ClientScheduleRole>,
): Generator<Span> {
const activeLocations = new Map(
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
);
const activeRoles = new Map(
[...roles.keys()].map(id => [id, new Set<ClientScheduleShiftSlot>()])
);
for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) {
if (edge.type === "start") {
if (edge.source === "event") {
for (const location of edge.slot.locations) {
activeLocations.get(location.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 location of edge.slot.locations) {
activeLocations.get(location.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: accountStore.activeLocale })
.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: accountStore.activeLocale })
.minus(oneHourMs)
.startOf("hour")
;
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
.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: Map<Id, ClientScheduleEvent>,
locations: Map<Id, ClientScheduleLocation>,
shifts: Map<Id, ClientScheduleShift>,
roles: Map<Id, ClientScheduleRole>,
timezone: string,
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<ClientScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ClientScheduleShiftSlot>, title: string };
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
const locationRows = new Map<number, LocationCell[]>([...locations.keys()].map(id => [id, []]));
const roleRows = new Map<number, RoleCell[]>([...roles.keys()].map(id => [id, []]));
const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].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<ClientScheduleEventSlot>()) {
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<ClientScheduleShiftSlot>()) {
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.values()) {
const row = locationRows.get(location.id)!;
row[row.length - 1].span += 1;
}
for(const role of roles.values()) {
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: accountStore.activeLocale });
if (first) {
first = false;
startColumnGroup();
startDay(startDate.toFormat("yyyy-LL-dd"));
startHour(startDate.toFormat("HH:mm"));
for(const location of locations.values()) {
startLocation(location.id);
}
for(const role of roles.values()) {
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.values()) {
startLocation(location.id);
}
for(const role of roles.values()) {
startRole(role.id);
}
pushColumn();
startColumnGroup();
if (!sameDay)
startDay(dayName);
startHour(startDate.toFormat("HH:mm"));
for(const location of locations.values()) {
startLocation(location.id);
}
for(const role of roles.values()) {
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.values()) {
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.values()) {
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: accountStore.activeLocale });
if (end === endDate.startOf("day").toMillis()) {
startDay(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
.toFormat("yyyy-LL-dd")
);
}
if (end === endDate.startOf("hour").toMillis()) {
startHour(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
.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: ClientSchedule,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
const junctions = computed(() => {
return junctionsFromEdges([
...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
...edgesFromShifts(schedule.value.shifts.values(), props.shiftSlotFilter),
])
});
const stretches = computed(() => {
return [
...stretchesFromSpans(
spansFromJunctions(
junctions.value,
schedule.value.locations,
schedule.value.roles,
),
oneHourMs * 5
)
]
})
const accountStore = useAccountStore();
const timezone = computed({
get: () => accountStore.activeTimezone,
set: (value: string) => { accountStore.timezone = value },
});
const elements = computed(() => {
return tableElementsFromStretches(
stretches.value,
schedule.value.events,
schedule.value.locations,
schedule.value.shifts,
schedule.value.roles,
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>