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 {

View file

@ -1,5 +1,5 @@
import { Account } from "~/shared/types/account";
import { Schedule, TimeSlot } from "~/shared/types/schedule";
import { Role, Schedule, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
const locations = [
{
@ -60,8 +60,8 @@ const events = [
},
{ name: "Fishing Trip", slots: ["d3 12:00 3h30m outside"]},
{ name: "Opening", slots: ["d1 18:30 1h30m stage"]},
{ name: "Closing", slots: ["d5 16:00 1h stage"]},
{ name: "Stage Teardown", crew: true, slots: ["d5 17:00 4h stage"]},
{ name: "Closing", slots: ["d5 10:00 1h stage"]},
{ name: "Stage Teardown", crew: true, slots: ["d5 11:00 4h stage"]},
{ name: "Setup Board Games", crew: true, slots: ["d1 11:30 30m summerhouse"]},
{
name: "Board Games",
@ -131,9 +131,59 @@ const events = [
{ name: "Setup Artist Alley", crew: true, slots: ["d4 10:00 2h clubhouse"]},
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16:00 1h clubhouse"]},
{ name: "Feedback Panel", slots: ["d5 18:00 1h clubhouse"]},
{ name: "Feedback Panel", slots: ["d5 12:00 1h clubhouse"]},
];
const roles: Role[] = [
{ id: "medic", name: "Medic" },
{ id: "security", name: "Security" },
]
const rota = [
{
name: "Medic Early",
role: "medic",
slots: [
"d1 12:00 4h",
"d2 12:00 4h",
"d3 12:00 4h",
"d4 11:00 5h",
"d5 10:00 3h",
],
},
{
name: "Medic Late",
role: "medic",
slots: [
"d1 16:00 7h",
"d2 16:00 6h",
"d3 16:00 8h",
"d4 16:00 7h",
],
},
{
name: "Security Early",
role: "security",
slots: [
"d1 12:00 6h",
"d2 12:00 6h",
"d3 12:00 6h",
"d4 11:00 7h",
"d5 10:00 3h",
],
},
{
name: "Security Late",
role: "security",
slots: [
"d1 18:00 5h",
"d2 18:00 4h",
"d3 18:00 6h",
"d4 18:00 5h",
],
},
]
function toId(name: string) {
return name.toLowerCase().replace(/ /g, "-");
}
@ -142,8 +192,7 @@ function toIso(date: Date) {
return date.toISOString().replace(":00.000Z", "Z");
}
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>): TimeSlot {
const [day, start, duration, location] = shorthand.split(" ");
function toDates(origin: Date, day: string, start: string, duration: string) {
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
const dayNumber = parseInt(day.slice(1));
@ -156,6 +205,13 @@ function toSlot(origin: Date, id: string, shorthand: string, index: number, coun
const durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
const endDate = new Date(startDate.getTime() + durationTotal * 60e3);
return [startDate, endDate];
}
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>): TimeSlot {
const [day, start, duration, location] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
return {
id: `${id}-${index}`,
start: toIso(startDate),
@ -165,6 +221,17 @@ function toSlot(origin: Date, id: string, shorthand: string, index: number, coun
};
}
function toShift(origin: Date, id: string, shorthand: string, index: number): ShiftSlot {
const [day, start, duration] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
return {
id: `${id}-${index}`,
start: toIso(startDate),
end: toIso(endDate),
};
}
export function generateDemoSchedule(): Schedule {
const origin = new Date();
const utcOffset = 1;
@ -195,6 +262,15 @@ export function generateDemoSchedule(): Schedule {
locations: locations.map(
({ name, description }) => ({ id: toId(name), name, description })
),
roles,
rota: rota.map(
({ name, role, slots }) => ({
id: toId(name),
name,
role,
slots: slots.map((shorthand, index) => toShift(origin, toId(name), shorthand, index))
})
)
};
}

View file

@ -23,7 +23,29 @@ export interface TimeSlot {
interested?: number,
}
export interface Shift {
name: string,
id: string,
role: string,
description?: string,
slots: ShiftSlot[],
}
export interface Role {
name: string,
id: string,
description?: string,
}
export interface ShiftSlot {
id: string,
start: string,
end: string,
}
export interface Schedule {
locations: ScheduleLocation[],
events: ScheduleEvent[],
roles?: Role[],
rota?: Shift[],
}