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

@ -5,7 +5,7 @@
<button
v-if="edit"
type="button"
@click="assignedIds = assignedIds.filter(id => id !== account.id)"
@click="assignedIds = new Set([...assignedIds].filter(id => id !== account.id))"
>
x
</button>
@ -26,9 +26,9 @@ defineProps<{
}>();
const { data: accounts } = useAccounts();
const accountsById = computed(() => new Map(accounts.value?.map?.(a => [a.id, a])));
const assignedIds = defineModel<number[]>({ required: true });
const assignedIds = defineModel<Set<number>>({ required: true });
const assigned = computed(
() => assignedIds.value.map(
() => [...assignedIds.value].map(
id => accountsById.value.get(id) ?? { id, name: String(id) }
)
);
@ -43,8 +43,8 @@ function addCrew() {
return;
const account = crewByName.value.get(addName.value);
if (account) {
if (!assignedIds.value.some(id => id === account.id)) {
assignedIds.value = [...assignedIds.value, account.id];
if (!assignedIds.value.has(account.id)) {
assignedIds.value = new Set([...assignedIds.value, account.id]);
} else {
alert(`${addName.value} has already been added`);
}

View file

@ -1,10 +1,5 @@
<template>
<section class="event" v-if="event.deleted">
<p>
Error: Unexpected deleted event.
</p>
</section>
<section class="event" v-else>
<section class="event">
<h3>{{ event.name }}</h3>
<p>{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested">
@ -14,7 +9,7 @@
<button
class="interested"
:class="{ active: accountStore.interestedEventIds.has(event.id) }"
@click="toggle('event', event.id, event.slots.map(slot => slot.id))"
@click="toggle('event', event.id, [...event.slots.keys()])"
>
{{ accountStore.interestedEventIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
</button>
@ -22,10 +17,10 @@
<h4>Timeslots</h4>
<ul>
<li v-for="slot in event.slots" :key="slot.id">
<li v-for="slot in event.slots.values()" :key="slot.id">
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
<button
v-if="accountStore.valid && event.slots.length > 1"
v-if="accountStore.valid && event.slots.size > 1"
class="interested"
:disabled="accountStore.interestedEventIds.has(event.id)"
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) }"
@ -38,7 +33,7 @@
</template>
<p v-if="slot.assigned">
Crew:
{{ slot.assigned.map(id => idToAccount.get(id)?.name).join(", ") }}
{{ [...slot.assigned].map(id => idToAccount.get(id)?.name).join(", ") }}
</p>
</li>
</ul>
@ -47,18 +42,17 @@
<script lang="ts" setup>
import { DateTime } from 'luxon';
import type { ApiScheduleEvent } from '~/shared/types/api';
defineProps<{
event: ApiScheduleEvent
event: ClientScheduleEvent
}>()
const accountStore = useAccountStore();
const { data: accounts } = await useAccounts();
const idToAccount = computed(() => new Map(accounts.value?.map(a => [a.id, a])));
function formatTime(time: string) {
return DateTime.fromISO(time, { zone: accountStore.activeTimezone, locale: "en-US" }).toFormat("yyyy-LL-dd HH:mm");
function formatTime(time: DateTime) {
return time.toFormat("yyyy-LL-dd HH:mm");
}
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {

View file

@ -1,8 +1,5 @@
<template>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<div>
<table>
<thead>
<tr>
@ -17,9 +14,9 @@
<tbody>
<template v-if="edit">
<tr
v-for="event in events.filter(e => !e.deleted)"
v-for="event in schedule.events.values()"
:key="event.id"
:class="{ removed: removed.has(event.id) }"
:class="{ removed: event.deleted }"
>
<td>{{ event.id }}</td>
<td>
@ -47,22 +44,22 @@
@change="editEvent(event, { crew: !($event as any).target.value })"
>
</td>
<td>{{ event.slots.length ? event.slots.length : "" }}</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
<td>
<button
type="button"
:disabled="!canEdit(event) || removed.has(event.id)"
@click="delEvent(event.id)"
:disabled="!canEdit(event) || event.deleted"
@click="editEvent(event, { deleted: true })"
>Delete</button>
<button
v-if="changes.some(c => c.id === event.id)"
v-if="schedule.isModifiedEvent(event.id)"
type="button"
@click="revertEvent(event.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ toId(newEventName) }}</td>
<td>{{ schedule.nextClientId }}</td>
<td>
<input
type="text"
@ -98,40 +95,25 @@
</template>
<template v-else>
<tr
v-for="event in events.filter(e => !e.deleted)"
v-for="event in schedule.events.values()"
:key="event.id"
>
<td>{{ event.id }}</td>
<td>{{ event.name }}</td>
<td>{{ event.description }}</td>
<td>{{ event.crew ? "" : "Yes"}}</td>
<td>{{ event.slots.length ? event.slots.length : "" }}</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
</tr>
</template>
</tbody>
</table>
<p v-if="changes.length">
Changes are not saved yet.
<button
type="button"
@click="saveEvents"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<ol>
<li v-for="change in changes">
{{ JSON.stringify(change) }}
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import type { ApiSchedule, ApiScheduleEvent } from '~/shared/types/api';
import type { Id } from '~/shared/types/common';
import { DateTime } from '~/shared/utils/luxon';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
defineProps<{
edit?: boolean,
@ -140,64 +122,26 @@ defineProps<{
const schedule = await useSchedule();
const accountStore = useAccountStore();
function canEdit(event: ApiScheduleEvent) {
return !event.deleted && (event.crew || accountStore.canEditPublic);
}
const changes = ref<ApiScheduleEvent[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ApiScheduleEvent,
changes: ApiScheduleEvent[],
) {
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(id: number, changes: ApiScheduleEvent[]) {
return changes.filter(change => change.id !== id);
function canEdit(event: ClientScheduleEvent) {
return event.crew || accountStore.canEditPublic;
}
const newEventName = ref("");
const newEventDescription = ref("");
const newEventPublic = ref(false);
function editEvent(
event: Extract<ApiScheduleEvent, { deleted?: false }>,
edits: { name?: string, description?: string, crew?: boolean }
event: ClientScheduleEvent,
edits: Parameters<ClientSchedule["editEvent"]>[1],
) {
const copy = { ...event };
if (edits.name !== undefined) {
copy.name = edits.name;
}
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
if (edits.crew !== undefined) {
copy.crew = edits.crew || undefined;
}
changes.value = replaceChange(copy, changes.value);
schedule.value.editEvent(event, edits);
}
function delEvent(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function revertEvent(id: number) {
changes.value = revertChange(id, changes.value);
function revertEvent(id: Id) {
schedule.value.restoreEvent(id);
}
function eventExists(name: string) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.events?.some(e => !e.deleted && toId(e.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
[...schedule.value.events.values()].some(e => !e.deleted && toId(e.name) === name)
);
}
function newEvent() {
@ -205,48 +149,23 @@ 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 = {
id,
updatedAt: "",
name: newEventName.value,
description: newEventDescription.value || undefined,
crew: !newEventPublic.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
const event = new ClientScheduleEvent(
schedule.value.nextClientId--,
DateTime.now(),
false,
newEventName.value,
!newEventPublic.value,
"",
false,
newEventDescription.value,
0,
new Map(),
);
schedule.value.setEvent(event);
newEventName.value = "";
newEventDescription.value = "";
newEventPublic.value = false;
}
async function saveEvents() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
events: changes.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
const events = computed(() => {
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>
<style scoped>

View file

@ -11,9 +11,9 @@
</thead>
<tbody>
<tr
v-for="location in locations.filter(l => !l.deleted)"
v-for="location in schedule.locations.values()"
:key="location.id"
:class="{ removed: removed.has(location.id) }"
:class="{ removed: location.deleted }"
>
<template v-if='edit'>
<td>{{ location.id }}</td>
@ -21,24 +21,24 @@
<input
type="text"
:value="location.name"
@input="setLocation({ ...location, name: ($event as any).target.value })"
@input="editLocation(location, { name: ($event as any).target.value })"
>
</td>
<td>
<input
type="text"
:value="location.description"
@input="setLocation({ ...location, description: ($event as any).target.value || undefined })"
@input="editLocation(location, { description: ($event as any).target.value })"
>
</td>
<td>
<button
:disabled="removed.has(location.id)"
:disabled="location.deleted"
type="button"
@click="delLocation(location.id)"
@click="editLocation(location, { deleted: true })"
>Remove</button>
<button
v-if="changes.some(c => c.id === location.id)"
v-if="schedule.isModifiedLocation(location.id)"
type="button"
@click="revertLocation(location.id)"
>Revert</button>
@ -52,7 +52,7 @@
</tr>
<tr v-if='edit'>
<td>
{{ newLocationId }}
{{ schedule.nextClientId }}
</td>
<td>
<input
@ -60,37 +60,27 @@
v-model="newLocationName"
>
</td>
<td colspan="2">
<td>
<input
type="text"
v-model="newLocationDescription"
>
</td>
<td colspan="1">
<button
type="button"
@click="newLocation(newLocationName); newLocationName = ''"
@click="newLocation()"
>Add Location</button>
</td>
</tr>
</tbody>
</table>
<p v-if="changes.length">
Changes are not save yet.
<button
type="button"
@click="saveLocations"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<ol>
<li v-for="change in changes">
{{ JSON.stringify(change) }}
</li>
</ol>
</details>
</figure>
</template>
<script lang="ts" setup>
import type { ApiSchedule, ApiScheduleLocation } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { DateTime } from '~/shared/utils/luxon';
import type { Id } from '~/shared/types/common';
defineProps<{
edit?: boolean
@ -98,81 +88,34 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ApiScheduleLocation[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ApiScheduleLocation,
changes: ApiScheduleLocation[],
) {
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(id: number, changes: ApiScheduleLocation[]) {
return changes.filter(change => change.id !== id);
}
const newLocationName = ref("");
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 revertLocation(id: number) {
changes.value = revertChange(id, changes.value);
}
function newLocation(name: string) {
const change = {
id: newLocationId.value,
updatedAt: "",
name,
};
changes.value = replaceChange(change, changes.value);
}
async function saveLocations() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
locations: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
const newLocationDescription = ref("");
const locations = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
function editLocation(
location: ClientScheduleLocation,
edits: Parameters<ClientSchedule["editLocation"]>[1],
) {
try {
schedule.value.editLocation(location, edits);
} catch (err: any) {
alert(err.message);
}
const data = [...schedule.value.locations ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
}
function revertLocation(id: Id) {
schedule.value.restoreLocation(id);
}
function newLocation() {
const location = new ClientScheduleLocation(
schedule.value.nextClientId--,
DateTime.now(),
false,
newLocationName.value,
newLocationDescription.value,
);
schedule.value.setLocation(location);
newLocationName.value = "";
newLocationDescription.value = "";
}
</script>
<style scoped>

View file

@ -1,8 +1,5 @@
<template>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<div>
<table>
<thead>
<tr>
@ -15,9 +12,9 @@
<tbody>
<template v-if="edit">
<tr
v-for="role in roles.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
:class="{ removed: removed.has(role.id) }"
:class="{ removed: role.deleted }"
>
<td>{{ role.id }}</td>
<td>
@ -37,18 +34,18 @@
<td>
<button
type="button"
:disabled="removed.has(role.id)"
@click="delRole(role.id)"
:disabled="role.deleted"
@click="editRole(role, { deleted: true })"
>Delete</button>
<button
v-if="changes.some(c => c.id === role.id)"
v-if="schedule.isModifiedRole(role.id)"
type="button"
@click="revertRole(role.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ newRoleId }}</td>
<td>{{ schedule.nextClientId }}</td>
<td>
<input
type="text"
@ -76,7 +73,7 @@
</template>
<template v-else>
<tr
v-for="role in roles.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
>
<td>{{ role.id }}</td>
@ -86,28 +83,13 @@
</template>
</tbody>
</table>
<p v-if="changes.length">
Changes are not saved yet.
<button
type="button"
@click="saveRoles"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<ol>
<li v-for="change in changes">
{{ JSON.stringify(change) }}
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import type { ApiSchedule, ApiScheduleRole } from '~/shared/types/api';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { DateTime } from '~/shared/utils/luxon';
import { toId } from '~/shared/utils/functions';
import type { Id } from '~/shared/types/common';
defineProps<{
edit?: boolean,
@ -115,66 +97,30 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ApiScheduleRole[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ApiScheduleRole,
changes: ApiScheduleRole[],
) {
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(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: Extract<ApiScheduleRole, { deleted?: false }>,
edits: { name?: string, description?: string }
role: ClientScheduleRole,
edits: { deleted?: boolean, name?: string, description?: string }
) {
const copy = { ...role };
if (edits.name !== undefined) {
copy.name = edits.name;
const copy = role.clone();
if (edits.deleted !== undefined) copy.deleted = edits.deleted;
if (edits.name !== undefined) copy.name = edits.name;
if (edits.description !== undefined) copy.description = edits.description;
try {
schedule.value.setRole(copy);
} catch (err: any) {
alert(err.message);
}
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
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 revertRole(id: number) {
changes.value = revertChange(id, changes.value);
function revertRole(id: Id) {
schedule.value.restoreRole(id);
}
function roleExists(name: string) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.roles?.some(r => !r.deleted && toId(r.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
[...schedule.value.roles.values()].some(r => !r.deleted && toId(r.name) === name)
);
}
function newRole() {
@ -182,45 +128,18 @@ function newRole() {
alert(`Role ${newRoleName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const change = {
id: newRoleId.value,
updatedAt: "",
name: newRoleName.value,
description: newRoleDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
const role = new ClientScheduleRole(
schedule.value.nextClientId--,
DateTime.now(),
false,
newRoleName.value,
newRoleDescription.value,
);
schedule.value.setRole(role);
newRoleName.value = "";
newRoleDescription.value = "";
}
async function saveRoles() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
roles: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
const roles = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.roles ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>
<style scoped>

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,
}]);
}

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="ss in shiftSlots"
:key='ss.slot?.id ?? ss.start.toMillis()'
:class='{
removed: ss.type === "slot" && removed.has(ss.id),
removed: ss.slot?.deleted || ss.shift?.deleted,
gap: ss.type === "gap",
}'
>
@ -39,21 +36,22 @@
>
</td>
<td>
<input
type="text"
v-model="newShiftName"
>
<input
type="text"
v-model="newShiftName"
>
</td>
<td></td>
<td>
<select
v-model="newShiftRole"
v-model="newShiftRoleId"
>
<option
v-for="role in schedule.roles?.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
:disabled="role.deleted"
:selected="role.id === newShiftRoleId"
>{{ role.name }}</option>
</select>
</td>
@ -96,20 +94,21 @@
<input
type="text"
:value="ss.name"
@input="editShiftSlot(ss, { name: ($event as any).target.value })"
@input="editShift(ss, { name: ($event as any).target.value })"
>
</td>
<td>{{ status(ss) }}</td>
<td>
<select
:value="ss.roleId"
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
:value="ss.role.id"
@change="editShift(ss, { role: schedule.roles.get(parseInt(($event as any).target.value)) })"
>
<option
v-for="role in schedule.roles?.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:selected="role.id === ss.roleId"
:disabled="role.deleted"
:selected="role.id === ss.role.id"
>{{ role.name }}</option>
</select>
</td>
@ -122,12 +121,12 @@
</td>
<td>
<button
:disabled="removed.has(ss.id)"
:disabled="ss.deleted"
type="button"
@click="delShiftSlot(ss)"
@click="editShiftSlot(ss, { deleted: true })"
>Remove</button>
<button
v-if="changes.some(c => c.id === ss.id)"
v-if="schedule.isModifiedShiftSlot(ss.slot.id)"
type="button"
@click="revertShiftSlot(ss.id)"
>Revert</button>
@ -187,63 +186,37 @@
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td>
<td>{{ ss.roleId }}</td>
<td>{{ ss.role.id }}</td>
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></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 }) => 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 }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
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';
import { DateTime, Duration } from '~/shared/utils/luxon';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { Id } from '~/shared/types/common';
const props = defineProps<{
edit?: boolean,
roleId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
roleId?: Id,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
}>();
interface ShiftSlot {
type: "slot",
id: number,
updatedAt: string,
deleted?: boolean,
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
slot?: ApiScheduleShiftSlot,
origRole: number,
id: Id,
deleted: boolean,
shift: ClientScheduleShift,
slot: ClientScheduleShiftSlot,
name: string,
roleId: number,
assigned: number[],
role: ClientScheduleRole,
assigned: Set<Id>,
start: DateTime,
end: DateTime,
}
@ -254,191 +227,25 @@ interface Gap {
shift?: undefined,
slot?: undefined,
name?: undefined,
roleId?: number,
role?: undefined,
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.shifts?.find(shift => !shift.deleted && shift.name === shiftSlot.name);
const shift = [...schedule.value.shifts.values()].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 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: ApiScheduleShift[],
schedule: ApiSchedule,
) {
if (schedule.deleted) {
throw new Error("Unexpected deleted schedule");
}
let setShift = changes.filter(
c => !c.deleted,
).find(
c => (
c.name === shiftSlot.name
&& c.roleId === shiftSlot.roleId
)
);
if (
!setShift
&& shiftSlot.shift
&& shiftSlot.shift.name === shiftSlot.name
) {
setShift = shiftSlot.shift;
}
if (!setShift) {
setShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.name);
}
let delShift;
if (shiftSlot.shift) {
delShift = changes.filter(c => !c.deleted).find(
c => c.name === shiftSlot.shift!.name
);
if (!delShift) {
delShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.shift!.name);
}
}
return { setShift, delShift };
}
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(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.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
if (oldSlot && oldSlot.id === shiftSlot.id) {
return {
...shift,
slots: shift.slots.map(s => s.id !== oldSlot.id ? s : { ...s, assigned, 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 : nextId,
assigned,
start,
end,
}],
};
}
const shiftChanges = computed(() => {
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
for (const change of filterSetOps(changes.value)) {
if (!change.deleted) {
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
if (delShift && delShift !== setShift) {
eventChanges = removeSlot(eventChanges, delShift, change);
}
if (!setShift) {
setShift = {
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
roleId: change.roleId,
slots: [],
};
}
setShift = {
...setShift,
roleId: change.roleId,
}
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
} else if (change.deleted) {
let { delShift } = findShift(change, eventChanges, schedule.value);
if (delShift) {
eventChanges = removeSlot(eventChanges, delShift, change);
}
}
}
return eventChanges;
});
const schedulePreview = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const shifts = [...schedule.value.shifts ?? []]
applyUpdatesToArray(shiftChanges.value, shifts);
return {
...schedule.value,
shifts,
};
});
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({
...shift,
slots: shift.slots.filter(s => s.id !== oldSlot.id)
}, eventChanges);
}
return eventChanges;
return shiftSlot.shift.slots.size === 1 ? "" : shiftSlot.shift.slots.size;
}
const accountStore = useAccountStore();
const schedule = await useSchedule();
const changes = ref<ShiftSlot[]>([]);
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) {
@ -450,113 +257,88 @@ function dropDay(diff: Duration) {
const newShiftStart = ref("");
const newShiftDuration = ref("01:00");
const newShiftEnd = computed({
get: () => (
DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" })
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: "en-US" }))
.toFormat("HH:mm")
),
get: () => {
try {
return DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: accountStore.activeLocale }))
.toFormat("HH:mm")
} catch (err) {
return "";
}
},
set: (value: string) => {
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
const end = endFromTime(start, value);
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newShiftRole = ref(props.roleId);
const newShiftRoleId = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
newShiftRoleId.value = props.roleId;
});
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 newShiftName = ref("");
function editShift(
shiftSlot: ShiftSlot,
edits: Parameters<ClientSchedule["editShift"]>[1],
) {
schedule.value.editShift(shiftSlot.shift, edits);
}
function editShiftSlot(
shiftSlot: ShiftSlot,
edits: {
deleted?: boolean,
start?: string,
end?: string,
duration?: string,
name?: string,
roleId?: number,
assigned?: number[],
assigned?: Set<Id>,
}
) {
const computedEdits: Parameters<ClientSchedule["editShiftSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: "en-US" });
shiftSlot = {
...shiftSlot,
start,
end: start.plus(shiftSlot.end.diff(shiftSlot.start)),
};
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start;
computedEdits.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
}
if (edits.end !== undefined) {
shiftSlot = {
...shiftSlot,
end: endFromTime(shiftSlot.start, edits.end),
};
computedEdits.end = endFromTime(shiftSlot.start, edits.end);
}
if (edits.duration !== undefined) {
shiftSlot = {
...shiftSlot,
end: shiftSlot.start.plus(durationFromTime(edits.duration)),
};
computedEdits.end = shiftSlot.start.plus(durationFromTime(edits.duration));
}
if (edits.name !== undefined) {
shiftSlot = {
...shiftSlot,
name: edits.name,
};
}
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({
...slot,
roleId: edits.roleId,
}, changesCopy);
}
}
changesCopy = replaceChange({
...shiftSlot,
roleId: edits.roleId,
}, changesCopy);
changes.value = changesCopy;
return;
}
if (edits.assigned !== undefined) {
shiftSlot = {
...shiftSlot,
assigned: edits.assigned,
};
}
changes.value = replaceChange(shiftSlot, changes.value);
schedule.value.editShiftSlot(shiftSlot.slot, computedEdits);
}
function delShiftSlot(shiftSlot: ShiftSlot) {
const change = {
...shiftSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertShiftSlot(id: number) {
changes.value = revertChange(id, changes.value);
function revertShiftSlot(id: Id) {
schedule.value.restoreShiftSlot(id);
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const roleId = newShiftRole.value;
if (!roleId) {
const nameId = toId(name);
const shift = [...schedule.value.shifts.values()].find(shift => toId(shift.name) === nameId);
if (!shift) {
alert("Invalid shift");
return;
}
const role = schedule.value.roles.get(newShiftRoleId.value!);
if (!role) {
alert("Invalid role");
return;
}
@ -574,42 +356,23 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
end = options.end;
start = options.end.minus(duration);
} else {
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
end = endFromTime(start, newShiftEnd.value);
}
if (!start.isValid || !end.isValid) {
alert("Invalid start and/or end time");
return;
}
const change: ShiftSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origRole: roleId,
roleId,
assigned: [],
const slot = new ClientScheduleShiftSlot(
schedule.value.nextClientId--,
false,
shift.id,
start,
end,
};
new Set(),
);
schedule.value.setShiftSlot(slot);
newShiftName.value = "";
changes.value = replaceChange(change, changes.value);
}
async function saveShiftSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
shifts: shiftChanges.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
const oneHourMs = 60 * 60 * 1000;
@ -623,33 +386,29 @@ 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.shifts ?? []) {
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
for (const shift of schedule.value.shifts.values()) {
if (props.roleId !== undefined && shift.role.id !== props.roleId)
continue;
for (const slot of shift.slots) {
for (const slot of shift.slots.values()) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
continue;
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
deleted: slot.deleted || shift.deleted,
shift,
slot,
name: shift.name,
roleId: shift.roleId,
assigned: slot.assigned ?? [],
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" }),
role: shift.role,
assigned: slot.assigned,
start: slot.start,
end: slot.end,
});
}
}
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());
const byTime = (a: DateTime, b: DateTime) => a.toMillis() - b.toMillis();
data.sort((a, b) => byTime(a.start, b.start) || byTime(a.end, b.end));
// Insert gaps
let maxEnd = 0;
@ -659,8 +418,7 @@ const shiftSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
roleId: props.roleId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
end: second.start,
}]);
}

View file

@ -1,8 +1,5 @@
<template>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<div>
<table>
<thead>
<tr>
@ -17,9 +14,9 @@
<tbody>
<template v-if="edit">
<tr
v-for="shift in shifts?.filter(s => !s.deleted)"
v-for="shift in schedule.shifts.values()"
:key="shift.id"
:class="{ removed: removed.has(shift.id) }"
:class="{ removed: shift.deleted }"
>
<td>{{ shift.id }}</td>
<td>
@ -31,18 +28,19 @@
</td>
<td>
<select
:value="shift.roleId"
@change="editShift(shift, { roleId: ($event as any).target.value })"
:value="shift.role.id"
@change="editShift(shift, { role: schedule.roles.get(parseInt(($event as any).target.value, 10)) })"
>
<option
v-for="role in schedule.roles?.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:selected="shift.roleId === role.id"
:disabled="shift.deleted"
:selected="shift.role.id === role.id"
>{{ role.name }}</option>
</select>
</td>
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>
<input
type="text"
@ -53,18 +51,18 @@
<td>
<button
type="button"
:disabled="removed.has(shift.id)"
@click="delShift(shift.id)"
:disabled="shift.deleted"
@click="editShift(shift, { deleted: true })"
>Delete</button>
<button
v-if="changes.some(c => c.id === shift.id)"
v-if="schedule.isModifiedShift(shift.id)"
type="button"
@click="revertShift(shift.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ newShiftId }}</td>
<td>{{ schedule.nextClientId }}</td>
<td>
<input
type="text"
@ -72,12 +70,13 @@
>
</td>
<td>
<select v-model="newShiftRole">
<select v-model="newShiftRoleId">
<option
v-for="role in schedule.roles?.filter(r => !r.deleted)"
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
:disabled="role.deleted"
:selected="role.id === newShiftRoleId"
>{{ role.name }}</option>
</select>
</td>
@ -103,40 +102,26 @@
</template>
<template v-else>
<tr
v-for="shift in shifts?.filter(s => !s.deleted)"
v-for="shift in schedule.shifts.values()"
:key="shift.id"
>
<td>{{ shift.id }}</td>
<td>{{ shift.name }}</td>
<td>{{ shift.roleId }}</td>
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
<td>{{ shift.role.id }}</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>{{ shift.description }}</td>
</tr>
</template>
</tbody>
</table>
<p v-if="changes.length">
Changes are not saved yet.
<button
type="button"
@click="saveShifts"
>Save Changes</button>
</p>
<details>
<summary>Debug</summary>
<ol>
<li v-for="change in changes">
<pre><code>{{ JSON.stringify(change, undefined, 2) }}</code></pre>
</li>
</ol>
</details>
</div>
</template>
<script lang="ts" setup>
import { DateTime } from 'luxon';
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
import type { Id } from '~/shared/types/common';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
const props = defineProps<{
edit?: boolean,
@ -145,73 +130,25 @@ const props = defineProps<{
const schedule = await useSchedule();
const changes = ref<ApiScheduleShift[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ApiScheduleShift,
changes: ApiScheduleShift[],
) {
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(id: number, changes: ApiScheduleShift[]) {
return changes.filter(change => change.id !== id);
}
const newShiftName = ref("");
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);
const newShiftRoleId = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
newShiftRoleId.value = props.roleId;
});
const newShiftDescription = ref("");
function editShift(
shift: Extract<ApiScheduleShift, { deleted?: false }>,
edits: { name?: string, description?: string, roleId?: number }
shift: ClientScheduleShift,
edits: Parameters<ClientSchedule["editShift"]>[1],
) {
const copy = { ...shift };
if (edits.name !== undefined) {
copy.name = edits.name;
}
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
if (edits.roleId !== undefined) {
copy.roleId = edits.roleId;
}
changes.value = replaceChange(copy, changes.value);
schedule.value.editShift(shift, edits);
}
function delShift(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function revertShift(id: number) {
changes.value = revertChange(id, changes.value);
function revertShift(id: Id) {
schedule.value.restoreShift(id);
}
function shiftExists(name: string) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.shifts?.some(s => !s.deleted && toId(s.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
[...schedule.value.shifts.values()].some(s => !s.deleted && toId(s.name) === name)
);
}
function newShift() {
@ -219,50 +156,24 @@ function newShift() {
alert(`Shift ${newShiftName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (!newShiftRole.value) {
const role = schedule.value.roles.get(newShiftRoleId.value!);
if (!role) {
alert(`Invalid role`);
return;
}
const change = {
id: newShiftId.value,
updatedAt: "",
name: newShiftName.value,
roleId: newShiftRole.value,
description: newShiftDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
const shift = new ClientScheduleShift(
schedule.value.nextClientId--,
DateTime.now(),
false,
role,
newShiftName.value,
newShiftDescription.value,
new Map(),
);
schedule.value.setShift(shift);
newShiftName.value = "";
newShiftDescription.value = "";
}
async function saveShifts() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: {
id: 111,
updatedAt: "",
shifts: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
const shifts = computed(() => {
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>
<style scoped>

View file

@ -1,10 +1,5 @@
<template>
<figure class="timetable" v-if="schedule.deleted">
<p>
Error: Schedule is deleted.
</p>
</figure>
<figure class="timetable" v-else>
<figure class="timetable">
<details>
<summary>Debug</summary>
<details>
@ -61,7 +56,7 @@
</tr>
</thead>
<tbody>
<template v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
<template v-for="location in schedule.locations.values()" :key="location.id">
<tr v-if="locationRows.has(location.id)">
<th>{{ location.name }}</th>
<td
@ -75,12 +70,12 @@
</td>
</tr>
</template>
<template v-if="schedule.roles">
<template v-if="schedule.roles.size">
<tr>
<th>Shifts</th>
<td :colSpan="totalColumns"></td>
</tr>
<template v-for="role in schedule.roles?.filter(r => !r.deleted)" :key="role.id">
<template v-for="role in schedule.roles.values()" :key="role.id">
<tr v-if="roleRows.has(role.id)">
<th>{{ role.name }}</th>
<td
@ -102,7 +97,6 @@
<script setup lang="ts">
import { DateTime } from "luxon";
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";
@ -113,8 +107,8 @@ const oneMinMs = 60 * 1000;
/** Point in time where a time slots starts or ends. */
type Edge =
| { type: "start" | "end", source: "event", slot: ApiScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ClientScheduleShiftSlot }
;
/** Point in time where multiple edges meet. */
@ -124,8 +118,8 @@ type Junction = { ts: number, edges: Edge[] };
type Span = {
start: Junction;
end: Junction,
locations: Map<number, Set<ApiScheduleEventSlot>>,
roles: Map<number, Set<ApiScheduleShiftSlot>>,
locations: Map<number, Set<ClientScheduleEventSlot>>,
roles: Map<number, Set<ClientScheduleShiftSlot>>,
};
/**
@ -140,11 +134,15 @@ type Stretch = {
}
function* edgesFromEvents(
events: Iterable<Extract<ApiScheduleEvent, { deleted?: false }>>,
filter = (slot: ApiScheduleEventSlot) => true,
events: Iterable<ClientScheduleEvent>,
filter = (slot: ClientScheduleEventSlot) => true,
): Generator<Edge> {
for (const event of events) {
for (const slot of event.slots.filter(filter)) {
if (event.deleted)
continue;
for (const slot of event.slots.values()) {
if (!filter(slot) || slot.deleted)
continue;
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
@ -155,16 +153,20 @@ function* edgesFromEvents(
}
function* edgesFromShifts(
shifts: Iterable<Extract<ApiScheduleShift, { deleted?: false }>>,
filter = (slot: ApiScheduleShiftSlot) => true,
shifts: Iterable<ClientScheduleShift>,
filter = (slot: ClientScheduleShiftSlot) => true,
): Generator<Edge> {
for (const shift of shifts) {
for (const slot of shift.slots.filter(filter)) {
if (shift.deleted)
continue;
for (const slot of shift.slots.values()) {
if (!filter(slot) || slot.deleted)
continue;
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
yield { type: "start", source: "shift", roleId: shift.role.id, slot };
yield { type: "end", source: "shift", roleId: shift.role.id, slot };
}
}
}
@ -172,7 +174,7 @@ function* edgesFromShifts(
function junctionsFromEdges(edges: Iterable<Edge>) {
const junctions = new Map<number, Junction>();
for (const edge of edges) {
const ts = Date.parse(edge.slot[edge.type]);
const ts = edge.slot[edge.type].toMillis();
const junction = junctions.get(ts);
if (junction) {
junction.edges.push(edge);
@ -186,21 +188,21 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
function* spansFromJunctions(
junctions: Iterable<Junction>,
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
locations: Map<Id, ClientScheduleLocation>,
roles: Map<Id, ClientScheduleRole>,
): Generator<Span> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
);
const activeRoles = new Map(
roles?.map(role => [role.id, new Set<ApiScheduleShiftSlot>()])
[...roles.keys()].map(id => [id, new Set<ClientScheduleShiftSlot>()])
);
for (const [start, end] of pairs(junctions)) {
for (const edge of start.edges) {
if (edge.type === "start") {
if (edge.source === "event") {
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.add(edge.slot)
for (const location of edge.slot.locations) {
activeLocations.get(location.id)?.add(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.add(edge.slot)
@ -224,8 +226,8 @@ function* spansFromJunctions(
for (const edge of end.edges) {
if (edge.type === "end") {
if (edge.source === "event") {
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.delete(edge.slot)
for (const location of edge.slot.locations) {
activeLocations.get(location.id)?.delete(edge.slot)
}
} else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.delete(edge.slot);
@ -265,7 +267,7 @@ function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Gene
/** Cuts up a span by whole hours that crosses it */
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: "en-US" })
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: accountStore.activeLocale })
.startOf("hour")
;
const end = span.end.ts;
@ -308,11 +310,11 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
function padStretch(stretch: Stretch, timezone: string): Stretch {
// Pad by one hour and extend it to the nearest whole hour.
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: "en-US" })
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale })
.minus(oneHourMs)
.startOf("hour")
;
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: "en-US" })
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
.plus(2 * oneHourMs - 1)
.startOf("hour")
;
@ -339,24 +341,24 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
function tableElementsFromStretches(
stretches: Iterable<Stretch>,
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
events: Map<Id, ClientScheduleEvent>,
locations: Map<Id, ClientScheduleLocation>,
shifts: Map<Id, ClientScheduleShift>,
roles: Map<Id, ClientScheduleRole>,
timezone: string,
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, title: string };
type LocationCell = { span: number, slots: Set<ClientScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ClientScheduleShiftSlot>, title: string };
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
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(shifts?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
const locationRows = new Map<number, LocationCell[]>([...locations.keys()].map(id => [id, []]));
const roleRows = new Map<number, RoleCell[]>([...roles.keys()].map(id => [id, []]));
const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift])));
let totalColumns = 0;
function startColumnGroup(className?: string) {
@ -368,7 +370,7 @@ function tableElementsFromStretches(
function startHour(content?: string) {
hourHeaders.push({ span: 0, content })
}
function startLocation(id: number, slots = new Set<ApiScheduleEventSlot>()) {
function startLocation(id: number, slots = new Set<ClientScheduleEventSlot>()) {
const rows = locationRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -377,7 +379,7 @@ function tableElementsFromStretches(
}
rows.push({ span: 0, slots, title: "" });
}
function startRole(id: number, slots = new Set<ApiScheduleShiftSlot>()) {
function startRole(id: number, slots = new Set<ClientScheduleShiftSlot>()) {
const rows = roleRows.get(id)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -390,11 +392,11 @@ function tableElementsFromStretches(
columnGroups[columnGroups.length - 1].cols.push({ minutes })
dayHeaders[dayHeaders.length - 1].span += 1;
hourHeaders[hourHeaders.length - 1].span += 1;
for(const location of locations) {
for(const location of locations.values()) {
const row = locationRows.get(location.id)!;
row[row.length - 1].span += 1;
}
for(const role of roles ?? []) {
for(const role of roles.values()) {
const row = roleRows.get(role.id)!;
row[row.length - 1].span += 1;
}
@ -403,16 +405,16 @@ function tableElementsFromStretches(
let first = true;
for (let stretch of stretches) {
stretch = padStretch(stretch, timezone);
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: "en-US" });
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale });
if (first) {
first = false;
startColumnGroup();
startDay(startDate.toFormat("yyyy-LL-dd"));
startHour(startDate.toFormat("HH:mm"));
for(const location of locations) {
for(const location of locations.values()) {
startLocation(location.id);
}
for(const role of roles ?? []) {
for(const role of roles.values()) {
startRole(role.id);
}
} else {
@ -423,10 +425,10 @@ function tableElementsFromStretches(
if (!sameDay)
startDay();
startHour("break");
for(const location of locations) {
for(const location of locations.values()) {
startLocation(location.id);
}
for(const role of roles ?? []) {
for(const role of roles.values()) {
startRole(role.id);
}
pushColumn();
@ -435,10 +437,10 @@ function tableElementsFromStretches(
if (!sameDay)
startDay(dayName);
startHour(startDate.toFormat("HH:mm"));
for(const location of locations) {
for(const location of locations.values()) {
startLocation(location.id);
}
for(const role of roles ?? []) {
for(const role of roles.values()) {
startRole(role.id);
}
}
@ -448,7 +450,7 @@ function tableElementsFromStretches(
const end = cutSpan.end.ts;
const durationMs = end - cutSpan.start.ts;
for (const location of locations) {
for (const location of locations.values()) {
const rows = locationRows.get(location.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.locations.get(location.id) ?? new Set();
@ -456,7 +458,7 @@ function tableElementsFromStretches(
startLocation(location.id, slots);
}
}
for (const role of roles ?? []) {
for (const role of roles.values()) {
const rows = roleRows.get(role.id)!;
const row = rows[rows.length - 1];
const slots = cutSpan.roles.get(role.id) ?? new Set();
@ -466,16 +468,16 @@ function tableElementsFromStretches(
}
pushColumn(durationMs / oneMinMs);
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: "en-US" });
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: accountStore.activeLocale });
if (end === endDate.startOf("day").toMillis()) {
startDay(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
.toFormat("yyyy-LL-dd")
);
}
if (end === endDate.startOf("hour").toMillis()) {
startHour(
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
.toFormat("HH:mm")
);
}
@ -501,30 +503,24 @@ function tableElementsFromStretches(
}
const props = defineProps<{
schedule: ApiSchedule,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
schedule: ClientSchedule,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
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),
...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
...edgesFromShifts(schedule.value.shifts.values(), 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) ?? [],
schedule.value.locations,
schedule.value.roles,
),
oneHourMs * 5
)
@ -538,15 +534,12 @@ const timezone = computed({
});
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) ?? [],
schedule.value.events,
schedule.value.locations,
schedule.value.shifts,
schedule.value.roles,
accountStore.activeTimezone
);
});