Refactor to use ClientSchedule on client

Use the ClientSchedule data structure for deserialising and tracking
edit state on the client instead of trying to directly deal with the
ApiSchedule type which is not build for ease of edits or rendering.
This commit is contained in:
Hornwitser 2025-06-14 19:22:53 +02:00
parent ce9f758f84
commit bb450fd583
15 changed files with 488 additions and 1297 deletions

View file

@ -1,9 +1,6 @@
<template>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<div>
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
<tr>
@ -23,7 +20,7 @@
v-for="es in eventSlots"
:key='es.slot?.id ?? es.start.toMillis()'
:class='{
removed: es.type === "slot" && removed.has(es.id),
removed: es.type === "slot" && es.deleted,
gap: es.type === "gap",
}'
>
@ -50,7 +47,7 @@
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations?.filter(l => !l.deleted)"
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
@ -96,20 +93,20 @@
<input
type="text"
:value="es.name"
@input="editEventSlot(es, { name: ($event as any).target.value })"
@input="editEvent(es, { name: ($event as any).target.value })"
>
</td>
<td>{{ status(es) }}</td>
<td>
<select
:value="es.locationId"
:value="es.location.id"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations?.filter(l => !l.deleted)"
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === es.locationId"
:selected="location.id === es.location.id"
>{{ location.name }}</option>
</select>
</td>
@ -122,12 +119,12 @@
</td>
<td>
<button
:disabled="removed.has(es.id)"
:disabled="es.deleted"
type="button"
@click="delEventSlot(es)"
@click="editEventSlot(es, { deleted: true })"
>Remove</button>
<button
v-if="changes.some(c => c.id === es.id)"
v-if="schedule.isModifiedEventSlot(es.id)"
type="button"
@click="revertEventSlot(es.id)"
>Revert</button>
@ -189,63 +186,37 @@
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.locationId }}</td>
<td>{{ es.location.id }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
</template>
</tr>
</template>
</tbody>
</table>
<p v-if="changes.length">
Changes are not save yet.
<button
type="button"
@click="saveEventSlots"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<b>EventSlot changes</b>
<ol>
<li v-for="change in changes">
<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 }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
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';
import type { Id } from '~/shared/types/common';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
const props = defineProps<{
edit?: boolean,
locationId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
}>();
interface EventSlot {
type: "slot",
id: number,
updatedAt: string,
id: Id,
deleted?: boolean,
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
slot?: ApiScheduleEventSlot,
origLocation: number,
event: ClientScheduleEvent,
slot: ClientScheduleEventSlot,
name: string,
locationId: number,
assigned: number[],
location: ClientScheduleLocation,
assigned: Set<Id>,
start: DateTime,
end: DateTime,
}
@ -262,218 +233,19 @@ interface Gap {
}
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.deleted && event.name === eventSlot.name);
const event = [...schedule.value.events.values()].find(event => 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 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: ApiScheduleEvent[],
schedule: ApiSchedule,
) {
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?.filter(e => !e.deleted).find(e => e.name === eventSlot.name);
}
let delEvent;
if (eventSlot.event) {
delEvent = changes.filter(c => !c.deleted).find(
c => c.name === eventSlot.event!.name
);
if (!delEvent) {
delEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.event!.name);
}
}
return { setEvent, delEvent };
}
function removeSlotLocation(
event: Extract<ApiScheduleEvent, { deleted?: false }>,
oldSlot: ApiScheduleEventSlot,
locationId: number
) {
// If location is an exact match remove the whole slot
if (oldSlot.locationIds.length === 1 && oldSlot.locationIds[0] === locationId) {
return {
...event,
slots: event.slots.filter(s => s.id !== oldSlot.id),
};
}
// Otherwise filter out location
return {
...event,
slots: event.slots.map(
s => s.id !== oldSlot.id ? s : {
...s,
locationIds: s.locationIds.filter(id => id !== locationId)
}
),
};
}
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(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 })!;
// Edit slot in-place if possible
if (
oldSlot
&& oldSlot.id === eventSlot.id
&& (
oldSlot.locationIds.length <= 1
|| oldSlot.start === start && oldSlot.end === end
)
) {
return {
...event,
slots: event.slots.map(s => {
if (s.id !== oldSlot.id)
return s;
const locationIds = new Set(s.locationIds);
locationIds.delete(eventSlot.origLocation)
locationIds.add(eventSlot.locationId);
return {
...s,
locationIds: [...locationIds],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
};
}),
};
}
// Else remove old slot if it exist and insert a new one
if (oldSlot) {
event = removeSlotLocation(event, oldSlot, eventSlot.origLocation);
}
return {
...event,
slots: [...event.slots, {
id: oldSlot ? oldSlot.id : nextId,
locationIds: [eventSlot.locationId],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
}],
};
}
const scheduleChanges = computed(() => {
let eventChanges: Extract<ApiScheduleEvent, { deleted?: false}>[] = [];
for (const change of filterSetOps(changes.value)) {
if (!change.deleted) {
let { setEvent, delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent && delEvent !== setEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change);
}
if (!setEvent) {
setEvent = {
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
crew: true,
slots: [],
};
}
eventChanges = replaceChange(mergeSlot(setEvent, change), eventChanges);
} else if (change.deleted) {
let { delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change);
}
}
}
return eventChanges;
});
const schedulePreview = computed(() => {
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: 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(
removeSlotLocation(event, oldSlot, eventSlot.origLocation),
eventChanges,
);
}
return eventChanges;
return eventSlot.event.slots.size === 1 ? "" : eventSlot.event.slots.size;
}
const accountStore = useAccountStore();
const schedule = await useSchedule();
const changes = ref<EventSlot[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange<T extends Entity>(
change: T,
changes: T[],
) {
const index = changes.findIndex(item => (
item.deleted === change.deleted && item.id === change.id
));
const copy = [...changes];
if (index !== -1)
copy.splice(index, 1, change);
else
copy.push(change);
return copy;
}
function revertChange<T extends Entity>(id: number, changes: T[]) {
return changes.filter(change => change.id !== id);
}
const oneDayMs = 24 * 60 * 60 * 1000;
function dropDay(diff: Duration) {
if (diff.toMillis() >= oneDayMs) {
@ -485,13 +257,17 @@ function dropDay(diff: Duration) {
const newEventStart = ref("");
const newEventDuration = ref("01:00");
const newEventEnd = computed({
get: () => (
DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" })
.plus(Duration.fromISOTime(newEventDuration.value, { locale: "en-US" }))
.toFormat("HH:mm")
),
get: () => {
try {
return DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
.plus(Duration.fromISOTime(newEventDuration.value, { locale: accountStore.activeLocale }))
.toFormat("HH:mm")
} catch (err) {
return "";
}
},
set: (value: string) => {
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
const end = endFromTime(start, value);
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
@ -502,85 +278,73 @@ watch(() => props.locationId, () => {
});
function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: "en-US" }));
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
if (end.toMillis() <= start.toMillis()) {
end = end.plus({ days: 1 });
}
return end;
}
function durationFromTime(time: string) {
let duration = Duration.fromISOTime(time, { locale: "en-US" });
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
if (duration.toMillis() === 0) {
duration = Duration.fromMillis(oneDayMs, { locale: "en-US" });
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
}
return duration;
}
const newEventName = ref("");
function editEvent(
eventSlot: EventSlot,
edits: Parameters<ClientSchedule["editEvent"]>[1],
) {
schedule.value.editEvent(eventSlot.event, edits);
}
function editEventSlot(
eventSlot: EventSlot,
edits: {
deleted?: boolean,
start?: string,
end?: string,
duration?: string,
name?: string,
locationId?: number,
assigned?: number[],
locationId?: Id,
assigned?: Set<Id>,
}
) {
const computedEdits: Parameters<ClientSchedule["editEventSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: "en-US" });
eventSlot = {
...eventSlot,
start,
end: start.plus(eventSlot.end.diff(eventSlot.start)),
};
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start;
computedEdits.end = start.plus(eventSlot.end.diff(eventSlot.start));
}
if (edits.end !== undefined) {
eventSlot = {
...eventSlot,
end: endFromTime(eventSlot.start, edits.end),
};
computedEdits.end = endFromTime(eventSlot.start, edits.end);
}
if (edits.duration !== undefined) {
eventSlot = {
...eventSlot,
end: eventSlot.start.plus(durationFromTime(edits.duration)),
};
}
if (edits.name !== undefined) {
eventSlot = {
...eventSlot,
name: edits.name,
};
computedEdits.end = eventSlot.start.plus(durationFromTime(edits.duration));
}
if (edits.locationId !== undefined) {
eventSlot = {
...eventSlot,
locationId: edits.locationId,
};
const location = schedule.value.locations.get(edits.locationId);
if (location)
computedEdits.locations = [location];
}
if (edits.assigned !== undefined) {
eventSlot = {
...eventSlot,
assigned: edits.assigned,
};
}
changes.value = replaceChange(eventSlot, changes.value);
schedule.value.editEventSlot(eventSlot.slot, computedEdits);
}
function delEventSlot(eventSlot: EventSlot) {
const change = {
...eventSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertEventSlot(id: number) {
changes.value = revertChange(id, changes.value);
function revertEventSlot(id: Id) {
schedule.value.restoreEventSlot(id);
}
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const locationId = newEventLocation.value;
if (!locationId) {
const nameId = toId(name);
const event = [...schedule.value.events.values()].find(event => toId(event.name) === nameId);
if (!event) {
alert("Invalid event");
return;
}
const location = schedule.value.locations.get(newEventLocation.value!);
if (!location) {
alert("Invalid location");
return;
}
@ -598,43 +362,25 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
end = options.end;
start = options.end.minus(duration);
} else {
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
end = endFromTime(start, newEventEnd.value);
}
if (!start.isValid || !end.isValid) {
alert("Invalid start and/or end time");
return;
}
const change: EventSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origLocation: locationId,
locationId,
assigned: [],
const slot = new ClientScheduleEventSlot(
schedule.value.nextClientId--,
false,
event.id,
start,
end,
};
[location],
new Set(),
0,
);
newEventName.value = "";
changes.value = replaceChange(change, changes.value);
}
async function saveEventSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
events: scheduleChanges.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
schedule.value.setEventSlot(slot);
}
const oneHourMs = 60 * 60 * 1000;
@ -648,36 +394,31 @@ 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.values()) {
if (event.deleted)
continue;
for (const slot of event.slots) {
for (const slot of event.slots.values()) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
for (const locationId of slot.locationIds) {
if (props.locationId !== undefined && locationId !== props.locationId)
for (const location of slot.locations) {
if (props.locationId !== undefined && location.id !== props.locationId)
continue;
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
deleted: slot.deleted || event.deleted,
event,
slot,
name: event.name,
locationId,
location,
assigned: slot.assigned ?? [],
origLocation: locationId,
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
start: slot.start,
end: slot.end,
});
}
}
}
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
@ -689,7 +430,7 @@ const eventSlots = computed(() => {
gaps.push([index, {
type: "gap",
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
end: second.start,
}]);
}