2025-03-05 15:36:50 +01:00
|
|
|
<template>
|
|
|
|
<figure class="timetable">
|
|
|
|
<details>
|
|
|
|
<summary>Debug</summary>
|
2025-03-09 18:35:38 +01:00
|
|
|
<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>
|
2025-03-05 15:36:50 +01:00
|
|
|
</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"
|
2025-03-10 14:40:02 +01:00
|
|
|
:class='{"event": row.slots.size, "crew": row.crew }'
|
|
|
|
:title="row.title"
|
2025-03-05 15:36:50 +01:00
|
|
|
>
|
2025-03-10 14:40:02 +01:00
|
|
|
{{ row.title }}
|
2025-03-05 15:36:50 +01:00
|
|
|
</td>
|
|
|
|
</tr>
|
2025-03-10 20:58:33 +01:00
|
|
|
<template v-if="schedule.roles">
|
|
|
|
<tr>
|
|
|
|
<th>Shifts</th>
|
|
|
|
<td :colSpan="totalColumns"></td>
|
|
|
|
</tr>
|
|
|
|
<tr v-for="role in schedule.roles" :key="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>
|
2025-03-05 15:36:50 +01:00
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</figure>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2025-03-09 18:35:38 +01:00
|
|
|
import { DateTime } from "luxon";
|
2025-03-10 20:58:33 +01:00
|
|
|
import type { Role, ScheduleEvent, ScheduleLocation, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
|
2025-03-12 14:44:06 +01:00
|
|
|
import { pairs, setEquals } from "~/shared/utils/functions";
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2025-02-26 22:53:56 +01:00
|
|
|
/** Point in time where a time slots starts or ends. */
|
2025-03-10 20:58:33 +01:00
|
|
|
type Edge =
|
|
|
|
| { type: "start" | "end", source: "event", slot: TimeSlot }
|
|
|
|
| { type: "start" | "end", source: "shift", role: string, slot: ShiftSlot }
|
|
|
|
;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
/** Point in time where multiple edges meet. */
|
2025-03-09 16:49:57 +01:00
|
|
|
type Junction = { ts: number, edges: Edge[] };
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
/** Span of time between two adjacent junctions */
|
|
|
|
type Span = {
|
|
|
|
start: Junction;
|
|
|
|
end: Junction,
|
|
|
|
locations: Map<string, Set<TimeSlot>>,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: Map<string, Set<ShiftSlot>>,
|
2025-02-26 22:53:56 +01:00
|
|
|
};
|
|
|
|
|
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.
|
|
|
|
*/
|
2025-02-26 22:53:56 +01:00
|
|
|
type Stretch = {
|
2025-03-09 16:49:57 +01:00
|
|
|
start: number,
|
|
|
|
end: number,
|
2025-02-26 22:53:56 +01:00
|
|
|
spans: Span[];
|
|
|
|
}
|
|
|
|
|
|
|
|
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.`);
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
yield { type: "start", source: "event", slot }
|
|
|
|
yield { type: "end", source: "event", slot }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function* edgesFromShifts(shifts: Iterable<Shift>): Generator<Edge> {
|
|
|
|
for (const shift of shifts) {
|
|
|
|
for (const slot of shift.slots) {
|
|
|
|
if (slot.start > slot.end) {
|
|
|
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
|
|
|
}
|
|
|
|
yield { type: "start", source: "shift", role: shift.role, slot };
|
|
|
|
yield { type: "end", source: "shift", role: shift.role, slot };
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function junctionsFromEdges(edges: Iterable<Edge>) {
|
2025-03-09 16:49:57 +01:00
|
|
|
const junctions = new Map<number, Junction>();
|
2025-02-26 22:53:56 +01:00
|
|
|
for (const edge of edges) {
|
2025-03-09 16:49:57 +01:00
|
|
|
const ts = Date.parse(edge.slot[edge.type]);
|
2025-02-26 22:53:56 +01:00
|
|
|
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(
|
2025-03-10 20:58:33 +01:00
|
|
|
junctions: Iterable<Junction>, locations: ScheduleLocation[], roles: Role[] | undefined,
|
2025-02-26 23:56:19 +01:00
|
|
|
): Generator<Span> {
|
2025-02-26 22:53:56 +01:00
|
|
|
const activeLocations = new Map(
|
|
|
|
locations.map(location => [location.id, new Set<TimeSlot>()])
|
|
|
|
);
|
2025-03-10 20:58:33 +01:00
|
|
|
const activeRoles = new Map(
|
|
|
|
roles?.map(role => [role.id, new Set<ShiftSlot>()])
|
|
|
|
);
|
2025-02-26 22:53:56 +01:00
|
|
|
for (const [start, end] of pairs(junctions)) {
|
|
|
|
for (const edge of start.edges) {
|
|
|
|
if (edge.type === "start") {
|
2025-03-10 20:58:33 +01:00
|
|
|
if (edge.source === "event") {
|
|
|
|
for (const location of edge.slot.locations) {
|
|
|
|
activeLocations.get(location)?.add(edge.slot)
|
|
|
|
}
|
|
|
|
} else if (edge.source === "shift") {
|
|
|
|
activeRoles.get(edge.role)?.add(edge.slot)
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
yield {
|
|
|
|
start,
|
|
|
|
end,
|
|
|
|
locations: new Map(
|
|
|
|
[...activeLocations]
|
|
|
|
.filter(([_, slots]) => slots.size)
|
|
|
|
.map(([location, slots]) => [location, new Set(slots)])
|
|
|
|
),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(
|
|
|
|
[...activeRoles]
|
|
|
|
.filter(([_, slots]) => slots.size)
|
|
|
|
.map(([role, slots]) => [role, new Set(slots)])
|
|
|
|
),
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
for (const edge of end.edges) {
|
|
|
|
if (edge.type === "end") {
|
2025-03-10 20:58:33 +01:00
|
|
|
if (edge.source === "event") {
|
|
|
|
for (const location of edge.slot.locations) {
|
|
|
|
activeLocations.get(location)?.delete(edge.slot)
|
|
|
|
}
|
|
|
|
} else if (edge.source === "shift") {
|
|
|
|
activeRoles.get(edge.role)?.delete(edge.slot);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function createStretch(spans: Span[]): Stretch {
|
|
|
|
return {
|
2025-03-09 18:35:38 +01:00
|
|
|
spans,
|
|
|
|
start: spans[0].start.ts,
|
|
|
|
end: spans[spans.length - 1].end.ts,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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.
|
2025-03-10 20:58:33 +01:00
|
|
|
if (
|
|
|
|
span.locations.size === 0
|
|
|
|
&& span.roles.size === 0
|
2025-03-09 16:49:57 +01:00
|
|
|
&& span.end.ts - span.start.ts >= minSeparation
|
2025-02-26 22:53:56 +01:00
|
|
|
) {
|
|
|
|
yield createStretch(currentSpans);
|
|
|
|
currentSpans = [];
|
|
|
|
} else {
|
|
|
|
currentSpans.push(span);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (currentSpans.length)
|
|
|
|
yield createStretch(currentSpans);
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Cuts up a span by whole hours that crosses it */
|
2025-03-09 18:35:38 +01:00
|
|
|
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
|
|
|
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone })
|
|
|
|
.startOf("hour")
|
|
|
|
;
|
|
|
|
const end = span.end.ts;
|
|
|
|
|
2025-02-26 22:53:56 +01:00
|
|
|
let currentStart = startHour;
|
2025-03-09 18:35:38 +01:00
|
|
|
let currentEnd = startHour.plus({ hours: 1 });
|
|
|
|
if (!startHour.isValid || currentEnd.toMillis() >= end) {
|
2025-02-26 22:53:56 +01:00
|
|
|
yield span;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
yield {
|
|
|
|
start: span.start,
|
2025-03-09 18:35:38 +01:00
|
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
2025-03-09 18:35:38 +01:00
|
|
|
while (true) {
|
|
|
|
currentStart = currentEnd;
|
|
|
|
currentEnd = currentEnd.plus({ hours: 1 });
|
|
|
|
if (currentEnd.toMillis() >= end) {
|
|
|
|
break;
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
yield {
|
2025-03-09 18:35:38 +01:00
|
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
|
|
|
end: { ts: currentEnd.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
yield {
|
2025-03-09 18:35:38 +01:00
|
|
|
start: { ts: currentStart.toMillis(), edges: [] },
|
2025-02-26 22:53:56 +01:00
|
|
|
end: span.end,
|
|
|
|
locations: span.locations,
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: span.roles,
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-09 18:35:38 +01:00
|
|
|
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(),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(),
|
2025-03-09 18:35:38 +01:00
|
|
|
},
|
|
|
|
...stretch.spans,
|
|
|
|
{
|
|
|
|
start: stretch.spans[stretch.spans.length - 1].end,
|
|
|
|
end: { ts: end.toMillis(), edges: [] },
|
|
|
|
locations: new Map(),
|
2025-03-10 20:58:33 +01:00
|
|
|
roles: new Map(),
|
2025-03-09 18:35:38 +01:00
|
|
|
},
|
|
|
|
],
|
|
|
|
start: start.toMillis(),
|
|
|
|
end: end.toMillis(),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-02-26 23:56:19 +01:00
|
|
|
function tableElementsFromStretches(
|
2025-03-10 14:40:02 +01:00
|
|
|
stretches: Iterable<Stretch>,
|
|
|
|
events: ScheduleEvent[],
|
|
|
|
locations: ScheduleLocation[],
|
2025-03-10 20:58:33 +01:00
|
|
|
rota: Shift[] | undefined,
|
|
|
|
roles: Role[] | undefined,
|
2025-03-10 14:40:02 +01:00
|
|
|
timezone: string,
|
2025-02-26 23:56:19 +01:00
|
|
|
) {
|
2025-02-26 22:53:56 +01:00
|
|
|
type Col = { minutes?: number };
|
|
|
|
type DayHead = { span: number, content?: string }
|
|
|
|
type HourHead = { span: number, content?: string }
|
2025-03-10 14:40:02 +01:00
|
|
|
type LocationCell = { span: number, slots: Set<TimeSlot>, title: string, crew?: boolean }
|
2025-03-10 20:58:33 +01:00
|
|
|
type RoleCell = { span: number, slots: Set<ShiftSlot>, title: string };
|
2025-02-26 22:53:56 +01:00
|
|
|
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
|
|
|
const dayHeaders: DayHead[] = [];
|
|
|
|
const hourHeaders: HourHead[]= [];
|
|
|
|
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
|
2025-03-10 20:58:33 +01:00
|
|
|
const roleRows = new Map<string, RoleCell[]>(roles?.map?.(role => [role.id, []]));
|
2025-03-10 14:40:02 +01:00
|
|
|
const eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
|
2025-03-10 20:58:33 +01:00
|
|
|
const shiftBySlotId = new Map(rota?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
|
|
|
|
let totalColumns = 0;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
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>()) {
|
2025-03-10 14:40:02 +01:00
|
|
|
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: "" });
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
function startRole(id: string, slots = new Set<ShiftSlot>()) {
|
|
|
|
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: "" });
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
function pushColumn(minutes?: number) {
|
2025-03-10 20:58:33 +01:00
|
|
|
totalColumns += 1;
|
2025-02-26 22:53:56 +01:00
|
|
|
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;
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
for(const role of roles ?? []) {
|
|
|
|
const row = roleRows.get(role.id)!;
|
|
|
|
row[row.length - 1].span += 1;
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let first = true;
|
2025-03-09 18:35:38 +01:00
|
|
|
for (let stretch of stretches) {
|
|
|
|
stretch = padStretch(stretch, timezone);
|
|
|
|
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone });
|
2025-02-26 22:53:56 +01:00
|
|
|
if (first) {
|
|
|
|
first = false;
|
|
|
|
startColumnGroup();
|
2025-03-09 18:35:38 +01:00
|
|
|
startDay(startDate.toFormat("yyyy-LL-dd"));
|
|
|
|
startHour(startDate.toFormat("HH:mm"));
|
2025-02-26 22:53:56 +01:00
|
|
|
for(const location of locations) {
|
|
|
|
startLocation(location.id);
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
for(const role of roles ?? []) {
|
|
|
|
startRole(role.id);
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
} else {
|
2025-03-05 15:36:50 +01:00
|
|
|
startColumnGroup("break");
|
2025-03-09 18:35:38 +01:00
|
|
|
const dayName = startDate.toFormat("yyyy-LL-dd");
|
2025-03-06 00:37:40 +01:00
|
|
|
const lastDayHeader = dayHeaders[dayHeaders.length - 1]
|
|
|
|
const sameDay = dayName === lastDayHeader.content && lastDayHeader.span;
|
2025-02-26 22:53:56 +01:00
|
|
|
if (!sameDay)
|
|
|
|
startDay();
|
|
|
|
startHour("break");
|
|
|
|
for(const location of locations) {
|
|
|
|
startLocation(location.id);
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
for(const role of roles ?? []) {
|
|
|
|
startRole(role.id);
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
pushColumn();
|
|
|
|
|
|
|
|
startColumnGroup();
|
|
|
|
if (!sameDay)
|
|
|
|
startDay(dayName);
|
2025-03-09 18:35:38 +01:00
|
|
|
startHour(startDate.toFormat("HH:mm"));
|
2025-02-26 22:53:56 +01:00
|
|
|
for(const location of locations) {
|
|
|
|
startLocation(location.id);
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
for(const role of roles ?? []) {
|
|
|
|
startRole(role.id);
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
for (const span of stretch.spans) {
|
2025-03-09 18:35:38 +01:00
|
|
|
for (const cutSpan of cutSpansByHours(span, timezone)) {
|
2025-03-09 16:49:57 +01:00
|
|
|
const end = cutSpan.end.ts;
|
|
|
|
const durationMs = end - cutSpan.start.ts;
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
2025-03-10 20:58:33 +01:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
2025-02-26 22:53:56 +01:00
|
|
|
|
|
|
|
pushColumn(durationMs / oneMinMs);
|
2025-03-09 18:35:38 +01:00
|
|
|
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")
|
|
|
|
);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-03-09 18:35:38 +01:00
|
|
|
if (end === endDate.startOf("hour").toMillis()) {
|
|
|
|
startHour(
|
|
|
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone })
|
|
|
|
.toFormat("HH:mm")
|
|
|
|
);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2025-03-10 20:58:33 +01:00
|
|
|
totalColumns,
|
2025-02-26 22:53:56 +01:00
|
|
|
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)])),
|
2025-03-10 20:58:33 +01:00
|
|
|
roleRows: new Map([...roleRows].map(([id, cells]) => [id, cells.filter(cell => cell.span)])),
|
2025-03-10 14:40:02 +01:00
|
|
|
eventBySlotId,
|
2025-02-26 22:53:56 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-03-10 15:41:32 +01:00
|
|
|
const schedule = await useSchedule();
|
2025-03-10 20:58:33 +01:00
|
|
|
const junctions = computed(() => junctionsFromEdges([
|
|
|
|
...edgesFromEvents(schedule.value.events),
|
|
|
|
...edgesFromShifts(schedule.value.rota ?? []),
|
|
|
|
]));
|
2025-03-05 15:36:50 +01:00
|
|
|
const stretches = computed(() => [
|
2025-03-10 20:58:33 +01:00
|
|
|
...stretchesFromSpans(
|
|
|
|
spansFromJunctions(junctions.value, schedule.value.locations, schedule.value.roles),
|
|
|
|
oneHourMs * 5
|
|
|
|
)
|
2025-03-05 15:36:50 +01:00
|
|
|
])
|
2025-03-09 18:35:38 +01:00
|
|
|
|
|
|
|
const runtimeConfig = useRuntimeConfig();
|
|
|
|
const { data: session } = await useAccountSession();
|
|
|
|
const debugTimezone = ref<undefined | string>();
|
|
|
|
const timezone = computed({
|
2025-03-09 22:18:42 +01:00
|
|
|
get: () => debugTimezone.value ?? session.value?.account?.timezone ?? runtimeConfig.public.defaultTimezone,
|
2025-03-09 18:35:38 +01:00
|
|
|
set: (value: string) => { debugTimezone.value = value },
|
|
|
|
});
|
|
|
|
|
2025-03-10 14:40:02 +01:00
|
|
|
const elements = computed(() => tableElementsFromStretches(
|
2025-03-10 20:58:33 +01:00
|
|
|
stretches.value, schedule.value.events, schedule.value.locations, schedule.value.rota, schedule.value.roles, timezone.value
|
2025-03-10 14:40:02 +01:00
|
|
|
));
|
2025-03-10 20:58:33 +01:00
|
|
|
const totalColumns = computed(() => elements.value.totalColumns);
|
2025-03-05 15:36:50 +01:00
|
|
|
const columnGroups = computed(() => elements.value.columnGroups);
|
|
|
|
const dayHeaders = computed(() => elements.value.dayHeaders);
|
|
|
|
const hourHeaders = computed(() => elements.value.hourHeaders);
|
|
|
|
const locationRows = computed(() => elements.value.locationRows);
|
2025-03-10 20:58:33 +01:00
|
|
|
const roleRows = computed(() => elements.value.roleRows);
|
2025-03-05 15:36:50 +01:00
|
|
|
</script>
|
2025-02-26 22:53:56 +01:00
|
|
|
|
2025-03-05 15:36:50 +01:00
|
|
|
<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) {
|
2025-03-10 14:40:02 +01:00
|
|
|
overflow: hidden;
|
|
|
|
white-space: pre;
|
|
|
|
text-overflow: ellipsis;
|
2025-03-05 15:36:50 +01:00
|
|
|
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%);
|
|
|
|
}
|
|
|
|
|
2025-03-10 20:58:33 +01:00
|
|
|
.event, .shift {
|
2025-03-05 15:36:50 +01:00
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
|
2025-02-26 22:53:56 +01:00
|
|
|
}
|
2025-03-10 14:40:02 +01:00
|
|
|
.event.crew {
|
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(127, 127, 127) 60%);
|
|
|
|
}
|
2025-03-05 15:36:50 +01:00
|
|
|
</style>
|