Implement role based shifts for crew
This commit is contained in:
parent
f69ca520c0
commit
a9ba0c55e1
3 changed files with 221 additions and 21 deletions
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
22
shared/types/schedule.d.ts
vendored
22
shared/types/schedule.d.ts
vendored
|
@ -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[],
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue