owltide/components/ScheduleTable.vue

731 lines
18 KiB
Vue
Raw Normal View History

<template>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
<tr>
<th>start</th>
<th>end</th>
<th>duration</th>
<th>event</th>
<th>s</th>
<th>location</th>
<th>assigned</th>
<th v-if="edit"></th>
</tr>
</thead>
<tbody>
<template v-if="edit">
<tr
v-for="es in eventSlots"
:key='es.slot?.id ?? es.start.toMillis()'
:class='{
removed: es.type === "slot" && removed.has(es.id),
gap: es.type === "gap",
}'
>
<template v-if="es.type === 'gap'">
<td colspan="2">
{{ gapFormat(es) }}
gap
</td>
<td>
<input
type="time"
v-model="newEventDuration"
>
</td>
<td>
<input
type="text"
v-model="newEventName"
>
</td>
<td></td>
<td>
<select
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
>{{ location.name }}</option>
</select>
</td>
<td></td>
<td>
Add at
<button
type="button"
@click="newEventSlot({ start: es.start })"
>Start</button>
<button
type="button"
@click="newEventSlot({ end: es.end })"
>End</button>
</td>
</template>
<template v-else-if='edit'>
<td>
<input
type="datetime-local"
:value="es.start.toFormat('yyyy-LL-dd\'T\'HH:mm')"
@blur="editEventSlot(es, { start: ($event as any).target.value })"
>
</td>
<td>
<input
type="time"
:value="es.end.toFormat('HH:mm')"
@input="editEventSlot(es, { end: ($event as any).target.value })"
>
</td>
<td>
<input
type="time"
:value='dropDay(es.end.diff(es.start)).toFormat("hh:mm")'
@input="editEventSlot(es, { duration: ($event as any).target.value })"
>
</td>
<td>
<input
type="text"
:value="es.name"
@input="editEventSlot(es, { name: ($event as any).target.value })"
>
</td>
<td>{{ status(es) }}</td>
<td>
<select
:value="es.locationId"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === es.locationId"
>{{ location.name }}</option>
</select>
</td>
<td>
<AssignedCrew
:edit="true"
:modelValue="es.assigned"
@update:modelValue="editEventSlot(es, { assigned: $event })"
/>
</td>
<td>
<button
:disabled="removed.has(es.id)"
type="button"
@click="delEventSlot(es)"
>Remove</button>
<button
v-if="changes.some(c => c.id === es.id)"
type="button"
@click="revertEventSlot(es.id)"
>Revert</button>
</td>
</template>
</tr>
<tr>
<td>
<input
type="datetime-local"
v-model="newEventStart"
>
</td>
<td>
<input
type="time"
v-model="newEventEnd"
>
</td>
<td>
<input
type="time"
v-model="newEventDuration"
>
</td>
<td>
<input
type="text"
v-model="newEventName"
>
</td>
<td></td>
<td></td>
<td colspan="2">
<button
type="button"
@click="newEventSlot()"
>Add Event</button>
</td>
</tr>
</template>
<template v-else>
<tr
v-for="es in eventSlots"
:key='es.slot?.id ?? es.start.toMillis()'
:class='{
gap: es.type === "gap",
}'
>
<template v-if="es.type === 'gap'">
<td colspan="2">
{{ gapFormat(es) }}
gap
</td>
</template>
<template v-else>
<td>{{ es.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
<td>{{ es.end.toFormat("HH:mm") }}</td>
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.locationId }}</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';
const props = defineProps<{
edit?: boolean,
locationId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
interface EventSlot {
type: "slot",
id: number,
updatedAt: string,
deleted?: boolean,
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
slot?: ApiScheduleEventSlot,
origLocation: number,
name: string,
locationId: number,
assigned: number[],
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
event?: undefined,
slot?: undefined,
name?: undefined,
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.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 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;
}
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) {
return diff.minus({ days: 1 });
}
return diff;
}
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")
),
set: (value: string) => {
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
const end = endFromTime(start, value);
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newEventLocation = ref(props.locationId);
watch(() => props.locationId, () => {
newEventLocation.value = props.locationId;
});
function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: "en-US" }));
if (end.toMillis() <= start.toMillis()) {
end = end.plus({ days: 1 });
}
return end;
}
function durationFromTime(time: string) {
let duration = Duration.fromISOTime(time, { locale: "en-US" });
if (duration.toMillis() === 0) {
duration = Duration.fromMillis(oneDayMs, { locale: "en-US" });
}
return duration;
}
const newEventName = ref("");
function editEventSlot(
eventSlot: EventSlot,
edits: {
start?: string,
end?: string,
duration?: string,
name?: string,
locationId?: number,
assigned?: number[],
}
) {
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)),
};
}
if (edits.end !== undefined) {
eventSlot = {
...eventSlot,
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,
};
}
if (edits.locationId !== undefined) {
eventSlot = {
...eventSlot,
locationId: edits.locationId,
};
}
if (edits.assigned !== undefined) {
eventSlot = {
...eventSlot,
assigned: edits.assigned,
};
}
changes.value = replaceChange(eventSlot, changes.value);
}
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 newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const locationId = newEventLocation.value;
if (!locationId) {
alert("Invalid location");
return;
}
let start;
let end;
const duration = durationFromTime(newEventDuration.value);
if (!duration.isValid) {
alert("Invalid duration");
return;
}
if (options.start) {
start = options.start;
end = options.start.plus(duration);
} else if (options.end) {
end = options.end;
start = options.end.minus(duration);
} else {
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
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: [],
start,
end,
};
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);
}
}
const oneHourMs = 60 * 60 * 1000;
function gapFormat(gap: Gap) {
let diff = gap.end.diff(gap.start);
if (diff.toMillis() % oneHourMs !== 0)
diff = diff.shiftTo("hours", "minutes");
else
diff = diff.shiftTo("hours");
return diff.toHuman({ listStyle: "short", unitDisplay: "short" });
}
const eventSlots = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data: (EventSlot | Gap)[] = [];
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 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,
locationId,
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" }),
});
}
}
}
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
let maxEnd = 0;
const gaps: [number, Gap][] = []
for (const [index, [first, second]] of enumerate(pairs(data))) {
maxEnd = Math.max(maxEnd, first.end.toMillis());
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
end: second.start,
}]);
}
}
gaps.reverse();
for (const [index, gap] of gaps) {
data.splice(index + 1, 0, gap);
}
return data;
});
</script>
<style scoped>
label {
display: inline;
padding-inline-end: 0.75em;
}
table {
margin-block-start: 1rem;
border-spacing: 0;
}
table th {
text-align: left;
border-bottom: 1px solid var(--foreground);
}
table :is(th, td) + :is(th, td) {
padding-inline-start: 0.4em;
}
.gap {
height: 1.8em;
}
.removed {
background-color: color-mix(in oklab, var(--background), rgb(255, 0, 0) 40%);
}
.removed :is(td, input) {
text-decoration: line-through;
}
</style>