Implement role based shifts for crew

This commit is contained in:
Hornwitser 2025-03-10 20:58:33 +01:00
parent f69ca520c0
commit a9ba0c55e1
3 changed files with 221 additions and 21 deletions

View file

@ -68,6 +68,24 @@
{{ row.title }}
</td>
</tr>
<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>
</tbody>
</table>
</figure>
@ -75,7 +93,7 @@
<script setup lang="ts">
import { DateTime } from "luxon";
import type { ScheduleEvent, ScheduleLocation, TimeSlot } from "~/shared/types/schedule";
import type { Role, ScheduleEvent, ScheduleLocation, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
const oneDayMs = 24 * 60 * 60 * 1000;
const oneHourMs = 60 * 60 * 1000;
@ -84,7 +102,10 @@ 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 };
type Edge =
| { type: "start" | "end", source: "event", slot: TimeSlot }
| { type: "start" | "end", source: "shift", role: string, slot: ShiftSlot }
;
/** Point in time where multiple edges meet. */
type Junction = { ts: number, edges: Edge[] };
@ -94,6 +115,7 @@ type Span = {
start: Junction;
end: Junction,
locations: Map<string, Set<TimeSlot>>,
roles: Map<string, Set<ShiftSlot>>,
};
/**
@ -161,8 +183,20 @@ function* edgesFromEvents(events: Iterable<ScheduleEvent>): Generator<Edge> {
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", slot }
yield { type: "end", slot }
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 };
}
}
}
@ -183,16 +217,23 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
}
function* spansFromJunctions(
junctions: Iterable<Junction>, locations: ScheduleLocation[]
junctions: Iterable<Junction>, locations: ScheduleLocation[], roles: Role[] | undefined,
): Generator<Span> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<TimeSlot>()])
);
const activeRoles = new Map(
roles?.map(role => [role.id, new Set<ShiftSlot>()])
);
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)
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)
}
}
}
@ -204,11 +245,20 @@ function* spansFromJunctions(
.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") {
for (const location of edge.slot.locations) {
activeLocations.get(location)?.delete(edge.slot)
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);
}
}
}
@ -228,7 +278,9 @@ function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Gene
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
if (
span.locations.size === 0
&& span.roles.size === 0
&& span.end.ts - span.start.ts >= minSeparation
) {
yield createStretch(currentSpans);
@ -259,6 +311,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: span.start,
end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations,
roles: span.roles,
}
while (true) {
@ -271,6 +324,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: { ts: currentStart.toMillis(), edges: [] },
end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations,
roles: span.roles,
}
}
@ -278,6 +332,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: { ts: currentStart.toMillis(), edges: [] },
end: span.end,
locations: span.locations,
roles: span.roles,
}
}
@ -297,12 +352,14 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
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(),
@ -314,17 +371,23 @@ function tableElementsFromStretches(
stretches: Iterable<Stretch>,
events: ScheduleEvent[],
locations: ScheduleLocation[],
rota: Shift[] | undefined,
roles: Role[] | undefined,
timezone: string,
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<TimeSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ShiftSlot>, title: string };
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
const roleRows = new Map<string, RoleCell[]>(roles?.map?.(role => [role.id, []]));
const eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
const shiftBySlotId = new Map(rota?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
let totalColumns = 0;
function startColumnGroup(className?: string) {
columnGroups.push({ className, cols: []})
@ -344,7 +407,16 @@ function tableElementsFromStretches(
}
rows.push({ span: 0, slots, title: "" });
}
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: "" });
}
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;
@ -352,6 +424,10 @@ function tableElementsFromStretches(
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;
@ -366,6 +442,9 @@ function tableElementsFromStretches(
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");
@ -377,6 +456,9 @@ function tableElementsFromStretches(
for(const location of locations) {
startLocation(location.id);
}
for(const role of roles ?? []) {
startRole(role.id);
}
pushColumn();
startColumnGroup();
@ -386,6 +468,9 @@ function tableElementsFromStretches(
for(const location of locations) {
startLocation(location.id);
}
for(const role of roles ?? []) {
startRole(role.id);
}
}
for (const span of stretch.spans) {
@ -401,6 +486,14 @@ function tableElementsFromStretches(
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 });
@ -421,18 +514,26 @@ function tableElementsFromStretches(
}
return {
totalColumns,
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)])),
roleRows: new Map([...roleRows].map(([id, cells]) => [id, cells.filter(cell => cell.span)])),
eventBySlotId,
};
}
const schedule = await useSchedule();
const junctions = computed(() => junctionsFromEdges(edgesFromEvents(schedule.value.events)));
const junctions = computed(() => junctionsFromEdges([
...edgesFromEvents(schedule.value.events),
...edgesFromShifts(schedule.value.rota ?? []),
]));
const stretches = computed(() => [
...stretchesFromSpans(spansFromJunctions(junctions.value, schedule.value.locations), oneHourMs * 5)
...stretchesFromSpans(
spansFromJunctions(junctions.value, schedule.value.locations, schedule.value.roles),
oneHourMs * 5
)
])
const runtimeConfig = useRuntimeConfig();
@ -444,13 +545,14 @@ const timezone = computed({
});
const elements = computed(() => tableElementsFromStretches(
stretches.value, schedule.value.events, schedule.value.locations, timezone.value
stretches.value, schedule.value.events, schedule.value.locations, schedule.value.rota, schedule.value.roles, timezone.value
));
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 eventBySlotId = computed(() => elements.value.eventBySlotId);
const roleRows = computed(() => elements.value.roleRows);
</script>
<style scoped>
@ -500,7 +602,7 @@ const eventBySlotId = computed(() => elements.value.eventBySlotId);
background-color: color-mix(in oklab, var(--background), rgb(50, 50, 255) 60%);
}
.event {
.event, .shift {
background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
}
.event.crew {