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 }} {{ row.title }}
</td> </td>
</tr> </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> </tbody>
</table> </table>
</figure> </figure>
@ -75,7 +93,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { DateTime } from "luxon"; 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 oneDayMs = 24 * 60 * 60 * 1000;
const oneHourMs = 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 // See timetable-terminology.png for an illustration of how these terms are related
/** Point in time where a time slots starts or ends. */ /** 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. */ /** Point in time where multiple edges meet. */
type Junction = { ts: number, edges: Edge[] }; type Junction = { ts: number, edges: Edge[] };
@ -94,6 +115,7 @@ type Span = {
start: Junction; start: Junction;
end: Junction, end: Junction,
locations: Map<string, Set<TimeSlot>>, 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) { if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`); throw new Error(`Slot ${slot.id} ends before it starts.`);
} }
yield { type: "start", slot } yield { type: "start", source: "event", slot }
yield { type: "end", 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( function* spansFromJunctions(
junctions: Iterable<Junction>, locations: ScheduleLocation[] junctions: Iterable<Junction>, locations: ScheduleLocation[], roles: Role[] | undefined,
): Generator<Span> { ): Generator<Span> {
const activeLocations = new Map( const activeLocations = new Map(
locations.map(location => [location.id, new Set<TimeSlot>()]) 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 [start, end] of pairs(junctions)) {
for (const edge of start.edges) { for (const edge of start.edges) {
if (edge.type === "start") { if (edge.type === "start") {
for (const location of edge.slot.locations) { if (edge.source === "event") {
activeLocations.get(location)?.add(edge.slot) 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) .filter(([_, slots]) => slots.size)
.map(([location, slots]) => [location, new Set(slots)]) .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) { for (const edge of end.edges) {
if (edge.type === "end") { if (edge.type === "end") {
for (const location of edge.slot.locations) { if (edge.source === "event") {
activeLocations.get(location)?.delete(edge.slot) 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) { for (const span of spans) {
// Based on how spans are generated I can assume that an empty span // Based on how spans are generated I can assume that an empty span
// will only occur between two spans with timeslots in them. // 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 && span.end.ts - span.start.ts >= minSeparation
) { ) {
yield createStretch(currentSpans); yield createStretch(currentSpans);
@ -259,6 +311,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: span.start, start: span.start,
end: { ts: currentEnd.toMillis(), edges: [] }, end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations, locations: span.locations,
roles: span.roles,
} }
while (true) { while (true) {
@ -271,6 +324,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: { ts: currentStart.toMillis(), edges: [] }, start: { ts: currentStart.toMillis(), edges: [] },
end: { ts: currentEnd.toMillis(), edges: [] }, end: { ts: currentEnd.toMillis(), edges: [] },
locations: span.locations, locations: span.locations,
roles: span.roles,
} }
} }
@ -278,6 +332,7 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
start: { ts: currentStart.toMillis(), edges: [] }, start: { ts: currentStart.toMillis(), edges: [] },
end: span.end, end: span.end,
locations: span.locations, locations: span.locations,
roles: span.roles,
} }
} }
@ -297,12 +352,14 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
start: { ts: start.toMillis(), edges: [] }, start: { ts: start.toMillis(), edges: [] },
end: stretch.spans[0].start, end: stretch.spans[0].start,
locations: new Map(), locations: new Map(),
roles: new Map(),
}, },
...stretch.spans, ...stretch.spans,
{ {
start: stretch.spans[stretch.spans.length - 1].end, start: stretch.spans[stretch.spans.length - 1].end,
end: { ts: end.toMillis(), edges: [] }, end: { ts: end.toMillis(), edges: [] },
locations: new Map(), locations: new Map(),
roles: new Map(),
}, },
], ],
start: start.toMillis(), start: start.toMillis(),
@ -314,17 +371,23 @@ function tableElementsFromStretches(
stretches: Iterable<Stretch>, stretches: Iterable<Stretch>,
events: ScheduleEvent[], events: ScheduleEvent[],
locations: ScheduleLocation[], locations: ScheduleLocation[],
rota: Shift[] | undefined,
roles: Role[] | undefined,
timezone: string, timezone: string,
) { ) {
type Col = { minutes?: number }; type Col = { minutes?: number };
type DayHead = { span: number, content?: string } type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string } type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<TimeSlot>, title: string, crew?: boolean } 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 columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = []; const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= []; const hourHeaders: HourHead[]= [];
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []])); 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 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) { function startColumnGroup(className?: string) {
columnGroups.push({ className, cols: []}) columnGroups.push({ className, cols: []})
@ -344,7 +407,16 @@ function tableElementsFromStretches(
} }
rows.push({ span: 0, slots, title: "" }); 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) { function pushColumn(minutes?: number) {
totalColumns += 1;
columnGroups[columnGroups.length - 1].cols.push({ minutes }) columnGroups[columnGroups.length - 1].cols.push({ minutes })
dayHeaders[dayHeaders.length - 1].span += 1; dayHeaders[dayHeaders.length - 1].span += 1;
hourHeaders[hourHeaders.length - 1].span += 1; hourHeaders[hourHeaders.length - 1].span += 1;
@ -352,6 +424,10 @@ function tableElementsFromStretches(
const row = locationRows.get(location.id)!; const row = locationRows.get(location.id)!;
row[row.length - 1].span += 1; 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; let first = true;
@ -366,6 +442,9 @@ function tableElementsFromStretches(
for(const location of locations) { for(const location of locations) {
startLocation(location.id); startLocation(location.id);
} }
for(const role of roles ?? []) {
startRole(role.id);
}
} else { } else {
startColumnGroup("break"); startColumnGroup("break");
const dayName = startDate.toFormat("yyyy-LL-dd"); const dayName = startDate.toFormat("yyyy-LL-dd");
@ -377,6 +456,9 @@ function tableElementsFromStretches(
for(const location of locations) { for(const location of locations) {
startLocation(location.id); startLocation(location.id);
} }
for(const role of roles ?? []) {
startRole(role.id);
}
pushColumn(); pushColumn();
startColumnGroup(); startColumnGroup();
@ -386,6 +468,9 @@ function tableElementsFromStretches(
for(const location of locations) { for(const location of locations) {
startLocation(location.id); startLocation(location.id);
} }
for(const role of roles ?? []) {
startRole(role.id);
}
} }
for (const span of stretch.spans) { for (const span of stretch.spans) {
@ -401,6 +486,14 @@ function tableElementsFromStretches(
startLocation(location.id, 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); pushColumn(durationMs / oneMinMs);
const endDate = DateTime.fromMillis(end, { zone: timezone }); const endDate = DateTime.fromMillis(end, { zone: timezone });
@ -421,18 +514,26 @@ function tableElementsFromStretches(
} }
return { return {
totalColumns,
columnGroups, columnGroups,
dayHeaders: dayHeaders.filter(day => day.span), dayHeaders: dayHeaders.filter(day => day.span),
hourHeaders: hourHeaders.filter(hour => hour.span), hourHeaders: hourHeaders.filter(hour => hour.span),
locationRows: new Map([...locationRows].map(([id, cells]) => [id, cells.filter(cell => cell.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, eventBySlotId,
}; };
} }
const schedule = await useSchedule(); 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(() => [ 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(); const runtimeConfig = useRuntimeConfig();
@ -444,13 +545,14 @@ const timezone = computed({
}); });
const elements = computed(() => tableElementsFromStretches( 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 columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders); const dayHeaders = computed(() => elements.value.dayHeaders);
const hourHeaders = computed(() => elements.value.hourHeaders); const hourHeaders = computed(() => elements.value.hourHeaders);
const locationRows = computed(() => elements.value.locationRows); const locationRows = computed(() => elements.value.locationRows);
const eventBySlotId = computed(() => elements.value.eventBySlotId); const roleRows = computed(() => elements.value.roleRows);
</script> </script>
<style scoped> <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%); 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%); background-color: color-mix(in oklab, var(--background), rgb(255, 125, 50) 60%);
} }
.event.crew { .event.crew {

View file

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