Refactor API types and sync logic
All checks were successful
/ build (push) Successful in 2m5s
/ deploy (push) Successful in 16s

Rename and refactor the types passed over the API to be based on an
entity that's either living or a tombstone.  A living entity has a
deleted property that's either undefined or false, while a tombstone
has a deleted property set to true.  All entities have a numeric id
and an updatedAt timestamp.

To sync entities, an array of replacements are passed around. Living
entities are replaced with tombstones when they're deleted. And
tombstones are replaced with living entities when restored.
This commit is contained in:
Hornwitser 2025-06-11 21:05:17 +02:00
parent 251e83f640
commit fe06d0d6bd
36 changed files with 1242 additions and 834 deletions

View file

@ -1,5 +1,10 @@
<template>
<section class="event">
<section class="event" v-if="event.deleted">
<p>
Error: Unexpected deleted event.
</p>
</section>
<section class="event" v-else>
<h3>{{ event.name }}</h3>
<p>{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested">
@ -8,10 +13,10 @@
<p v-if="accountStore.valid">
<button
class="interested"
:class="{ active: accountStore.interestedIds.has(event.id) }"
@click="toggle(event.id, event.slots.map(slot => slot.id))"
:class="{ active: accountStore.interestedEventIds.has(event.id) }"
@click="toggle('event', event.id, event.slots.map(slot => slot.id))"
>
{{ accountStore.interestedIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
{{ accountStore.interestedEventIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
</button>
</p>
@ -22,11 +27,11 @@
<button
v-if="accountStore.valid && event.slots.length > 1"
class="interested"
:disabled="accountStore.interestedIds.has(event.id)"
:class="{ active: accountStore.interestedIds.has(event.id) || accountStore.interestedIds.has(slot.id) }"
@click="toggle(slot.id)"
:disabled="accountStore.interestedEventIds.has(event.id)"
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) }"
@click="toggle('slot', slot.id)"
>
{{ accountStore.interestedIds.has(event.id) || accountStore.interestedIds.has(slot.id) ? "✔ interested" : "🔔 interested?" }}
{{ accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) ? "✔ interested" : "🔔 interested?" }}
</button>
<template v-if="slot.interested">
({{ slot.interested }} interested)
@ -42,10 +47,10 @@
<script lang="ts" setup>
import { DateTime } from 'luxon';
import type { ScheduleEvent } from '~/shared/types/schedule';
import type { ApiScheduleEvent } from '~/shared/types/api';
defineProps<{
event: ScheduleEvent
event: ApiScheduleEvent
}>()
const accountStore = useAccountStore();
@ -56,8 +61,8 @@ function formatTime(time: string) {
return DateTime.fromISO(time, { zone: accountStore.activeTimezone, locale: "en-US" }).toFormat("yyyy-LL-dd HH:mm");
}
async function toggle(id: string, slotIds?: string[]) {
await accountStore.toggleInterestedId(id, slotIds);
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
await accountStore.toggleInterestedId(type, id, slotIds);
}
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -14,7 +17,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="event in events"
v-for="event in events.filter(e => !e.deleted)"
:key="event.id"
:class="{ removed: removed.has(event.id) }"
>
@ -52,7 +55,7 @@
@click="delEvent(event.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === event.id)"
v-if="changes.some(c => c.id === event.id)"
type="button"
@click="revertEvent(event.id)"
>Revert</button>
@ -95,7 +98,7 @@
</template>
<template v-else>
<tr
v-for="event in events"
v-for="event in events.filter(e => !e.deleted)"
:key="event.id"
>
<td>{{ event.id }}</td>
@ -126,9 +129,9 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, ScheduleEvent } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleEvent } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
defineProps<{
edit?: boolean,
@ -137,18 +140,18 @@ defineProps<{
const schedule = await useSchedule();
const accountStore = useAccountStore();
function canEdit(event: ScheduleEvent) {
return event.crew || accountStore.canEditPublic;
function canEdit(event: ApiScheduleEvent) {
return !event.deleted && (event.crew || accountStore.canEditPublic);
}
const changes = ref<ChangeRecord<ScheduleEvent>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleEvent[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<ScheduleEvent>,
changes: ChangeRecord<ScheduleEvent>[],
change: ApiScheduleEvent,
changes: ApiScheduleEvent[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -157,15 +160,15 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<ScheduleEvent>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleEvent[]) {
return changes.filter(change => change.id !== id);
}
const newEventName = ref("");
const newEventDescription = ref("");
const newEventPublic = ref(false);
function editEvent(
event: ScheduleEvent,
event: Extract<ApiScheduleEvent, { deleted?: false }>,
edits: { name?: string, description?: string, crew?: boolean }
) {
const copy = { ...event };
@ -178,21 +181,23 @@ function editEvent(
if (edits.crew !== undefined) {
copy.crew = edits.crew || undefined;
}
const change = { op: "set" as const, data: copy };
changes.value = replaceChange(copy, changes.value);
}
function delEvent(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function delEvent(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertEvent(id: string) {
function revertEvent(id: number) {
changes.value = revertChange(id, changes.value);
}
function eventExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.events.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.events?.some(e => !e.deleted && toId(e.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newEvent() {
@ -200,15 +205,17 @@ function newEvent() {
alert(`Event ${newEventName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const id = Math.max(1, ...schedule.value.events?.map(e => e.id) ?? []) + 1;
const change = {
op: "set" as const,
data: {
id: toId(newEventName.value),
name: newEventName.value,
description: newEventDescription.value || undefined,
crew: !newEventPublic.value || undefined,
slots: [],
},
id,
updatedAt: "",
name: newEventName.value,
description: newEventDescription.value || undefined,
crew: !newEventPublic.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newEventName.value = "";
@ -219,7 +226,11 @@ async function saveEvents() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { events: changes.value },
body: {
id: 111,
updatedAt: "",
events: changes.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -229,8 +240,11 @@ async function saveEvents() {
}
const events = computed(() => {
const data = [...schedule.value.events];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.events ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -11,7 +11,7 @@
</thead>
<tbody>
<tr
v-for="location in locations"
v-for="location in locations.filter(l => !l.deleted)"
:key="location.id"
:class="{ removed: removed.has(location.id) }"
>
@ -38,7 +38,7 @@
@click="delLocation(location.id)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === location.id)"
v-if="changes.some(c => c.id === location.id)"
type="button"
@click="revertLocation(location.id)"
>Revert</button>
@ -52,7 +52,7 @@
</tr>
<tr v-if='edit'>
<td>
{{ toId(newLocationName) }}
{{ newLocationId }}
</td>
<td>
<input
@ -88,9 +88,9 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, ScheduleLocation } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleLocation } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
defineProps<{
edit?: boolean
@ -98,15 +98,15 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ChangeRecord<ScheduleLocation>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleLocation[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<ScheduleLocation>,
changes: ChangeRecord<ScheduleLocation>[],
change: ApiScheduleLocation,
changes: ApiScheduleLocation[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -115,29 +115,36 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<ScheduleLocation>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleLocation[]) {
return changes.filter(change => change.id !== id);
}
const newLocationName = ref("");
function setLocation(location: ScheduleLocation) {
const change = { op: "set" as const, data: location };
const newLocationId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.locations?.map(l => l.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
function setLocation(location: ApiScheduleLocation) {
changes.value = replaceChange(location, changes.value);
}
function delLocation(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function delLocation(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertLocation(id: string) {
function revertLocation(id: number) {
changes.value = revertChange(id, changes.value);
}
function newLocation(name: string) {
const change = {
op: "set" as const,
data: {
id: toId(name),
name,
},
id: newLocationId.value,
updatedAt: "",
name,
};
changes.value = replaceChange(change, changes.value);
}
@ -145,7 +152,11 @@ async function saveLocations() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { locations: changes.value },
body: {
id: 111,
updatedAt: "",
locations: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -155,8 +166,11 @@ async function saveLocations() {
}
const locations = computed(() => {
const data = [...schedule.value.locations];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.locations ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -12,7 +15,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="role in roles"
v-for="role in roles.filter(r => !r.deleted)"
:key="role.id"
:class="{ removed: removed.has(role.id) }"
>
@ -38,14 +41,14 @@
@click="delRole(role.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === role.id)"
v-if="changes.some(c => c.id === role.id)"
type="button"
@click="revertRole(role.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ toId(newRoleName) }}</td>
<td>{{ newRoleId }}</td>
<td>
<input
type="text"
@ -73,7 +76,7 @@
</template>
<template v-else>
<tr
v-for="role in roles"
v-for="role in roles.filter(r => !r.deleted)"
:key="role.id"
>
<td>{{ role.id }}</td>
@ -102,8 +105,8 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, Role } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleRole } from '~/shared/types/api';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { toId } from '~/shared/utils/functions';
defineProps<{
@ -112,14 +115,14 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ChangeRecord<Role>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleRole[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<Role>,
changes: ChangeRecord<Role>[],
change: ApiScheduleRole,
changes: ApiScheduleRole[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -128,14 +131,24 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<Role>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleRole[]) {
return changes.filter(change => change.id !== id);
}
const newRoleName = ref("");
const newRoleId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.roles?.map(r => r.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
const newRoleDescription = ref("");
function editRole(
role: Role,
role: Extract<ApiScheduleRole, { deleted?: false }>,
edits: { name?: string, description?: string }
) {
const copy = { ...role };
@ -145,21 +158,23 @@ function editRole(
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
const change = { op: "set" as const, data: copy };
changes.value = replaceChange(copy, changes.value);
}
function delRole(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function delRole(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertRole(id: string) {
function revertRole(id: number) {
changes.value = revertChange(id, changes.value);
}
function roleExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.roles?.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.roles?.some(r => !r.deleted && toId(r.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newRole() {
@ -167,14 +182,15 @@ function newRole() {
alert(`Role ${newRoleName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const change = {
op: "set" as const,
data: {
id: toId(newRoleName.value),
name: newRoleName.value,
description: newRoleDescription.value || undefined,
slots: [],
},
id: newRoleId.value,
updatedAt: "",
name: newRoleName.value,
description: newRoleDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newRoleName.value = "";
@ -184,7 +200,11 @@ async function saveRoles() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { roles: changes.value },
body: {
id: 111,
updatedAt: "",
roles: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -194,8 +214,11 @@ async function saveRoles() {
}
const roles = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.roles ?? []];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
@ -47,7 +50,7 @@
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations"
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
@ -99,14 +102,14 @@
<td>{{ status(es) }}</td>
<td>
<select
:value="es.location"
@change="editEventSlot(es, { location: ($event as any).target.value })"
:value="es.locationId"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations"
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === es.location"
:selected="location.id === es.locationId"
>{{ location.name }}</option>
</select>
</td>
@ -124,7 +127,7 @@
@click="delEventSlot(es)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === es.id)"
v-if="changes.some(c => c.id === es.id)"
type="button"
@click="revertEventSlot(es.id)"
>Revert</button>
@ -186,7 +189,7 @@
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.location }}</td>
<td>{{ es.locationId }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
</template>
</tr>
@ -205,13 +208,13 @@
<b>EventSlot changes</b>
<ol>
<li v-for="change in changes">
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
<b>ScheduleEvent changes</b>
<ol>
<li v-for="change in scheduleChanges">
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
</details>
@ -220,25 +223,28 @@
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
import type { ChangeRecord, Schedule, ScheduleEvent, ScheduleLocation, ShiftSlot, TimeSlot } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
import type { Entity } from '~/shared/types/common';
import { enumerate, pairs } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
const props = defineProps<{
edit?: boolean,
location?: string,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
locationId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
interface EventSlot {
type: "slot",
id: string,
event?: ScheduleEvent,
slot?: TimeSlot,
origLocation: string,
id: number,
updatedAt: string,
deleted?: boolean,
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
slot?: ApiScheduleEventSlot,
origLocation: number,
name: string,
location: string,
locationId: number,
assigned: number[],
start: DateTime,
end: DateTime,
@ -250,57 +256,69 @@ interface Gap {
event?: undefined,
slot?: undefined,
name?: undefined,
location?: string,
locationId?: number,
start: DateTime,
end: DateTime,
}
function status(eventSlot: EventSlot) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (
!eventSlot.event
|| eventSlot.event.name !== eventSlot.name
) {
const event = schedule.value.events.find(event => event.name === eventSlot.name);
const event = schedule.value.events?.find(event => !event.deleted && event.name === eventSlot.name);
return event ? "L" : "N";
}
return eventSlot.event.slots.length === 1 ? "" : eventSlot.event.slots.length;
}
// Filter out set records where a del record exists for the same id.
function filterSetOps<T extends { op: "set" | "del", data: { id: string }}>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
function filterSetOps<T extends Entity>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
}
function findEvent(
eventSlot: EventSlot,
changes: ChangeRecord<ScheduleEvent>[],
schedule: Schedule,
changes: ApiScheduleEvent[],
schedule: ApiSchedule,
) {
let setEvent = changes.find(
c => c.op === "set" && c.data.name === eventSlot.name
)?.data as ScheduleEvent | undefined;
if (schedule.deleted) {
throw new Error("Unexpected deleted schedule");
}
let setEvent = changes.filter(
c => !c.deleted
).find(
c => c.name === eventSlot.name
);
if (!setEvent && eventSlot.event && eventSlot.event.name === eventSlot.name) {
setEvent = eventSlot.event;
}
if (!setEvent) {
setEvent = schedule.events.find(e => e.name === eventSlot.name);
setEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.name);
}
let delEvent;
if (eventSlot.event) {
delEvent = changes.find(
c => c.op === "set" && c.data.name === eventSlot.event!.name
)?.data as ScheduleEvent | undefined;
delEvent = changes.filter(c => !c.deleted).find(
c => c.name === eventSlot.event!.name
);
if (!delEvent) {
delEvent = schedule.events.find(e => e.name === eventSlot.event!.name);
delEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.event!.name);
}
}
return { setEvent, delEvent };
}
function removeSlotLocation(event: ScheduleEvent, oldSlot: TimeSlot, location: string) {
function removeSlotLocation(
event: Extract<ApiScheduleEvent, { deleted?: false }>,
oldSlot: ApiScheduleEventSlot,
locationId: number
) {
// If location is an exact match remove the whole slot
if (oldSlot.locations.length === 1 && oldSlot.locations[0] === location) {
if (oldSlot.locationIds.length === 1 && oldSlot.locationIds[0] === locationId) {
return {
...event,
slots: event.slots.filter(s => s.id !== oldSlot.id),
@ -312,18 +330,21 @@ function removeSlotLocation(event: ScheduleEvent, oldSlot: TimeSlot, location: s
slots: event.slots.map(
s => s.id !== oldSlot.id ? s : {
...s,
locations: s.locations.filter(l => l !== location)
locationIds: s.locationIds.filter(id => id !== locationId)
}
),
};
}
function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
function mergeSlot(
event: Extract<ApiScheduleEvent, { deleted?: false }>,
eventSlot: EventSlot,
): Extract<ApiScheduleEvent, { deleted?: false }> {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const oldSlot = event.slots.find(s => s.id === eventSlot.id);
const nextId = Math.max(-1, ...event.slots.map(s => {
const id = /-(\d+)$/.exec(s.id)?.[1];
return id ? parseInt(id) : 0;
})) + 1;
const nextId = Math.max(0, ...schedule.value.events?.filter(e => !e.deleted).flatMap(e => e.slots.map(slot => slot.id)) ?? []) + 1;
const start = eventSlot.start.toUTC().toISO({ suppressSeconds: true })!;
const end = eventSlot.end.toUTC().toISO({ suppressSeconds: true })!;
@ -332,7 +353,7 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
oldSlot
&& oldSlot.id === eventSlot.id
&& (
oldSlot.locations.length <= 1
oldSlot.locationIds.length <= 1
|| oldSlot.start === start && oldSlot.end === end
)
) {
@ -341,12 +362,12 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
slots: event.slots.map(s => {
if (s.id !== oldSlot.id)
return s;
const locations = new Set(s.locations);
locations.delete(eventSlot.origLocation)
locations.add(eventSlot.location);
const locationIds = new Set(s.locationIds);
locationIds.delete(eventSlot.origLocation)
locationIds.add(eventSlot.locationId);
return {
...s,
locations: [...locations],
locationIds: [...locationIds],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
@ -363,8 +384,8 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
return {
...event,
slots: [...event.slots, {
id: oldSlot ? oldSlot.id : `${event.id}-${nextId}`,
locations: [eventSlot.location],
id: oldSlot ? oldSlot.id : nextId,
locationIds: [eventSlot.locationId],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
@ -373,31 +394,29 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
}
const scheduleChanges = computed(() => {
let eventChanges: ChangeRecord<ScheduleEvent>[] = [];
let eventChanges: Extract<ApiScheduleEvent, { deleted?: false}>[] = [];
for (const change of filterSetOps(changes.value)) {
if (change.op === "set") {
let { setEvent, delEvent } = findEvent(change.data, eventChanges, schedule.value);
if (!change.deleted) {
let { setEvent, delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent && delEvent !== setEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change.data);
eventChanges = removeSlot(eventChanges, delEvent, change);
}
if (!setEvent) {
setEvent = {
id: toId(change.data.name),
name: change.data.name,
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
crew: true,
slots: [],
};
}
eventChanges = replaceChange({
op: "set",
data: mergeSlot(setEvent, change.data),
}, eventChanges);
eventChanges = replaceChange(mergeSlot(setEvent, change), eventChanges);
} else if (change.op === "del") {
let { delEvent } = findEvent(change.data, eventChanges, schedule.value);
} else if (change.deleted) {
let { delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change.data);
eventChanges = removeSlot(eventChanges, delEvent, change);
}
}
}
@ -405,21 +424,28 @@ const scheduleChanges = computed(() => {
});
const schedulePreview = computed(() => {
const events = [...schedule.value.events]
applyChangeArray(scheduleChanges.value, events);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const events = [...schedule.value.events ?? []]
applyUpdatesToArray(scheduleChanges.value, events);
return {
...schedule.value,
events,
};
});
function removeSlot(eventChanges: ChangeRecord<ScheduleEvent>[], event: ScheduleEvent, eventSlot: EventSlot) {
function removeSlot(
eventChanges: Extract<ApiScheduleEvent, { deleted?: false }>[],
event: Extract<ApiScheduleEvent, { deleted?: false }>,
eventSlot: EventSlot,
) {
let oldSlot = event.slots.find(s => s.id === eventSlot.id);
if (oldSlot) {
eventChanges = replaceChange({
op: "set",
data: removeSlotLocation(event, oldSlot, eventSlot.origLocation),
}, eventChanges);
eventChanges = replaceChange(
removeSlotLocation(event, oldSlot, eventSlot.origLocation),
eventChanges,
);
}
return eventChanges;
}
@ -427,17 +453,15 @@ function removeSlot(eventChanges: ChangeRecord<ScheduleEvent>[], event: Schedule
const accountStore = useAccountStore();
const schedule = await useSchedule();
type EventSlotChange = { op: "set" | "del", data: EventSlot } ;
const changes = ref<EventSlot[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
const changes = ref<EventSlotChange[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
function replaceChange<T extends Entity>(
change: T,
changes: T[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -446,8 +470,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
copy.push(change);
return copy;
}
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
return changes.filter(change => change.data.id !== id);
function revertChange<T extends Entity>(id: number, changes: T[]) {
return changes.filter(change => change.id !== id);
}
const oneDayMs = 24 * 60 * 60 * 1000;
@ -472,9 +496,9 @@ const newEventEnd = computed({
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newEventLocation = ref(props.location);
watch(() => props.location, () => {
newEventLocation.value = props.location;
const newEventLocation = ref(props.locationId);
watch(() => props.locationId, () => {
newEventLocation.value = props.locationId;
});
function endFromTime(start: DateTime, time: string) {
@ -499,7 +523,7 @@ function editEventSlot(
end?: string,
duration?: string,
name?: string,
location?: string,
locationId?: number,
assigned?: number[],
}
) {
@ -512,7 +536,6 @@ function editEventSlot(
};
}
if (edits.end !== undefined) {
eventSlot = {
...eventSlot,
end: endFromTime(eventSlot.start, edits.end),
@ -530,10 +553,10 @@ function editEventSlot(
name: edits.name,
};
}
if (edits.location !== undefined) {
if (edits.locationId !== undefined) {
eventSlot = {
...eventSlot,
location: edits.location,
locationId: edits.locationId,
};
}
if (edits.assigned !== undefined) {
@ -542,20 +565,22 @@ function editEventSlot(
assigned: edits.assigned,
};
}
const change = { op: "set" as const, data: eventSlot };
changes.value = replaceChange(change, changes.value);
changes.value = replaceChange(eventSlot, changes.value);
}
function delEventSlot(eventSlot: EventSlot) {
const change = { op: "del" as const, data: eventSlot };
const change = {
...eventSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertEventSlot(id: string) {
function revertEventSlot(id: number) {
changes.value = revertChange(id, changes.value);
}
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const location = newEventLocation.value;
if (!location) {
const locationId = newEventLocation.value;
if (!locationId) {
alert("Invalid location");
return;
}
@ -580,27 +605,30 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time");
return;
}
const change: ChangeRecord<EventSlot> = {
op: "set" as const,
data: {
type: "slot",
id: `$new-${Date.now()}`,
name,
origLocation: location,
location,
assigned: [],
start,
end,
},
const change: EventSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origLocation: locationId,
locationId,
assigned: [],
start,
end,
};
newEventName.value = "";
changes.value = replaceChange(change, changes.value);
}
async function saveEventSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { events: scheduleChanges.value },
body: {
id: 111,
updatedAt: "",
events: scheduleChanges.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -620,30 +648,36 @@ function gapFormat(gap: Gap) {
}
const eventSlots = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data: (EventSlot | Gap)[] = [];
for (const event of schedule.value.events) {
for (const event of schedule.value.events ?? []) {
if (event.deleted)
continue;
for (const slot of event.slots) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
for (const location of slot.locations) {
if (props.location !== undefined && location !== props.location)
for (const locationId of slot.locationIds) {
if (props.locationId !== undefined && locationId !== props.locationId)
continue;
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
event,
slot,
name: event.name,
location,
locationId,
assigned: slot.assigned ?? [],
origLocation: location,
origLocation: locationId,
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
});
}
}
}
applyChangeArray(changes.value.filter(change => change.op === "set"), data as EventSlot[]);
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as EventSlot[]);
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
// Insert gaps
@ -654,7 +688,7 @@ const eventSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
location: props.location,
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
end: second.start,
}]);

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
@ -47,7 +50,7 @@
v-model="newShiftRole"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
@ -99,14 +102,14 @@
<td>{{ status(ss) }}</td>
<td>
<select
:value="ss.role"
@change="editShiftSlot(ss, { role: ($event as any).target.value })"
:value="ss.roleId"
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === ss.role"
:selected="role.id === ss.roleId"
>{{ role.name }}</option>
</select>
</td>
@ -124,7 +127,7 @@
@click="delShiftSlot(ss)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === ss.id)"
v-if="changes.some(c => c.id === ss.id)"
type="button"
@click="revertShiftSlot(ss.id)"
>Revert</button>
@ -184,7 +187,7 @@
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td>
<td>{{ ss.role }}</td>
<td>{{ ss.roleId }}</td>
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
</template>
</tr>
@ -203,13 +206,13 @@
<b>ShiftSlot changes</b>
<ol>
<li v-for="change in changes">
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
<b>Shift changes</b>
<ol>
<li v-for="change in shiftChanges">
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
</details>
@ -218,25 +221,28 @@
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
import type { ChangeRecord, Schedule, Shift, Role, ShiftSlot as ShiftTimeSlot, TimeSlot} from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { ApiSchedule, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { enumerate, pairs } from '~/shared/utils/functions';
import type { Entity } from '~/shared/types/common';
const props = defineProps<{
edit?: boolean,
role?: string,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftTimeSlot) => boolean,
roleId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
interface ShiftSlot {
type: "slot",
id: string,
shift?: Shift,
slot?: ShiftTimeSlot,
origRole: string,
id: number,
updatedAt: string,
deleted?: boolean,
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
slot?: ApiScheduleShiftSlot,
origRole: number,
name: string,
role: string,
roleId: number,
assigned: number[],
start: DateTime,
end: DateTime,
@ -248,40 +254,47 @@ interface Gap {
shift?: undefined,
slot?: undefined,
name?: undefined,
role?: string,
roleId?: number,
start: DateTime,
end: DateTime,
}
function status(shiftSlot: ShiftSlot) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (
!shiftSlot.shift
|| shiftSlot.shift.name !== shiftSlot.name
) {
const shift = schedule.value.rota?.find(shift => shift.name === shiftSlot.name);
const shift = schedule.value.shifts?.find(shift => !shift.deleted && shift.name === shiftSlot.name);
return shift ? "L" : "N";
}
return shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
}
// Filter out set records where a del record exists for the same id.
function filterSetOps<T extends { op: "set" | "del", data: { id: string }}>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
function filterSetOps<T extends Entity>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
}
function findShift(
shiftSlot: ShiftSlot,
changes: ChangeRecord<Shift>[],
schedule: Schedule,
changes: ApiScheduleShift[],
schedule: ApiSchedule,
) {
let setShift = changes.find(
if (schedule.deleted) {
throw new Error("Unexpected deleted schedule");
}
let setShift = changes.filter(
c => !c.deleted,
).find(
c => (
c.op === "set"
&& c.data.name === shiftSlot.name
&& c.data.role === shiftSlot.role
c.name === shiftSlot.name
&& c.roleId === shiftSlot.roleId
)
)?.data as Shift | undefined;
);
if (
!setShift
&& shiftSlot.shift
@ -290,32 +303,35 @@ function findShift(
setShift = shiftSlot.shift;
}
if (!setShift) {
setShift = schedule.rota?.find(e => e.name === shiftSlot.name);
setShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.name);
}
let delShift;
if (shiftSlot.shift) {
delShift = changes.find(
c => c.op === "set" && c.data.name === shiftSlot.shift!.name
)?.data as Shift | undefined;
delShift = changes.filter(c => !c.deleted).find(
c => c.name === shiftSlot.shift!.name
);
if (!delShift) {
delShift = schedule.rota?.find(e => e.name === shiftSlot.shift!.name);
delShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.shift!.name);
}
}
return { setShift, delShift };
}
function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
function mergeSlot(
shift: Extract<ApiScheduleShift, { deleted?: false }>,
shiftSlot: ShiftSlot,
): Extract<ApiScheduleShift, { deleted?: false }> {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
const nextId = Math.max(-1, ...shift.slots.map(s => {
const id = /-(\d+)$/.exec(s.id)?.[1];
return id ? parseInt(id) : 0;
})) + 1;
const nextId = Math.max(0, ...schedule.value.shifts?.filter(s => !s.deleted).flatMap(s => s.slots.map(slot => slot.id)) ?? []) + 1;
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
if (shift.role !== shiftSlot.role) {
console.warn(`Attempt to add slot id=${shiftSlot.id} role=${shiftSlot.role} to shift id=${shift.id} role=${shift.role}`);
if (shift.roleId !== shiftSlot.roleId) {
console.warn(`Attempt to add slot id=${shiftSlot.id} roleId=${shiftSlot.roleId} to shift id=${shift.id} roleId=${shift.roleId}`);
}
// Edit slot in-place if possible
@ -330,7 +346,7 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
return {
...shift,
slots: [...(oldSlot ? shift.slots.filter(s => s.id !== oldSlot.id) : shift.slots), {
id: oldSlot ? oldSlot.id : `${shift.id}-${nextId}`,
id: oldSlot ? oldSlot.id : nextId,
assigned,
start,
end,
@ -339,35 +355,33 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
}
const shiftChanges = computed(() => {
let eventChanges: ChangeRecord<Shift>[] = [];
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
for (const change of filterSetOps(changes.value)) {
if (change.op === "set") {
let { setShift, delShift } = findShift(change.data, eventChanges, schedule.value);
if (!change.deleted) {
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
if (delShift && delShift !== setShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
eventChanges = removeSlot(eventChanges, delShift, change);
}
if (!setShift) {
setShift = {
id: toId(change.data.name),
name: change.data.name,
role: change.data.role,
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
roleId: change.roleId,
slots: [],
};
}
setShift = {
...setShift,
role: change.data.role,
roleId: change.roleId,
}
eventChanges = replaceChange({
op: "set",
data: mergeSlot(setShift, change.data),
}, eventChanges);
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
} else if (change.op === "del") {
let { delShift } = findShift(change.data, eventChanges, schedule.value);
} else if (change.deleted) {
let { delShift } = findShift(change, eventChanges, schedule.value);
if (delShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
eventChanges = removeSlot(eventChanges, delShift, change);
}
}
}
@ -375,20 +389,27 @@ const shiftChanges = computed(() => {
});
const schedulePreview = computed(() => {
const rota = [...schedule.value.rota ?? []]
applyChangeArray(shiftChanges.value, rota);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const shifts = [...schedule.value.shifts ?? []]
applyUpdatesToArray(shiftChanges.value, shifts);
return {
...schedule.value,
rota,
shifts,
};
});
function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, shiftSlot: ShiftSlot) {
function removeSlot(
eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[],
shift: Extract<ApiScheduleShift, { deleted?: false }>,
shiftSlot: ShiftSlot,
) {
let oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
if (oldSlot) {
eventChanges = replaceChange({
op: "set",
data: { ...shift, slots: shift.slots.filter(s => s.id !== oldSlot.id) },
...shift,
slots: shift.slots.filter(s => s.id !== oldSlot.id)
}, eventChanges);
}
return eventChanges;
@ -397,17 +418,15 @@ function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, shiftSlot
const accountStore = useAccountStore();
const schedule = await useSchedule();
type ShiftSlotChange = { op: "set" | "del", data: ShiftSlot } ;
const changes = ref<ShiftSlot[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
const changes = ref<ShiftSlotChange[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
function replaceChange<T extends Entity>(
change: T,
changes: T[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -416,8 +435,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
copy.push(change);
return copy;
}
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
return changes.filter(change => change.data.id !== id);
function revertChange<T extends Entity>(id: number, changes: T[]) {
return changes.filter(change => change.id !== id);
}
const oneDayMs = 24 * 60 * 60 * 1000;
@ -442,9 +461,9 @@ const newShiftEnd = computed({
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newShiftRole = ref(props.role);
watch(() => props.role, () => {
newShiftRole.value = props.role;
const newShiftRole = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
});
function endFromTime(start: DateTime, time: string) {
@ -469,7 +488,7 @@ function editShiftSlot(
end?: string,
duration?: string,
name?: string,
role?: string,
roleId?: number,
assigned?: number[],
}
) {
@ -482,7 +501,6 @@ function editShiftSlot(
};
}
if (edits.end !== undefined) {
shiftSlot = {
...shiftSlot,
end: endFromTime(shiftSlot.start, edits.end),
@ -500,19 +518,19 @@ function editShiftSlot(
name: edits.name,
};
}
if (edits.role !== undefined) {
if (edits.roleId !== undefined) {
let changesCopy = changes.value;
for (const slot of shiftSlots.value) {
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
changesCopy = replaceChange({
op: "set",
data: { ...slot, role: edits.role }
...slot,
roleId: edits.roleId,
}, changesCopy);
}
}
changesCopy = replaceChange({
op: "set",
data: { ...shiftSlot, role: edits.role }
...shiftSlot,
roleId: edits.roleId,
}, changesCopy);
changes.value = changesCopy;
return;
@ -523,20 +541,22 @@ function editShiftSlot(
assigned: edits.assigned,
};
}
const change = { op: "set" as const, data: shiftSlot };
changes.value = replaceChange(change, changes.value);
changes.value = replaceChange(shiftSlot, changes.value);
}
function delShiftSlot(shiftSlot: ShiftSlot) {
const change = { op: "del" as const, data: shiftSlot };
const change = {
...shiftSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertShiftSlot(id: string) {
function revertShiftSlot(id: number) {
changes.value = revertChange(id, changes.value);
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const role = newShiftRole.value;
if (!role) {
const roleId = newShiftRole.value;
if (!roleId) {
alert("Invalid role");
return;
}
@ -561,18 +581,16 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time");
return;
}
const change: ChangeRecord<ShiftSlot> = {
op: "set" as const,
data: {
type: "slot",
id: `$new-${Date.now()}`,
name,
origRole: role,
role,
assigned: [],
start,
end,
},
const change: ShiftSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origRole: roleId,
roleId,
assigned: [],
start,
end,
};
newShiftName.value = "";
changes.value = replaceChange(change, changes.value);
@ -581,7 +599,11 @@ async function saveShiftSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { rota: shiftChanges.value },
body: {
id: 111,
updatedAt: "",
shifts: shiftChanges.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -601,9 +623,12 @@ function gapFormat(gap: Gap) {
}
const shiftSlots = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data: (ShiftSlot | Gap)[] = [];
for (const shift of schedule.value.rota ?? []) {
if (props.role !== undefined && shift.role !== props.role)
for (const shift of schedule.value.shifts ?? []) {
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
continue;
for (const slot of shift.slots) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
@ -611,18 +636,19 @@ const shiftSlots = computed(() => {
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
shift,
slot,
name: shift.name,
role: shift.role,
roleId: shift.roleId,
assigned: slot.assigned ?? [],
origRole: shift.role,
origRole: shift.roleId,
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
});
}
}
applyChangeArray(changes.value.filter(change => change.op === "set"), data as ShiftSlot[]);
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as ShiftSlot[]);
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
// Insert gaps
@ -633,7 +659,7 @@ const shiftSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
role: props.role,
roleId: props.roleId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
end: second.start,
}]);

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -14,7 +17,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="shift in shifts"
v-for="shift in shifts?.filter(s => !s.deleted)"
:key="shift.id"
:class="{ removed: removed.has(shift.id) }"
>
@ -28,14 +31,14 @@
</td>
<td>
<select
:value="shift.role"
@change="editShift(shift, { role: ($event as any).target.value })"
:value="shift.roleId"
@change="editShift(shift, { roleId: ($event as any).target.value })"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="shift.role === role.id"
:selected="shift.roleId === role.id"
>{{ role.name }}</option>
</select>
</td>
@ -54,14 +57,14 @@
@click="delShift(shift.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === shift.id)"
v-if="changes.some(c => c.id === shift.id)"
type="button"
@click="revertShift(shift.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ toId(newShiftName) }}</td>
<td>{{ newShiftId }}</td>
<td>
<input
type="text"
@ -71,7 +74,7 @@
<td>
<select v-model="newShiftRole">
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
@ -100,12 +103,12 @@
</template>
<template v-else>
<tr
v-for="shift in shifts"
v-for="shift in shifts?.filter(s => !s.deleted)"
:key="shift.id"
>
<td>{{ shift.id }}</td>
<td>{{ shift.name }}</td>
<td>{{ shift.role }}</td>
<td>{{ shift.roleId }}</td>
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
<td>{{ shift.description }}</td>
</tr>
@ -131,25 +134,25 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, Shift } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
const props = defineProps<{
edit?: boolean,
role?: string,
roleId?: number,
}>();
const schedule = await useSchedule();
const changes = ref<ChangeRecord<Shift>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleShift[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<Shift>,
changes: ChangeRecord<Shift>[],
change: ApiScheduleShift,
changes: ApiScheduleShift[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
@ -158,19 +161,29 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<Shift>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleShift[]) {
return changes.filter(change => change.id !== id);
}
const newShiftName = ref("");
const newShiftRole = ref(props.role);
watch(() => props.role, () => {
newShiftRole.value = props.role;
const newShiftId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.shifts?.map(r => r.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
const newShiftRole = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
});
const newShiftDescription = ref("");
function editShift(
shift: Shift,
edits: { name?: string, description?: string, role?: string }
shift: Extract<ApiScheduleShift, { deleted?: false }>,
edits: { name?: string, description?: string, roleId?: number }
) {
const copy = { ...shift };
if (edits.name !== undefined) {
@ -179,24 +192,26 @@ function editShift(
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
if (edits.role !== undefined) {
copy.role = edits.role;
if (edits.roleId !== undefined) {
copy.roleId = edits.roleId;
}
const change = { op: "set" as const, data: copy };
changes.value = replaceChange(copy, changes.value);
}
function delShift(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function delShift(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertShift(id: string) {
function revertShift(id: number) {
changes.value = revertChange(id, changes.value);
}
function shiftExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.rota?.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.shifts?.some(s => !s.deleted && toId(s.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newShift() {
@ -204,19 +219,20 @@ function newShift() {
alert(`Shift ${newShiftName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (!newShiftRole.value) {
alert(`Invalid role`);
return;
}
const change = {
op: "set" as const,
data: {
id: toId(newShiftName.value),
name: newShiftName.value,
role: newShiftRole.value,
description: newShiftDescription.value || undefined,
slots: [],
},
id: newShiftId.value,
updatedAt: "",
name: newShiftName.value,
roleId: newShiftRole.value,
description: newShiftDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newShiftName.value = "";
@ -226,7 +242,11 @@ async function saveShifts() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { rota: changes.value },
body: {
id: 111,
updatedAt: "",
shifts: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -236,8 +256,11 @@ async function saveShifts() {
}
const shifts = computed(() => {
const data = [...schedule.value.rota ?? []].filter(shift => !props.role || shift.role === props.role);
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.shifts ?? []].filter(shift => !shift.deleted && (!props.roleId || shift.roleId === props.roleId));
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,10 @@
<template>
<figure class="timetable">
<figure class="timetable" v-if="schedule.deleted">
<p>
Error: Schedule is deleted.
</p>
</figure>
<figure class="timetable" v-else>
<details>
<summary>Debug</summary>
<details>
@ -56,7 +61,7 @@
</tr>
</thead>
<tbody>
<template v-for="location in schedule.locations" :key="location.id">
<template v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
<tr v-if="locationRows.has(location.id)">
<th>{{ location.name }}</th>
<td
@ -75,7 +80,7 @@
<th>Shifts</th>
<td :colSpan="totalColumns"></td>
</tr>
<template v-for="role in schedule.roles" :key="role.id">
<template v-for="role in schedule.roles?.filter(r => !r.deleted)" :key="role.id">
<tr v-if="roleRows.has(role.id)">
<th>{{ role.name }}</th>
<td
@ -97,7 +102,8 @@
<script setup lang="ts">
import { DateTime } from "luxon";
import type { Role, Schedule, ScheduleEvent, ScheduleLocation, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleLocation, ApiScheduleRole, ApiScheduleShift, ApiScheduleShiftSlot } from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
import { pairs, setEquals } from "~/shared/utils/functions";
const oneHourMs = 60 * 60 * 1000;
@ -107,8 +113,8 @@ const oneMinMs = 60 * 1000;
/** Point in time where a time slots starts or ends. */
type Edge =
| { type: "start" | "end", source: "event", slot: TimeSlot }
| { type: "start" | "end", source: "shift", role: string, slot: ShiftSlot }
| { type: "start" | "end", source: "event", slot: ApiScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
;
/** Point in time where multiple edges meet. */
@ -118,8 +124,8 @@ type Junction = { ts: number, edges: Edge[] };
type Span = {
start: Junction;
end: Junction,
locations: Map<string, Set<TimeSlot>>,
roles: Map<string, Set<ShiftSlot>>,
locations: Map<number, Set<ApiScheduleEventSlot>>,
roles: Map<number, Set<ApiScheduleShiftSlot>>,
};
/**
@ -133,7 +139,10 @@ type Stretch = {
spans: Span[];
}
function* edgesFromEvents(events: Iterable<ScheduleEvent>, filter = (slot: TimeSlot) => true): Generator<Edge> {
function* edgesFromEvents(
events: Iterable<Extract<ApiScheduleEvent, { deleted?: false }>>,
filter = (slot: ApiScheduleEventSlot) => true,
): Generator<Edge> {
for (const event of events) {
for (const slot of event.slots.filter(filter)) {
if (slot.start > slot.end) {
@ -145,14 +154,17 @@ function* edgesFromEvents(events: Iterable<ScheduleEvent>, filter = (slot: TimeS
}
}
function* edgesFromShifts(shifts: Iterable<Shift>, filter = (slot: ShiftSlot) => true): Generator<Edge> {
function* edgesFromShifts(
shifts: Iterable<Extract<ApiScheduleShift, { deleted?: false }>>,
filter = (slot: ApiScheduleShiftSlot) => true,
): Generator<Edge> {
for (const shift of shifts) {
for (const slot of shift.slots.filter(filter)) {
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 };
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
}
}
}
@ -173,23 +185,25 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
}
function* spansFromJunctions(
junctions: Iterable<Junction>, locations: ScheduleLocation[], roles: Role[] | undefined,
junctions: Iterable<Junction>,
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
): Generator<Span> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<TimeSlot>()])
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
);
const activeRoles = new Map(
roles?.map(role => [role.id, new Set<ShiftSlot>()])
roles?.map(role => [role.id, new Set<ApiScheduleShiftSlot>()])
);
for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) {
if (edge.type === "start") {
if (edge.source === "event") {
for (const location of edge.slot.locations) {
activeLocations.get(location)?.add(edge.slot)
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.add(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.role)?.add(edge.slot)
activeRoles.get(edge.roleId)?.add(edge.slot)
}
}
}
@ -210,11 +224,11 @@ function* spansFromJunctions(
for (const edge of end.edges) {
if (edge.type === "end") {
if (edge.source === "event") {
for (const location of edge.slot.locations) {
activeLocations.get(location)?.delete(edge.slot)
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.delete(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.role)?.delete(edge.slot);
activeRoles.get(edge.roleId)?.delete(edge.slot);
}
}
}
@ -325,24 +339,24 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
function tableElementsFromStretches(
stretches: Iterable<Stretch>,
events: ScheduleEvent[],
locations: ScheduleLocation[],
rota: Shift[] | undefined,
roles: Role[] | undefined,
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
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 };
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, 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 locationRows = new Map<number, LocationCell[]>(locations.map(location => [location.id, []]));
const roleRows = new Map<number, 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])))
const shiftBySlotId = new Map(shifts?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
let totalColumns = 0;
function startColumnGroup(className?: string) {
@ -354,7 +368,7 @@ function tableElementsFromStretches(
function startHour(content?: string) {
hourHeaders.push({ span: 0, content })
}
function startLocation(id: string, slots = new Set<TimeSlot>()) {
function startLocation(id: number, slots = new Set<ApiScheduleEventSlot>()) {
const rows = locationRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -363,7 +377,7 @@ function tableElementsFromStretches(
}
rows.push({ span: 0, slots, title: "" });
}
function startRole(id: string, slots = new Set<ShiftSlot>()) {
function startRole(id: number, slots = new Set<ApiScheduleShiftSlot>()) {
const rows = roleRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -487,21 +501,35 @@ function tableElementsFromStretches(
}
const props = defineProps<{
schedule: Schedule,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
schedule: ApiSchedule,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
const junctions = computed(() => junctionsFromEdges([
...edgesFromEvents(schedule.value.events, props.eventSlotFilter),
...edgesFromShifts(schedule.value.rota ?? [], props.shiftSlotFilter),
]));
const stretches = computed(() => [
...stretchesFromSpans(
spansFromJunctions(junctions.value, schedule.value.locations, schedule.value.roles),
oneHourMs * 5
)
])
const junctions = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return junctionsFromEdges([
...edgesFromEvents(schedule.value.events?.filter(e => !e.deleted) ?? [], props.eventSlotFilter),
...edgesFromShifts(schedule.value.shifts?.filter(s => !s.deleted) ?? [], props.shiftSlotFilter),
])
});
const stretches = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return [
...stretchesFromSpans(
spansFromJunctions(
junctions.value,
schedule.value.locations?.filter(l => !l.deleted) ?? [],
schedule.value.roles?.filter(r => !r.deleted) ?? [],
),
oneHourMs * 5
)
]
})
const accountStore = useAccountStore();
const timezone = computed({
@ -509,9 +537,19 @@ const timezone = computed({
set: (value: string) => { accountStore.timezone = value },
});
const elements = computed(() => tableElementsFromStretches(
stretches.value, schedule.value.events, schedule.value.locations, schedule.value.rota, schedule.value.roles, accountStore.activeTimezone
));
const elements = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return tableElementsFromStretches(
stretches.value,
schedule.value.events?.filter(e => !e.deleted) ?? [],
schedule.value.locations?.filter(l => !l.deleted) ?? [],
schedule.value.shifts?.filter(s => !s.deleted) ?? [],
schedule.value.roles?.filter(r => !r.deleted) ?? [],
accountStore.activeTimezone
);
});
const totalColumns = computed(() => elements.value.totalColumns);
const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders);