Add editing of shift time slots

This commit is contained in:
Hornwitser 2025-03-15 16:45:02 +01:00
parent accc1690ff
commit 905ec8a38b
2 changed files with 683 additions and 0 deletions

View file

@ -0,0 +1,653 @@
<template>
<div>
<Timetable :schedule="schedulePreview" />
<table>
<thead>
<tr>
<th>start</th>
<th>end</th>
<th>duration</th>
<th>shift</th>
<th>s</th>
<th>role</th>
<th v-if="edit"></th>
</tr>
</thead>
<tbody>
<template v-if="edit">
<tr
v-for="ss in shiftSlots"
:key='ss.slot?.id ?? ss.start.toMillis()'
:class='{
removed: ss.type === "slot" && removed.has(ss.id),
gap: ss.type === "gap",
}'
>
<template v-if="ss.type === 'gap'">
<td colspan="2">
{{ gapFormat(ss) }}
gap
</td>
<td>
<input
type="time"
v-model="newShiftDuration"
>
</td>
<td>
<input
type="text"
v-model="newShiftName"
>
</td>
<td></td>
<td>
<select
v-model="newShiftRole"
>
<option
v-for="role in schedule.roles"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
>{{ role.name }}</option>
</select>
</td>
<td>
Add at
<button
type="button"
@click="newShiftSlot({ start: ss.start })"
>Start</button>
<button
type="button"
@click="newShiftSlot({ end: ss.end })"
>End</button>
</td>
</template>
<template v-else-if='edit'>
<td>
<input
type="datetime-local"
:value="ss.start.toFormat('yyyy-LL-dd\'T\'HH:mm')"
@blur="editShiftSlot(ss, { start: ($event as any).target.value })"
>
</td>
<td>
<input
type="time"
:value="ss.end.toFormat('HH:mm')"
@input="editShiftSlot(ss, { end: ($event as any).target.value })"
>
</td>
<td>
<input
type="time"
:value='dropDay(ss.end.diff(ss.start)).toFormat("hh:mm")'
@input="editShiftSlot(ss, { duration: ($event as any).target.value })"
>
</td>
<td>
<input
type="text"
:value="ss.name"
@input="editShiftSlot(ss, { name: ($event as any).target.value })"
>
</td>
<td>{{ status(ss) }}</td>
<td>
<select
:value="ss.role"
@change="editShiftSlot(ss, { role: ($event as any).target.value })"
>
<option
v-for="role in schedule.roles"
:key="role.id"
:value="role.id"
:selected="role.id === ss.role"
>{{ role.name }}</option>
</select>
</td>
<td>
<button
:disabled="removed.has(ss.id)"
type="button"
@click="delShiftSlot(ss)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === ss.id)"
type="button"
@click="revertShiftSlot(ss.id)"
>Revert</button>
</td>
</template>
</tr>
<tr>
<td>
<input
type="datetime-local"
v-model="newShiftStart"
>
</td>
<td>
<input
type="time"
v-model="newShiftEnd"
>
</td>
<td>
<input
type="time"
v-model="newShiftDuration"
>
</td>
<td>
<input
type="text"
v-model="newShiftName"
>
</td>
<td colspan="2">
<button
type="button"
@click="newShiftSlot()"
>Add Shift</button>
</td>
</tr>
</template>
<template v-else>
<tr
v-for="ss in shiftSlots"
:key='ss.slot?.id ?? ss.start.toMillis()'
:class='{
gap: ss.type === "gap",
}'
>
<template v-if="ss.type === 'gap'">
<td colspan="2">
{{ gapFormat(ss) }}
gap
</td>
</template>
<template v-else>
<td>{{ ss.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
<td>{{ ss.end.toFormat("HH:mm") }}</td>
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td>
<td>{{ ss.role }}</td>
</template>
</tr>
</template>
</tbody>
</table>
<p v-if="changes.length">
Changes are not save yet.
<button
type="button"
@click="saveShiftSlots"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<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>
</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>
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
import type { ChangeRecord, Schedule, Shift, Role, ShiftSlot as ShiftTimeSlot} from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
const props = defineProps<{
edit?: boolean,
role?: string,
}>();
interface ShiftSlot {
type: "slot",
id: string,
shift?: Shift,
slot?: ShiftTimeSlot,
origRole: string,
name: string,
role: string,
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
shift?: undefined,
slot?: undefined,
name?: undefined,
role?: string,
start: DateTime,
end: DateTime,
}
function status(shiftSlot: ShiftSlot) {
if (
!shiftSlot.shift
|| shiftSlot.shift.name !== shiftSlot.name
) {
const shift = schedule.value.rota?.find(shift => 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 findShift(
shiftSlot: ShiftSlot,
changes: ChangeRecord<Shift>[],
schedule: Schedule,
) {
let setShift = changes.find(
c => (
c.op === "set"
&& c.data.name === shiftSlot.name
&& c.data.role === shiftSlot.role
)
)?.data as Shift | undefined;
if (
!setShift
&& shiftSlot.shift
&& shiftSlot.shift.name === shiftSlot.name
) {
setShift = shiftSlot.shift;
}
if (!setShift) {
setShift = schedule.rota?.find(e => e.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;
if (!delShift) {
delShift = schedule.rota?.find(e => e.name === shiftSlot.shift!.name);
}
}
return { setShift, delShift };
}
function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
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 start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
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}`);
}
// Edit slot in-place if possible
if (oldSlot && oldSlot.id === shiftSlot.id) {
return {
...shift,
slots: shift.slots.map(s => s.id !== oldSlot.id ? s : { ...s, start, end, }),
};
}
// Else remove old slot if it exist and insert a new one
return {
...shift,
slots: [...(oldSlot ? shift.slots.filter(s => s.id !== oldSlot.id) : shift.slots), {
id: oldSlot ? oldSlot.id : `${shift.id}-${nextId}`,
start,
end,
}],
};
}
const shiftChanges = computed(() => {
let eventChanges: ChangeRecord<Shift>[] = [];
for (const change of filterSetOps(changes.value)) {
if (change.op === "set") {
let { setShift, delShift } = findShift(change.data, eventChanges, schedule.value);
if (delShift && delShift !== setShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
}
if (!setShift) {
setShift = {
id: toId(change.data.name),
name: change.data.name,
role: change.data.role,
slots: [],
};
}
setShift = {
...setShift,
role: change.data.role,
}
eventChanges = replaceChange({
op: "set",
data: mergeSlot(setShift, change.data),
}, eventChanges);
} else if (change.op === "del") {
let { delShift } = findShift(change.data, eventChanges, schedule.value);
if (delShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
}
}
}
return eventChanges;
});
const schedulePreview = computed(() => {
const rota = [...schedule.value.rota ?? []]
applyChangeArray(shiftChanges.value, rota);
return {
...schedule.value,
rota,
};
});
function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, 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) },
}, eventChanges);
}
return eventChanges;
}
const { data: session } = await useAccountSession();
const schedule = await useSchedule();
const runtimeConfig = useRuntimeConfig();
const timezone = computed(
() => session.value?.account?.timezone ?? runtimeConfig.public.defaultTimezone
);
type ShiftSlotChange = { op: "set" | "del", data: ShiftSlot } ;
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 }}>(
change: T,
changes: T[],
) {
const index = changes.findIndex(item => (
item.op === change.op && item.data.id === change.data.id
));
const copy = [...changes];
if (index !== -1)
copy.splice(index, 1, change);
else
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);
}
const oneDayMs = 24 * 60 * 60 * 1000;
function dropDay(diff: Duration) {
if (diff.toMillis() >= oneDayMs) {
return diff.minus({ days: 1 });
}
return diff;
}
const newShiftStart = ref("");
const newShiftDuration = ref("01:00");
const newShiftEnd = computed({
get: () => (
DateTime.fromISO(newShiftStart.value, { zone: timezone.value })
.plus(Duration.fromISOTime(newShiftDuration.value))
.toFormat("HH:mm")
),
set: (value: string) => {
const start = DateTime.fromISO(newShiftStart.value, { zone: timezone.value });
const end = endFromTime(start, value);
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newShiftRole = ref(props.role);
watch(() => props.role, () => {
newShiftRole.value = props.role;
});
function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time));
if (end.toMillis() <= start.toMillis()) {
end = end.plus({ days: 1 });
}
return end;
}
function durationFromTime(time: string) {
let duration = Duration.fromISOTime(time);
if (duration.toMillis() === 0) {
duration = Duration.fromMillis(oneDayMs);
}
return duration;
}
const newShiftName = ref("");
function editShiftSlot(
shiftSlot: ShiftSlot,
edits: {
start?: string,
end?: string,
duration?: string,
name?: string,
role?: string,
}
) {
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: timezone.value });
shiftSlot = {
...shiftSlot,
start,
end: start.plus(shiftSlot.end.diff(shiftSlot.start)),
};
}
if (edits.end !== undefined) {
shiftSlot = {
...shiftSlot,
end: endFromTime(shiftSlot.start, edits.end),
};
}
if (edits.duration !== undefined) {
shiftSlot = {
...shiftSlot,
end: shiftSlot.start.plus(durationFromTime(edits.duration)),
};
}
if (edits.name !== undefined) {
shiftSlot = {
...shiftSlot,
name: edits.name,
};
}
if (edits.role !== 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 }
}, changesCopy);
}
}
changesCopy = replaceChange({
op: "set",
data: { ...shiftSlot, role: edits.role }
}, changesCopy);
changes.value = changesCopy;
return;
}
const change = { op: "set" as const, data: shiftSlot };
changes.value = replaceChange(change, changes.value);
}
function delShiftSlot(shiftSlot: ShiftSlot) {
const change = { op: "del" as const, data: shiftSlot };
changes.value = replaceChange(change, changes.value);
}
function revertShiftSlot(id: string) {
changes.value = revertChange(id, changes.value);
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const role = newShiftRole.value;
if (!role) {
alert("Invalid role");
return;
}
let start;
let end;
const duration = durationFromTime(newShiftDuration.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(newShiftStart.value, { zone: timezone.value });
end = endFromTime(start, newShiftEnd.value);
}
if (!start.isValid || !end.isValid) {
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,
start,
end,
},
};
newShiftName.value = "";
changes.value = replaceChange(change, changes.value);
}
async function saveShiftSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { rota: shiftChanges.value },
});
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 shiftSlots = computed(() => {
const data: (ShiftSlot | Gap)[] = [];
for (const shift of schedule.value.rota ?? []) {
if (props.role !== undefined && shift.role !== props.role)
continue;
for (const slot of shift.slots) {
data.push({
type: "slot",
id: slot.id,
shift,
slot,
name: shift.name,
role: shift.role,
origRole: shift.role,
start: DateTime.fromISO(slot.start, { zone: timezone.value }),
end: DateTime.fromISO(slot.end, { zone: timezone.value }),
});
}
}
applyChangeArray(changes.value.filter(change => change.op === "set"), data as ShiftSlot[]);
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",
role: props.role,
start: DateTime.fromMillis(maxEnd),
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>