Refactor API types and sync logic
Rename and refactor the types passed over the API to be based on an entity that's either living or a tombstone. A living entity has a deleted property that's either undefined or false, while a tombstone has a deleted property set to true. All entities have a numeric id and an updatedAt timestamp. To sync entities, an array of replacements are passed around. Living entities are replaced with tombstones when they're deleted. And tombstones are replaced with living entities when restored.
This commit is contained in:
parent
251e83f640
commit
fe06d0d6bd
36 changed files with 1242 additions and 834 deletions
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="event">
|
<section class="event" v-if="event.deleted">
|
||||||
|
<p>
|
||||||
|
Error: Unexpected deleted event.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="event" v-else>
|
||||||
<h3>{{ event.name }}</h3>
|
<h3>{{ event.name }}</h3>
|
||||||
<p>{{ event.description ?? "No description provided" }}</p>
|
<p>{{ event.description ?? "No description provided" }}</p>
|
||||||
<p v-if="event.interested">
|
<p v-if="event.interested">
|
||||||
|
@ -8,10 +13,10 @@
|
||||||
<p v-if="accountStore.valid">
|
<p v-if="accountStore.valid">
|
||||||
<button
|
<button
|
||||||
class="interested"
|
class="interested"
|
||||||
:class="{ active: accountStore.interestedIds.has(event.id) }"
|
:class="{ active: accountStore.interestedEventIds.has(event.id) }"
|
||||||
@click="toggle(event.id, event.slots.map(slot => slot.id))"
|
@click="toggle('event', event.id, event.slots.map(slot => slot.id))"
|
||||||
>
|
>
|
||||||
{{ accountStore.interestedIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
|
{{ accountStore.interestedEventIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -22,11 +27,11 @@
|
||||||
<button
|
<button
|
||||||
v-if="accountStore.valid && event.slots.length > 1"
|
v-if="accountStore.valid && event.slots.length > 1"
|
||||||
class="interested"
|
class="interested"
|
||||||
:disabled="accountStore.interestedIds.has(event.id)"
|
:disabled="accountStore.interestedEventIds.has(event.id)"
|
||||||
:class="{ active: accountStore.interestedIds.has(event.id) || accountStore.interestedIds.has(slot.id) }"
|
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) }"
|
||||||
@click="toggle(slot.id)"
|
@click="toggle('slot', slot.id)"
|
||||||
>
|
>
|
||||||
{{ accountStore.interestedIds.has(event.id) || accountStore.interestedIds.has(slot.id) ? "✔ interested" : "🔔 interested?" }}
|
{{ accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) ? "✔ interested" : "🔔 interested?" }}
|
||||||
</button>
|
</button>
|
||||||
<template v-if="slot.interested">
|
<template v-if="slot.interested">
|
||||||
({{ slot.interested }} interested)
|
({{ slot.interested }} interested)
|
||||||
|
@ -42,10 +47,10 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { ScheduleEvent } from '~/shared/types/schedule';
|
import type { ApiScheduleEvent } from '~/shared/types/api';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
event: ScheduleEvent
|
event: ApiScheduleEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
|
@ -56,8 +61,8 @@ function formatTime(time: string) {
|
||||||
return DateTime.fromISO(time, { zone: accountStore.activeTimezone, locale: "en-US" }).toFormat("yyyy-LL-dd HH:mm");
|
return DateTime.fromISO(time, { zone: accountStore.activeTimezone, locale: "en-US" }).toFormat("yyyy-LL-dd HH:mm");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(id: string, slotIds?: string[]) {
|
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
|
||||||
await accountStore.toggleInterestedId(id, slotIds);
|
await accountStore.toggleInterestedId(type, id, slotIds);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="schedule.deleted">
|
||||||
|
Error: Unexpected deleted schedule.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -14,7 +17,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="event in events"
|
v-for="event in events.filter(e => !e.deleted)"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
:class="{ removed: removed.has(event.id) }"
|
:class="{ removed: removed.has(event.id) }"
|
||||||
>
|
>
|
||||||
|
@ -52,7 +55,7 @@
|
||||||
@click="delEvent(event.id)"
|
@click="delEvent(event.id)"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === event.id)"
|
v-if="changes.some(c => c.id === event.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertEvent(event.id)"
|
@click="revertEvent(event.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -95,7 +98,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="event in events"
|
v-for="event in events.filter(e => !e.deleted)"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
>
|
>
|
||||||
<td>{{ event.id }}</td>
|
<td>{{ event.id }}</td>
|
||||||
|
@ -126,9 +129,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ChangeRecord, ScheduleEvent } from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleEvent } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
|
@ -137,18 +140,18 @@ defineProps<{
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
|
|
||||||
function canEdit(event: ScheduleEvent) {
|
function canEdit(event: ApiScheduleEvent) {
|
||||||
return event.crew || accountStore.canEditPublic;
|
return !event.deleted && (event.crew || accountStore.canEditPublic);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes = ref<ChangeRecord<ScheduleEvent>[]>([]);
|
const changes = ref<ApiScheduleEvent[]>([]);
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
function replaceChange(
|
function replaceChange(
|
||||||
change: ChangeRecord<ScheduleEvent>,
|
change: ApiScheduleEvent,
|
||||||
changes: ChangeRecord<ScheduleEvent>[],
|
changes: ApiScheduleEvent[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -157,15 +160,15 @@ function replaceChange(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange(id: string, changes: ChangeRecord<ScheduleEvent>[]) {
|
function revertChange(id: number, changes: ApiScheduleEvent[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEventName = ref("");
|
const newEventName = ref("");
|
||||||
const newEventDescription = ref("");
|
const newEventDescription = ref("");
|
||||||
const newEventPublic = ref(false);
|
const newEventPublic = ref(false);
|
||||||
function editEvent(
|
function editEvent(
|
||||||
event: ScheduleEvent,
|
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
||||||
edits: { name?: string, description?: string, crew?: boolean }
|
edits: { name?: string, description?: string, crew?: boolean }
|
||||||
) {
|
) {
|
||||||
const copy = { ...event };
|
const copy = { ...event };
|
||||||
|
@ -178,21 +181,23 @@ function editEvent(
|
||||||
if (edits.crew !== undefined) {
|
if (edits.crew !== undefined) {
|
||||||
copy.crew = edits.crew || undefined;
|
copy.crew = edits.crew || undefined;
|
||||||
}
|
}
|
||||||
const change = { op: "set" as const, data: copy };
|
changes.value = replaceChange(copy, changes.value);
|
||||||
|
}
|
||||||
|
function delEvent(id: number) {
|
||||||
|
const change = { id, updatedAt: "", deleted: true as const };
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function delEvent(id: string) {
|
function revertEvent(id: number) {
|
||||||
const change = { op: "del" as const, data: { id } };
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertEvent(id: string) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function eventExists(name: string) {
|
function eventExists(name: string) {
|
||||||
const id = toId(name);
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.events.some(e => e.id === id)
|
schedule.value.events?.some(e => !e.deleted && toId(e.name) === name)
|
||||||
|| changes.value.some(c => c.data.id === id)
|
|| changes.value.some(c => !c.deleted && c.name === name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newEvent() {
|
function newEvent() {
|
||||||
|
@ -200,15 +205,17 @@ function newEvent() {
|
||||||
alert(`Event ${newEventName.value} already exists`);
|
alert(`Event ${newEventName.value} already exists`);
|
||||||
return;
|
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 = {
|
const change = {
|
||||||
op: "set" as const,
|
id,
|
||||||
data: {
|
updatedAt: "",
|
||||||
id: toId(newEventName.value),
|
name: newEventName.value,
|
||||||
name: newEventName.value,
|
description: newEventDescription.value || undefined,
|
||||||
description: newEventDescription.value || undefined,
|
crew: !newEventPublic.value || undefined,
|
||||||
crew: !newEventPublic.value || undefined,
|
slots: [],
|
||||||
slots: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
newEventName.value = "";
|
newEventName.value = "";
|
||||||
|
@ -219,7 +226,11 @@ async function saveEvents() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { events: changes.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
events: changes.value,
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -229,8 +240,11 @@ async function saveEvents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = computed(() => {
|
const events = computed(() => {
|
||||||
const data = [...schedule.value.events];
|
if (schedule.value.deleted) {
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
const data = [...schedule.value.events ?? []];
|
||||||
|
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="location in locations"
|
v-for="location in locations.filter(l => !l.deleted)"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:class="{ removed: removed.has(location.id) }"
|
:class="{ removed: removed.has(location.id) }"
|
||||||
>
|
>
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
@click="delLocation(location.id)"
|
@click="delLocation(location.id)"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === location.id)"
|
v-if="changes.some(c => c.id === location.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertLocation(location.id)"
|
@click="revertLocation(location.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if='edit'>
|
<tr v-if='edit'>
|
||||||
<td>
|
<td>
|
||||||
{{ toId(newLocationName) }}
|
{{ newLocationId }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
@ -88,9 +88,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ChangeRecord, ScheduleLocation } from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleLocation } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
edit?: boolean
|
edit?: boolean
|
||||||
|
@ -98,15 +98,15 @@ defineProps<{
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ChangeRecord<ScheduleLocation>[]>([]);
|
const changes = ref<ApiScheduleLocation[]>([]);
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
|
|
||||||
function replaceChange(
|
function replaceChange(
|
||||||
change: ChangeRecord<ScheduleLocation>,
|
change: ApiScheduleLocation,
|
||||||
changes: ChangeRecord<ScheduleLocation>[],
|
changes: ApiScheduleLocation[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -115,29 +115,36 @@ function replaceChange(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange(id: string, changes: ChangeRecord<ScheduleLocation>[]) {
|
function revertChange(id: number, changes: ApiScheduleLocation[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLocationName = ref("");
|
const newLocationName = ref("");
|
||||||
function setLocation(location: ScheduleLocation) {
|
const newLocationId = computed(() => {
|
||||||
const change = { op: "set" as const, data: location };
|
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);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function delLocation(id: string) {
|
function revertLocation(id: number) {
|
||||||
const change = { op: "del" as const, data: { id } };
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertLocation(id: string) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function newLocation(name: string) {
|
function newLocation(name: string) {
|
||||||
const change = {
|
const change = {
|
||||||
op: "set" as const,
|
id: newLocationId.value,
|
||||||
data: {
|
updatedAt: "",
|
||||||
id: toId(name),
|
name,
|
||||||
name,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
|
@ -145,7 +152,11 @@ async function saveLocations() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { locations: changes.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
locations: changes.value
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -155,8 +166,11 @@ async function saveLocations() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const locations = computed(() => {
|
const locations = computed(() => {
|
||||||
const data = [...schedule.value.locations];
|
if (schedule.value.deleted) {
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
const data = [...schedule.value.locations ?? []];
|
||||||
|
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="schedule.deleted">
|
||||||
|
Error: Unexpected deleted schedule.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -12,7 +15,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="role in roles"
|
v-for="role in roles.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:class="{ removed: removed.has(role.id) }"
|
:class="{ removed: removed.has(role.id) }"
|
||||||
>
|
>
|
||||||
|
@ -38,14 +41,14 @@
|
||||||
@click="delRole(role.id)"
|
@click="delRole(role.id)"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === role.id)"
|
v-if="changes.some(c => c.id === role.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertRole(role.id)"
|
@click="revertRole(role.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ toId(newRoleName) }}</td>
|
<td>{{ newRoleId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -73,7 +76,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="role in roles"
|
v-for="role in roles.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
>
|
>
|
||||||
<td>{{ role.id }}</td>
|
<td>{{ role.id }}</td>
|
||||||
|
@ -102,8 +105,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ChangeRecord, Role } from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleRole } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -112,14 +115,14 @@ defineProps<{
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ChangeRecord<Role>[]>([]);
|
const changes = ref<ApiScheduleRole[]>([]);
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
function replaceChange(
|
function replaceChange(
|
||||||
change: ChangeRecord<Role>,
|
change: ApiScheduleRole,
|
||||||
changes: ChangeRecord<Role>[],
|
changes: ApiScheduleRole[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -128,14 +131,24 @@ function replaceChange(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange(id: string, changes: ChangeRecord<Role>[]) {
|
function revertChange(id: number, changes: ApiScheduleRole[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRoleName = ref("");
|
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("");
|
const newRoleDescription = ref("");
|
||||||
function editRole(
|
function editRole(
|
||||||
role: Role,
|
role: Extract<ApiScheduleRole, { deleted?: false }>,
|
||||||
edits: { name?: string, description?: string }
|
edits: { name?: string, description?: string }
|
||||||
) {
|
) {
|
||||||
const copy = { ...role };
|
const copy = { ...role };
|
||||||
|
@ -145,21 +158,23 @@ function editRole(
|
||||||
if (edits.description !== undefined) {
|
if (edits.description !== undefined) {
|
||||||
copy.description = edits.description || undefined;
|
copy.description = edits.description || undefined;
|
||||||
}
|
}
|
||||||
const change = { op: "set" as const, data: copy };
|
changes.value = replaceChange(copy, changes.value);
|
||||||
|
}
|
||||||
|
function delRole(id: number) {
|
||||||
|
const change = { id, updatedAt: "", deleted: true as const };
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function delRole(id: string) {
|
function revertRole(id: number) {
|
||||||
const change = { op: "del" as const, data: { id } };
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertRole(id: string) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function roleExists(name: string) {
|
function roleExists(name: string) {
|
||||||
const id = toId(name);
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.roles?.some(e => e.id === id)
|
schedule.value.roles?.some(r => !r.deleted && toId(r.name) === name)
|
||||||
|| changes.value.some(c => c.data.id === id)
|
|| changes.value.some(c => !c.deleted && c.name === name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newRole() {
|
function newRole() {
|
||||||
|
@ -167,14 +182,15 @@ function newRole() {
|
||||||
alert(`Role ${newRoleName.value} already exists`);
|
alert(`Role ${newRoleName.value} already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const change = {
|
const change = {
|
||||||
op: "set" as const,
|
id: newRoleId.value,
|
||||||
data: {
|
updatedAt: "",
|
||||||
id: toId(newRoleName.value),
|
name: newRoleName.value,
|
||||||
name: newRoleName.value,
|
description: newRoleDescription.value || undefined,
|
||||||
description: newRoleDescription.value || undefined,
|
slots: [],
|
||||||
slots: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
newRoleName.value = "";
|
newRoleName.value = "";
|
||||||
|
@ -184,7 +200,11 @@ async function saveRoles() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { roles: changes.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
roles: changes.value
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -194,8 +214,11 @@ async function saveRoles() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = computed(() => {
|
const roles = computed(() => {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const data = [...schedule.value.roles ?? []];
|
const data = [...schedule.value.roles ?? []];
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
|
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="schedule.deleted">
|
||||||
|
Error: Unexpected deleted schedule.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -47,7 +50,7 @@
|
||||||
v-model="newEventLocation"
|
v-model="newEventLocation"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations"
|
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:selected="location.id === newEventLocation"
|
:selected="location.id === newEventLocation"
|
||||||
|
@ -99,14 +102,14 @@
|
||||||
<td>{{ status(es) }}</td>
|
<td>{{ status(es) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="es.location"
|
:value="es.locationId"
|
||||||
@change="editEventSlot(es, { location: ($event as any).target.value })"
|
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations"
|
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:selected="location.id === es.location"
|
:selected="location.id === es.locationId"
|
||||||
>{{ location.name }}</option>
|
>{{ location.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -124,7 +127,7 @@
|
||||||
@click="delEventSlot(es)"
|
@click="delEventSlot(es)"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === es.id)"
|
v-if="changes.some(c => c.id === es.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertEventSlot(es.id)"
|
@click="revertEventSlot(es.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -186,7 +189,7 @@
|
||||||
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
||||||
<td>{{ es.name }}</td>
|
<td>{{ es.name }}</td>
|
||||||
<td>{{ status(es) }}</td>
|
<td>{{ status(es) }}</td>
|
||||||
<td>{{ es.location }}</td>
|
<td>{{ es.locationId }}</td>
|
||||||
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
|
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -205,13 +208,13 @@
|
||||||
<b>EventSlot changes</b>
|
<b>EventSlot changes</b>
|
||||||
<ol>
|
<ol>
|
||||||
<li v-for="change in changes">
|
<li v-for="change in changes">
|
||||||
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
|
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<b>ScheduleEvent changes</b>
|
<b>ScheduleEvent changes</b>
|
||||||
<ol>
|
<ol>
|
||||||
<li v-for="change in scheduleChanges">
|
<li v-for="change in scheduleChanges">
|
||||||
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
|
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
@ -220,25 +223,28 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import type { ChangeRecord, Schedule, ScheduleEvent, ScheduleLocation, ShiftSlot, TimeSlot } from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
import type { Entity } from '~/shared/types/common';
|
||||||
import { enumerate, pairs, toId } from '~/shared/utils/functions';
|
import { enumerate, pairs } from '~/shared/utils/functions';
|
||||||
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
location?: string,
|
locationId?: number,
|
||||||
eventSlotFilter?: (slot: TimeSlot) => boolean,
|
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
|
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface EventSlot {
|
interface EventSlot {
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: string,
|
id: number,
|
||||||
event?: ScheduleEvent,
|
updatedAt: string,
|
||||||
slot?: TimeSlot,
|
deleted?: boolean,
|
||||||
origLocation: string,
|
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
|
||||||
|
slot?: ApiScheduleEventSlot,
|
||||||
|
origLocation: number,
|
||||||
name: string,
|
name: string,
|
||||||
location: string,
|
locationId: number,
|
||||||
assigned: number[],
|
assigned: number[],
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
|
@ -250,57 +256,69 @@ interface Gap {
|
||||||
event?: undefined,
|
event?: undefined,
|
||||||
slot?: undefined,
|
slot?: undefined,
|
||||||
name?: undefined,
|
name?: undefined,
|
||||||
location?: string,
|
locationId?: number,
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(eventSlot: EventSlot) {
|
function status(eventSlot: EventSlot) {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!eventSlot.event
|
!eventSlot.event
|
||||||
|| eventSlot.event.name !== eventSlot.name
|
|| eventSlot.event.name !== eventSlot.name
|
||||||
) {
|
) {
|
||||||
const event = schedule.value.events.find(event => event.name === eventSlot.name);
|
const event = schedule.value.events?.find(event => !event.deleted && event.name === eventSlot.name);
|
||||||
return event ? "L" : "N";
|
return event ? "L" : "N";
|
||||||
}
|
}
|
||||||
return eventSlot.event.slots.length === 1 ? "" : eventSlot.event.slots.length;
|
return eventSlot.event.slots.length === 1 ? "" : eventSlot.event.slots.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out set records where a del record exists for the same id.
|
// 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[]) {
|
function filterSetOps<T extends Entity>(changes: T[]) {
|
||||||
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
|
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
|
||||||
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
|
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function findEvent(
|
function findEvent(
|
||||||
eventSlot: EventSlot,
|
eventSlot: EventSlot,
|
||||||
changes: ChangeRecord<ScheduleEvent>[],
|
changes: ApiScheduleEvent[],
|
||||||
schedule: Schedule,
|
schedule: ApiSchedule,
|
||||||
) {
|
) {
|
||||||
let setEvent = changes.find(
|
if (schedule.deleted) {
|
||||||
c => c.op === "set" && c.data.name === eventSlot.name
|
throw new Error("Unexpected deleted schedule");
|
||||||
)?.data as ScheduleEvent | undefined;
|
}
|
||||||
|
let setEvent = changes.filter(
|
||||||
|
c => !c.deleted
|
||||||
|
).find(
|
||||||
|
c => c.name === eventSlot.name
|
||||||
|
);
|
||||||
if (!setEvent && eventSlot.event && eventSlot.event.name === eventSlot.name) {
|
if (!setEvent && eventSlot.event && eventSlot.event.name === eventSlot.name) {
|
||||||
setEvent = eventSlot.event;
|
setEvent = eventSlot.event;
|
||||||
}
|
}
|
||||||
if (!setEvent) {
|
if (!setEvent) {
|
||||||
setEvent = schedule.events.find(e => e.name === eventSlot.name);
|
setEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.name);
|
||||||
}
|
}
|
||||||
let delEvent;
|
let delEvent;
|
||||||
if (eventSlot.event) {
|
if (eventSlot.event) {
|
||||||
delEvent = changes.find(
|
delEvent = changes.filter(c => !c.deleted).find(
|
||||||
c => c.op === "set" && c.data.name === eventSlot.event!.name
|
c => c.name === eventSlot.event!.name
|
||||||
)?.data as ScheduleEvent | undefined;
|
);
|
||||||
if (!delEvent) {
|
if (!delEvent) {
|
||||||
delEvent = schedule.events.find(e => e.name === eventSlot.event!.name);
|
delEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.event!.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { setEvent, delEvent };
|
return { setEvent, delEvent };
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSlotLocation(event: ScheduleEvent, oldSlot: TimeSlot, location: string) {
|
function removeSlotLocation(
|
||||||
|
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
||||||
|
oldSlot: ApiScheduleEventSlot,
|
||||||
|
locationId: number
|
||||||
|
) {
|
||||||
// If location is an exact match remove the whole slot
|
// If location is an exact match remove the whole slot
|
||||||
if (oldSlot.locations.length === 1 && oldSlot.locations[0] === location) {
|
if (oldSlot.locationIds.length === 1 && oldSlot.locationIds[0] === locationId) {
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
slots: event.slots.filter(s => s.id !== oldSlot.id),
|
slots: event.slots.filter(s => s.id !== oldSlot.id),
|
||||||
|
@ -312,18 +330,21 @@ function removeSlotLocation(event: ScheduleEvent, oldSlot: TimeSlot, location: s
|
||||||
slots: event.slots.map(
|
slots: event.slots.map(
|
||||||
s => s.id !== oldSlot.id ? s : {
|
s => s.id !== oldSlot.id ? s : {
|
||||||
...s,
|
...s,
|
||||||
locations: s.locations.filter(l => l !== location)
|
locationIds: s.locationIds.filter(id => id !== locationId)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
|
function mergeSlot(
|
||||||
|
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
||||||
|
eventSlot: EventSlot,
|
||||||
|
): Extract<ApiScheduleEvent, { deleted?: false }> {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
const oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
||||||
const nextId = Math.max(-1, ...event.slots.map(s => {
|
const nextId = Math.max(0, ...schedule.value.events?.filter(e => !e.deleted).flatMap(e => e.slots.map(slot => slot.id)) ?? []) + 1;
|
||||||
const id = /-(\d+)$/.exec(s.id)?.[1];
|
|
||||||
return id ? parseInt(id) : 0;
|
|
||||||
})) + 1;
|
|
||||||
const start = eventSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
const start = eventSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
||||||
const end = eventSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
const end = eventSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
||||||
|
|
||||||
|
@ -332,7 +353,7 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
|
||||||
oldSlot
|
oldSlot
|
||||||
&& oldSlot.id === eventSlot.id
|
&& oldSlot.id === eventSlot.id
|
||||||
&& (
|
&& (
|
||||||
oldSlot.locations.length <= 1
|
oldSlot.locationIds.length <= 1
|
||||||
|| oldSlot.start === start && oldSlot.end === end
|
|| oldSlot.start === start && oldSlot.end === end
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -341,12 +362,12 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
|
||||||
slots: event.slots.map(s => {
|
slots: event.slots.map(s => {
|
||||||
if (s.id !== oldSlot.id)
|
if (s.id !== oldSlot.id)
|
||||||
return s;
|
return s;
|
||||||
const locations = new Set(s.locations);
|
const locationIds = new Set(s.locationIds);
|
||||||
locations.delete(eventSlot.origLocation)
|
locationIds.delete(eventSlot.origLocation)
|
||||||
locations.add(eventSlot.location);
|
locationIds.add(eventSlot.locationId);
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
locations: [...locations],
|
locationIds: [...locationIds],
|
||||||
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
@ -363,8 +384,8 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
slots: [...event.slots, {
|
slots: [...event.slots, {
|
||||||
id: oldSlot ? oldSlot.id : `${event.id}-${nextId}`,
|
id: oldSlot ? oldSlot.id : nextId,
|
||||||
locations: [eventSlot.location],
|
locationIds: [eventSlot.locationId],
|
||||||
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
@ -373,31 +394,29 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleChanges = computed(() => {
|
const scheduleChanges = computed(() => {
|
||||||
let eventChanges: ChangeRecord<ScheduleEvent>[] = [];
|
let eventChanges: Extract<ApiScheduleEvent, { deleted?: false}>[] = [];
|
||||||
for (const change of filterSetOps(changes.value)) {
|
for (const change of filterSetOps(changes.value)) {
|
||||||
if (change.op === "set") {
|
if (!change.deleted) {
|
||||||
let { setEvent, delEvent } = findEvent(change.data, eventChanges, schedule.value);
|
let { setEvent, delEvent } = findEvent(change, eventChanges, schedule.value);
|
||||||
if (delEvent && delEvent !== setEvent) {
|
if (delEvent && delEvent !== setEvent) {
|
||||||
eventChanges = removeSlot(eventChanges, delEvent, change.data);
|
eventChanges = removeSlot(eventChanges, delEvent, change);
|
||||||
}
|
}
|
||||||
if (!setEvent) {
|
if (!setEvent) {
|
||||||
setEvent = {
|
setEvent = {
|
||||||
id: toId(change.data.name),
|
id: Math.floor(Math.random() * -1000), // XXX This wont work.
|
||||||
name: change.data.name,
|
updatedAt: "",
|
||||||
|
name: change.name,
|
||||||
crew: true,
|
crew: true,
|
||||||
slots: [],
|
slots: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
eventChanges = replaceChange({
|
eventChanges = replaceChange(mergeSlot(setEvent, change), eventChanges);
|
||||||
op: "set",
|
|
||||||
data: mergeSlot(setEvent, change.data),
|
|
||||||
}, eventChanges);
|
|
||||||
|
|
||||||
} else if (change.op === "del") {
|
} else if (change.deleted) {
|
||||||
let { delEvent } = findEvent(change.data, eventChanges, schedule.value);
|
let { delEvent } = findEvent(change, eventChanges, schedule.value);
|
||||||
if (delEvent) {
|
if (delEvent) {
|
||||||
eventChanges = removeSlot(eventChanges, delEvent, change.data);
|
eventChanges = removeSlot(eventChanges, delEvent, change);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -405,21 +424,28 @@ const scheduleChanges = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const schedulePreview = computed(() => {
|
const schedulePreview = computed(() => {
|
||||||
const events = [...schedule.value.events]
|
if (schedule.value.deleted) {
|
||||||
applyChangeArray(scheduleChanges.value, events);
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
const events = [...schedule.value.events ?? []]
|
||||||
|
applyUpdatesToArray(scheduleChanges.value, events);
|
||||||
return {
|
return {
|
||||||
...schedule.value,
|
...schedule.value,
|
||||||
events,
|
events,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeSlot(eventChanges: ChangeRecord<ScheduleEvent>[], event: ScheduleEvent, eventSlot: EventSlot) {
|
function removeSlot(
|
||||||
|
eventChanges: Extract<ApiScheduleEvent, { deleted?: false }>[],
|
||||||
|
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
||||||
|
eventSlot: EventSlot,
|
||||||
|
) {
|
||||||
let oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
let oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
||||||
if (oldSlot) {
|
if (oldSlot) {
|
||||||
eventChanges = replaceChange({
|
eventChanges = replaceChange(
|
||||||
op: "set",
|
removeSlotLocation(event, oldSlot, eventSlot.origLocation),
|
||||||
data: removeSlotLocation(event, oldSlot, eventSlot.origLocation),
|
eventChanges,
|
||||||
}, eventChanges);
|
);
|
||||||
}
|
}
|
||||||
return eventChanges;
|
return eventChanges;
|
||||||
}
|
}
|
||||||
|
@ -427,17 +453,15 @@ function removeSlot(eventChanges: ChangeRecord<ScheduleEvent>[], event: Schedule
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
type EventSlotChange = { op: "set" | "del", data: EventSlot } ;
|
const changes = ref<EventSlot[]>([]);
|
||||||
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
|
|
||||||
const changes = ref<EventSlotChange[]>([]);
|
function replaceChange<T extends Entity>(
|
||||||
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,
|
change: T,
|
||||||
changes: T[],
|
changes: T[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -446,8 +470,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
|
function revertChange<T extends Entity>(id: number, changes: T[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
|
@ -472,9 +496,9 @@ const newEventEnd = computed({
|
||||||
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const newEventLocation = ref(props.location);
|
const newEventLocation = ref(props.locationId);
|
||||||
watch(() => props.location, () => {
|
watch(() => props.locationId, () => {
|
||||||
newEventLocation.value = props.location;
|
newEventLocation.value = props.locationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
function endFromTime(start: DateTime, time: string) {
|
function endFromTime(start: DateTime, time: string) {
|
||||||
|
@ -499,7 +523,7 @@ function editEventSlot(
|
||||||
end?: string,
|
end?: string,
|
||||||
duration?: string,
|
duration?: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
location?: string,
|
locationId?: number,
|
||||||
assigned?: number[],
|
assigned?: number[],
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
@ -512,7 +536,6 @@ function editEventSlot(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (edits.end !== undefined) {
|
if (edits.end !== undefined) {
|
||||||
|
|
||||||
eventSlot = {
|
eventSlot = {
|
||||||
...eventSlot,
|
...eventSlot,
|
||||||
end: endFromTime(eventSlot.start, edits.end),
|
end: endFromTime(eventSlot.start, edits.end),
|
||||||
|
@ -530,10 +553,10 @@ function editEventSlot(
|
||||||
name: edits.name,
|
name: edits.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (edits.location !== undefined) {
|
if (edits.locationId !== undefined) {
|
||||||
eventSlot = {
|
eventSlot = {
|
||||||
...eventSlot,
|
...eventSlot,
|
||||||
location: edits.location,
|
locationId: edits.locationId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (edits.assigned !== undefined) {
|
if (edits.assigned !== undefined) {
|
||||||
|
@ -542,20 +565,22 @@ function editEventSlot(
|
||||||
assigned: edits.assigned,
|
assigned: edits.assigned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const change = { op: "set" as const, data: eventSlot };
|
changes.value = replaceChange(eventSlot, changes.value);
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
}
|
||||||
function delEventSlot(eventSlot: EventSlot) {
|
function delEventSlot(eventSlot: EventSlot) {
|
||||||
const change = { op: "del" as const, data: eventSlot };
|
const change = {
|
||||||
|
...eventSlot,
|
||||||
|
deleted: true,
|
||||||
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function revertEventSlot(id: string) {
|
function revertEventSlot(id: number) {
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
const name = newEventName.value;
|
const name = newEventName.value;
|
||||||
const location = newEventLocation.value;
|
const locationId = newEventLocation.value;
|
||||||
if (!location) {
|
if (!locationId) {
|
||||||
alert("Invalid location");
|
alert("Invalid location");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -580,27 +605,30 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
alert("Invalid start and/or end time");
|
alert("Invalid start and/or end time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change: ChangeRecord<EventSlot> = {
|
const change: EventSlot = {
|
||||||
op: "set" as const,
|
type: "slot",
|
||||||
data: {
|
updatedAt: "",
|
||||||
type: "slot",
|
id: Math.floor(Math.random() * -1000), // XXX this wont work.
|
||||||
id: `$new-${Date.now()}`,
|
name,
|
||||||
name,
|
origLocation: locationId,
|
||||||
origLocation: location,
|
locationId,
|
||||||
location,
|
assigned: [],
|
||||||
assigned: [],
|
start,
|
||||||
start,
|
end,
|
||||||
end,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
newEventName.value = "";
|
newEventName.value = "";
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEventSlots() {
|
async function saveEventSlots() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { events: scheduleChanges.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
events: scheduleChanges.value,
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -620,30 +648,36 @@ function gapFormat(gap: Gap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSlots = computed(() => {
|
const eventSlots = computed(() => {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const data: (EventSlot | Gap)[] = [];
|
const data: (EventSlot | Gap)[] = [];
|
||||||
for (const event of schedule.value.events) {
|
for (const event of schedule.value.events ?? []) {
|
||||||
|
if (event.deleted)
|
||||||
|
continue;
|
||||||
for (const slot of event.slots) {
|
for (const slot of event.slots) {
|
||||||
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
|
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
|
||||||
continue;
|
continue;
|
||||||
for (const location of slot.locations) {
|
for (const locationId of slot.locationIds) {
|
||||||
if (props.location !== undefined && location !== props.location)
|
if (props.locationId !== undefined && locationId !== props.locationId)
|
||||||
continue;
|
continue;
|
||||||
data.push({
|
data.push({
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
|
updatedAt: "",
|
||||||
event,
|
event,
|
||||||
slot,
|
slot,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
location,
|
locationId,
|
||||||
assigned: slot.assigned ?? [],
|
assigned: slot.assigned ?? [],
|
||||||
origLocation: location,
|
origLocation: locationId,
|
||||||
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
||||||
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data as EventSlot[]);
|
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as EventSlot[]);
|
||||||
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
||||||
|
|
||||||
// Insert gaps
|
// Insert gaps
|
||||||
|
@ -654,7 +688,7 @@ const eventSlots = computed(() => {
|
||||||
if (maxEnd < second.start.toMillis()) {
|
if (maxEnd < second.start.toMillis()) {
|
||||||
gaps.push([index, {
|
gaps.push([index, {
|
||||||
type: "gap",
|
type: "gap",
|
||||||
location: props.location,
|
locationId: props.locationId,
|
||||||
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
||||||
end: second.start,
|
end: second.start,
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="schedule.deleted">
|
||||||
|
Error: Unexpected deleted schedule.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -47,7 +50,7 @@
|
||||||
v-model="newShiftRole"
|
v-model="newShiftRole"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles"
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === newShiftRole"
|
:selected="role.id === newShiftRole"
|
||||||
|
@ -99,14 +102,14 @@
|
||||||
<td>{{ status(ss) }}</td>
|
<td>{{ status(ss) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="ss.role"
|
:value="ss.roleId"
|
||||||
@change="editShiftSlot(ss, { role: ($event as any).target.value })"
|
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles"
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === ss.role"
|
:selected="role.id === ss.roleId"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -124,7 +127,7 @@
|
||||||
@click="delShiftSlot(ss)"
|
@click="delShiftSlot(ss)"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === ss.id)"
|
v-if="changes.some(c => c.id === ss.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertShiftSlot(ss.id)"
|
@click="revertShiftSlot(ss.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -184,7 +187,7 @@
|
||||||
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
|
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
|
||||||
<td>{{ ss.name }}</td>
|
<td>{{ ss.name }}</td>
|
||||||
<td>{{ status(ss) }}</td>
|
<td>{{ status(ss) }}</td>
|
||||||
<td>{{ ss.role }}</td>
|
<td>{{ ss.roleId }}</td>
|
||||||
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
|
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -203,13 +206,13 @@
|
||||||
<b>ShiftSlot changes</b>
|
<b>ShiftSlot changes</b>
|
||||||
<ol>
|
<ol>
|
||||||
<li v-for="change in changes">
|
<li v-for="change in changes">
|
||||||
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
|
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<b>Shift changes</b>
|
<b>Shift changes</b>
|
||||||
<ol>
|
<ol>
|
||||||
<li v-for="change in shiftChanges">
|
<li v-for="change in shiftChanges">
|
||||||
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
|
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
@ -218,25 +221,28 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import type { ChangeRecord, Schedule, Shift, Role, ShiftSlot as ShiftTimeSlot, TimeSlot} from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
import { enumerate, pairs, toId } from '~/shared/utils/functions';
|
import { enumerate, pairs } from '~/shared/utils/functions';
|
||||||
|
import type { Entity } from '~/shared/types/common';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
role?: string,
|
roleId?: number,
|
||||||
eventSlotFilter?: (slot: TimeSlot) => boolean,
|
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ShiftTimeSlot) => boolean,
|
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface ShiftSlot {
|
interface ShiftSlot {
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: string,
|
id: number,
|
||||||
shift?: Shift,
|
updatedAt: string,
|
||||||
slot?: ShiftTimeSlot,
|
deleted?: boolean,
|
||||||
origRole: string,
|
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
|
||||||
|
slot?: ApiScheduleShiftSlot,
|
||||||
|
origRole: number,
|
||||||
name: string,
|
name: string,
|
||||||
role: string,
|
roleId: number,
|
||||||
assigned: number[],
|
assigned: number[],
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
|
@ -248,40 +254,47 @@ interface Gap {
|
||||||
shift?: undefined,
|
shift?: undefined,
|
||||||
slot?: undefined,
|
slot?: undefined,
|
||||||
name?: undefined,
|
name?: undefined,
|
||||||
role?: string,
|
roleId?: number,
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(shiftSlot: ShiftSlot) {
|
function status(shiftSlot: ShiftSlot) {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!shiftSlot.shift
|
!shiftSlot.shift
|
||||||
|| shiftSlot.shift.name !== shiftSlot.name
|
|| shiftSlot.shift.name !== shiftSlot.name
|
||||||
) {
|
) {
|
||||||
const shift = schedule.value.rota?.find(shift => shift.name === shiftSlot.name);
|
const shift = schedule.value.shifts?.find(shift => !shift.deleted && shift.name === shiftSlot.name);
|
||||||
return shift ? "L" : "N";
|
return shift ? "L" : "N";
|
||||||
}
|
}
|
||||||
return shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
|
return shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out set records where a del record exists for the same id.
|
// 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[]) {
|
function filterSetOps<T extends Entity>(changes: T[]) {
|
||||||
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
|
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
|
||||||
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
|
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function findShift(
|
function findShift(
|
||||||
shiftSlot: ShiftSlot,
|
shiftSlot: ShiftSlot,
|
||||||
changes: ChangeRecord<Shift>[],
|
changes: ApiScheduleShift[],
|
||||||
schedule: Schedule,
|
schedule: ApiSchedule,
|
||||||
) {
|
) {
|
||||||
let setShift = changes.find(
|
if (schedule.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
let setShift = changes.filter(
|
||||||
|
c => !c.deleted,
|
||||||
|
).find(
|
||||||
c => (
|
c => (
|
||||||
c.op === "set"
|
c.name === shiftSlot.name
|
||||||
&& c.data.name === shiftSlot.name
|
&& c.roleId === shiftSlot.roleId
|
||||||
&& c.data.role === shiftSlot.role
|
|
||||||
)
|
)
|
||||||
)?.data as Shift | undefined;
|
);
|
||||||
if (
|
if (
|
||||||
!setShift
|
!setShift
|
||||||
&& shiftSlot.shift
|
&& shiftSlot.shift
|
||||||
|
@ -290,32 +303,35 @@ function findShift(
|
||||||
setShift = shiftSlot.shift;
|
setShift = shiftSlot.shift;
|
||||||
}
|
}
|
||||||
if (!setShift) {
|
if (!setShift) {
|
||||||
setShift = schedule.rota?.find(e => e.name === shiftSlot.name);
|
setShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.name);
|
||||||
}
|
}
|
||||||
let delShift;
|
let delShift;
|
||||||
if (shiftSlot.shift) {
|
if (shiftSlot.shift) {
|
||||||
delShift = changes.find(
|
delShift = changes.filter(c => !c.deleted).find(
|
||||||
c => c.op === "set" && c.data.name === shiftSlot.shift!.name
|
c => c.name === shiftSlot.shift!.name
|
||||||
)?.data as Shift | undefined;
|
);
|
||||||
if (!delShift) {
|
if (!delShift) {
|
||||||
delShift = schedule.rota?.find(e => e.name === shiftSlot.shift!.name);
|
delShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.shift!.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { setShift, delShift };
|
return { setShift, delShift };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
|
function mergeSlot(
|
||||||
|
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
||||||
|
shiftSlot: ShiftSlot,
|
||||||
|
): Extract<ApiScheduleShift, { deleted?: false }> {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
const oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
||||||
const nextId = Math.max(-1, ...shift.slots.map(s => {
|
const nextId = Math.max(0, ...schedule.value.shifts?.filter(s => !s.deleted).flatMap(s => s.slots.map(slot => slot.id)) ?? []) + 1;
|
||||||
const id = /-(\d+)$/.exec(s.id)?.[1];
|
|
||||||
return id ? parseInt(id) : 0;
|
|
||||||
})) + 1;
|
|
||||||
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
||||||
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
||||||
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
|
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
|
||||||
|
|
||||||
if (shift.role !== shiftSlot.role) {
|
if (shift.roleId !== shiftSlot.roleId) {
|
||||||
console.warn(`Attempt to add slot id=${shiftSlot.id} role=${shiftSlot.role} to shift id=${shift.id} role=${shift.role}`);
|
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
|
// Edit slot in-place if possible
|
||||||
|
@ -330,7 +346,7 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
|
||||||
return {
|
return {
|
||||||
...shift,
|
...shift,
|
||||||
slots: [...(oldSlot ? shift.slots.filter(s => s.id !== oldSlot.id) : shift.slots), {
|
slots: [...(oldSlot ? shift.slots.filter(s => s.id !== oldSlot.id) : shift.slots), {
|
||||||
id: oldSlot ? oldSlot.id : `${shift.id}-${nextId}`,
|
id: oldSlot ? oldSlot.id : nextId,
|
||||||
assigned,
|
assigned,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
@ -339,35 +355,33 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftChanges = computed(() => {
|
const shiftChanges = computed(() => {
|
||||||
let eventChanges: ChangeRecord<Shift>[] = [];
|
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
|
||||||
for (const change of filterSetOps(changes.value)) {
|
for (const change of filterSetOps(changes.value)) {
|
||||||
if (change.op === "set") {
|
if (!change.deleted) {
|
||||||
let { setShift, delShift } = findShift(change.data, eventChanges, schedule.value);
|
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
|
||||||
if (delShift && delShift !== setShift) {
|
if (delShift && delShift !== setShift) {
|
||||||
eventChanges = removeSlot(eventChanges, delShift, change.data);
|
eventChanges = removeSlot(eventChanges, delShift, change);
|
||||||
}
|
}
|
||||||
if (!setShift) {
|
if (!setShift) {
|
||||||
setShift = {
|
setShift = {
|
||||||
id: toId(change.data.name),
|
id: Math.floor(Math.random() * -1000), // XXX This wont work.
|
||||||
name: change.data.name,
|
updatedAt: "",
|
||||||
role: change.data.role,
|
name: change.name,
|
||||||
|
roleId: change.roleId,
|
||||||
slots: [],
|
slots: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
setShift = {
|
setShift = {
|
||||||
...setShift,
|
...setShift,
|
||||||
role: change.data.role,
|
roleId: change.roleId,
|
||||||
}
|
}
|
||||||
|
|
||||||
eventChanges = replaceChange({
|
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
|
||||||
op: "set",
|
|
||||||
data: mergeSlot(setShift, change.data),
|
|
||||||
}, eventChanges);
|
|
||||||
|
|
||||||
} else if (change.op === "del") {
|
} else if (change.deleted) {
|
||||||
let { delShift } = findShift(change.data, eventChanges, schedule.value);
|
let { delShift } = findShift(change, eventChanges, schedule.value);
|
||||||
if (delShift) {
|
if (delShift) {
|
||||||
eventChanges = removeSlot(eventChanges, delShift, change.data);
|
eventChanges = removeSlot(eventChanges, delShift, change);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -375,20 +389,27 @@ const shiftChanges = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const schedulePreview = computed(() => {
|
const schedulePreview = computed(() => {
|
||||||
const rota = [...schedule.value.rota ?? []]
|
if (schedule.value.deleted) {
|
||||||
applyChangeArray(shiftChanges.value, rota);
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
const shifts = [...schedule.value.shifts ?? []]
|
||||||
|
applyUpdatesToArray(shiftChanges.value, shifts);
|
||||||
return {
|
return {
|
||||||
...schedule.value,
|
...schedule.value,
|
||||||
rota,
|
shifts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, shiftSlot: ShiftSlot) {
|
function removeSlot(
|
||||||
|
eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[],
|
||||||
|
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
||||||
|
shiftSlot: ShiftSlot,
|
||||||
|
) {
|
||||||
let oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
let oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
||||||
if (oldSlot) {
|
if (oldSlot) {
|
||||||
eventChanges = replaceChange({
|
eventChanges = replaceChange({
|
||||||
op: "set",
|
...shift,
|
||||||
data: { ...shift, slots: shift.slots.filter(s => s.id !== oldSlot.id) },
|
slots: shift.slots.filter(s => s.id !== oldSlot.id)
|
||||||
}, eventChanges);
|
}, eventChanges);
|
||||||
}
|
}
|
||||||
return eventChanges;
|
return eventChanges;
|
||||||
|
@ -397,17 +418,15 @@ function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, shiftSlot
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
type ShiftSlotChange = { op: "set" | "del", data: ShiftSlot } ;
|
const changes = ref<ShiftSlot[]>([]);
|
||||||
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
|
|
||||||
const changes = ref<ShiftSlotChange[]>([]);
|
function replaceChange<T extends Entity>(
|
||||||
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,
|
change: T,
|
||||||
changes: T[],
|
changes: T[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -416,8 +435,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
|
function revertChange<T extends Entity>(id: number, changes: T[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
|
@ -442,9 +461,9 @@ const newShiftEnd = computed({
|
||||||
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const newShiftRole = ref(props.role);
|
const newShiftRole = ref(props.roleId);
|
||||||
watch(() => props.role, () => {
|
watch(() => props.roleId, () => {
|
||||||
newShiftRole.value = props.role;
|
newShiftRole.value = props.roleId;
|
||||||
});
|
});
|
||||||
|
|
||||||
function endFromTime(start: DateTime, time: string) {
|
function endFromTime(start: DateTime, time: string) {
|
||||||
|
@ -469,7 +488,7 @@ function editShiftSlot(
|
||||||
end?: string,
|
end?: string,
|
||||||
duration?: string,
|
duration?: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
role?: string,
|
roleId?: number,
|
||||||
assigned?: number[],
|
assigned?: number[],
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
@ -482,7 +501,6 @@ function editShiftSlot(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (edits.end !== undefined) {
|
if (edits.end !== undefined) {
|
||||||
|
|
||||||
shiftSlot = {
|
shiftSlot = {
|
||||||
...shiftSlot,
|
...shiftSlot,
|
||||||
end: endFromTime(shiftSlot.start, edits.end),
|
end: endFromTime(shiftSlot.start, edits.end),
|
||||||
|
@ -500,19 +518,19 @@ function editShiftSlot(
|
||||||
name: edits.name,
|
name: edits.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (edits.role !== undefined) {
|
if (edits.roleId !== undefined) {
|
||||||
let changesCopy = changes.value;
|
let changesCopy = changes.value;
|
||||||
for (const slot of shiftSlots.value) {
|
for (const slot of shiftSlots.value) {
|
||||||
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
|
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
|
||||||
changesCopy = replaceChange({
|
changesCopy = replaceChange({
|
||||||
op: "set",
|
...slot,
|
||||||
data: { ...slot, role: edits.role }
|
roleId: edits.roleId,
|
||||||
}, changesCopy);
|
}, changesCopy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changesCopy = replaceChange({
|
changesCopy = replaceChange({
|
||||||
op: "set",
|
...shiftSlot,
|
||||||
data: { ...shiftSlot, role: edits.role }
|
roleId: edits.roleId,
|
||||||
}, changesCopy);
|
}, changesCopy);
|
||||||
changes.value = changesCopy;
|
changes.value = changesCopy;
|
||||||
return;
|
return;
|
||||||
|
@ -523,20 +541,22 @@ function editShiftSlot(
|
||||||
assigned: edits.assigned,
|
assigned: edits.assigned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const change = { op: "set" as const, data: shiftSlot };
|
changes.value = replaceChange(shiftSlot, changes.value);
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
}
|
||||||
function delShiftSlot(shiftSlot: ShiftSlot) {
|
function delShiftSlot(shiftSlot: ShiftSlot) {
|
||||||
const change = { op: "del" as const, data: shiftSlot };
|
const change = {
|
||||||
|
...shiftSlot,
|
||||||
|
deleted: true,
|
||||||
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function revertShiftSlot(id: string) {
|
function revertShiftSlot(id: number) {
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
const name = newShiftName.value;
|
const name = newShiftName.value;
|
||||||
const role = newShiftRole.value;
|
const roleId = newShiftRole.value;
|
||||||
if (!role) {
|
if (!roleId) {
|
||||||
alert("Invalid role");
|
alert("Invalid role");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -561,18 +581,16 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
alert("Invalid start and/or end time");
|
alert("Invalid start and/or end time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change: ChangeRecord<ShiftSlot> = {
|
const change: ShiftSlot = {
|
||||||
op: "set" as const,
|
type: "slot",
|
||||||
data: {
|
updatedAt: "",
|
||||||
type: "slot",
|
id: Math.floor(Math.random() * -1000), // XXX this wont work.
|
||||||
id: `$new-${Date.now()}`,
|
name,
|
||||||
name,
|
origRole: roleId,
|
||||||
origRole: role,
|
roleId,
|
||||||
role,
|
assigned: [],
|
||||||
assigned: [],
|
start,
|
||||||
start,
|
end,
|
||||||
end,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
newShiftName.value = "";
|
newShiftName.value = "";
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
|
@ -581,7 +599,11 @@ async function saveShiftSlots() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { rota: shiftChanges.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
shifts: shiftChanges.value
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -601,9 +623,12 @@ function gapFormat(gap: Gap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftSlots = computed(() => {
|
const shiftSlots = computed(() => {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
const data: (ShiftSlot | Gap)[] = [];
|
const data: (ShiftSlot | Gap)[] = [];
|
||||||
for (const shift of schedule.value.rota ?? []) {
|
for (const shift of schedule.value.shifts ?? []) {
|
||||||
if (props.role !== undefined && shift.role !== props.role)
|
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
|
||||||
continue;
|
continue;
|
||||||
for (const slot of shift.slots) {
|
for (const slot of shift.slots) {
|
||||||
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
|
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
|
||||||
|
@ -611,18 +636,19 @@ const shiftSlots = computed(() => {
|
||||||
data.push({
|
data.push({
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
|
updatedAt: "",
|
||||||
shift,
|
shift,
|
||||||
slot,
|
slot,
|
||||||
name: shift.name,
|
name: shift.name,
|
||||||
role: shift.role,
|
roleId: shift.roleId,
|
||||||
assigned: slot.assigned ?? [],
|
assigned: slot.assigned ?? [],
|
||||||
origRole: shift.role,
|
origRole: shift.roleId,
|
||||||
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
||||||
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data as ShiftSlot[]);
|
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as ShiftSlot[]);
|
||||||
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
||||||
|
|
||||||
// Insert gaps
|
// Insert gaps
|
||||||
|
@ -633,7 +659,7 @@ const shiftSlots = computed(() => {
|
||||||
if (maxEnd < second.start.toMillis()) {
|
if (maxEnd < second.start.toMillis()) {
|
||||||
gaps.push([index, {
|
gaps.push([index, {
|
||||||
type: "gap",
|
type: "gap",
|
||||||
role: props.role,
|
roleId: props.roleId,
|
||||||
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
||||||
end: second.start,
|
end: second.start,
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="schedule.deleted">
|
||||||
|
Error: Unexpected deleted schedule.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -14,7 +17,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="shift in shifts"
|
v-for="shift in shifts?.filter(s => !s.deleted)"
|
||||||
:key="shift.id"
|
:key="shift.id"
|
||||||
:class="{ removed: removed.has(shift.id) }"
|
:class="{ removed: removed.has(shift.id) }"
|
||||||
>
|
>
|
||||||
|
@ -28,14 +31,14 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="shift.role"
|
:value="shift.roleId"
|
||||||
@change="editShift(shift, { role: ($event as any).target.value })"
|
@change="editShift(shift, { roleId: ($event as any).target.value })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles"
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="shift.role === role.id"
|
:selected="shift.roleId === role.id"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -54,14 +57,14 @@
|
||||||
@click="delShift(shift.id)"
|
@click="delShift(shift.id)"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.data.id === shift.id)"
|
v-if="changes.some(c => c.id === shift.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertShift(shift.id)"
|
@click="revertShift(shift.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ toId(newShiftName) }}</td>
|
<td>{{ newShiftId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -71,7 +74,7 @@
|
||||||
<td>
|
<td>
|
||||||
<select v-model="newShiftRole">
|
<select v-model="newShiftRole">
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles"
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === newShiftRole"
|
:selected="role.id === newShiftRole"
|
||||||
|
@ -100,12 +103,12 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="shift in shifts"
|
v-for="shift in shifts?.filter(s => !s.deleted)"
|
||||||
:key="shift.id"
|
:key="shift.id"
|
||||||
>
|
>
|
||||||
<td>{{ shift.id }}</td>
|
<td>{{ shift.id }}</td>
|
||||||
<td>{{ shift.name }}</td>
|
<td>{{ shift.name }}</td>
|
||||||
<td>{{ shift.role }}</td>
|
<td>{{ shift.roleId }}</td>
|
||||||
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
|
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
|
||||||
<td>{{ shift.description }}</td>
|
<td>{{ shift.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -131,25 +134,25 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ChangeRecord, Shift } from '~/shared/types/schedule';
|
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
|
||||||
import { applyChangeArray } from '~/shared/utils/changes';
|
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
role?: string,
|
roleId?: number,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ChangeRecord<Shift>[]>([]);
|
const changes = ref<ApiScheduleShift[]>([]);
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
||||||
function replaceChange(
|
function replaceChange(
|
||||||
change: ChangeRecord<Shift>,
|
change: ApiScheduleShift,
|
||||||
changes: ChangeRecord<Shift>[],
|
changes: ApiScheduleShift[],
|
||||||
) {
|
) {
|
||||||
const index = changes.findIndex(item => (
|
const index = changes.findIndex(item => (
|
||||||
item.op === change.op && item.data.id === change.data.id
|
item.deleted === change.deleted && item.id === change.id
|
||||||
));
|
));
|
||||||
const copy = [...changes];
|
const copy = [...changes];
|
||||||
if (index !== -1)
|
if (index !== -1)
|
||||||
|
@ -158,19 +161,29 @@ function replaceChange(
|
||||||
copy.push(change);
|
copy.push(change);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
function revertChange(id: string, changes: ChangeRecord<Shift>[]) {
|
function revertChange(id: number, changes: ApiScheduleShift[]) {
|
||||||
return changes.filter(change => change.data.id !== id);
|
return changes.filter(change => change.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newShiftName = ref("");
|
const newShiftName = ref("");
|
||||||
const newShiftRole = ref(props.role);
|
const newShiftId = computed(() => {
|
||||||
watch(() => props.role, () => {
|
if (schedule.value.deleted) {
|
||||||
newShiftRole.value = props.role;
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
...schedule.value.shifts?.map(r => r.id) ?? [],
|
||||||
|
...changes.value.map(c => c.id)
|
||||||
|
) + 1;
|
||||||
|
});
|
||||||
|
const newShiftRole = ref(props.roleId);
|
||||||
|
watch(() => props.roleId, () => {
|
||||||
|
newShiftRole.value = props.roleId;
|
||||||
});
|
});
|
||||||
const newShiftDescription = ref("");
|
const newShiftDescription = ref("");
|
||||||
function editShift(
|
function editShift(
|
||||||
shift: Shift,
|
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
||||||
edits: { name?: string, description?: string, role?: string }
|
edits: { name?: string, description?: string, roleId?: number }
|
||||||
) {
|
) {
|
||||||
const copy = { ...shift };
|
const copy = { ...shift };
|
||||||
if (edits.name !== undefined) {
|
if (edits.name !== undefined) {
|
||||||
|
@ -179,24 +192,26 @@ function editShift(
|
||||||
if (edits.description !== undefined) {
|
if (edits.description !== undefined) {
|
||||||
copy.description = edits.description || undefined;
|
copy.description = edits.description || undefined;
|
||||||
}
|
}
|
||||||
if (edits.role !== undefined) {
|
if (edits.roleId !== undefined) {
|
||||||
copy.role = edits.role;
|
copy.roleId = edits.roleId;
|
||||||
}
|
}
|
||||||
const change = { op: "set" as const, data: copy };
|
changes.value = replaceChange(copy, changes.value);
|
||||||
|
}
|
||||||
|
function delShift(id: number) {
|
||||||
|
const change = { id, updatedAt: "", deleted: true as const };
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
}
|
}
|
||||||
function delShift(id: string) {
|
function revertShift(id: number) {
|
||||||
const change = { op: "del" as const, data: { id } };
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertShift(id: string) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
changes.value = revertChange(id, changes.value);
|
||||||
}
|
}
|
||||||
function shiftExists(name: string) {
|
function shiftExists(name: string) {
|
||||||
const id = toId(name);
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.rota?.some(e => e.id === id)
|
schedule.value.shifts?.some(s => !s.deleted && toId(s.name) === name)
|
||||||
|| changes.value.some(c => c.data.id === id)
|
|| changes.value.some(c => !c.deleted && c.name === name)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newShift() {
|
function newShift() {
|
||||||
|
@ -204,19 +219,20 @@ function newShift() {
|
||||||
alert(`Shift ${newShiftName.value} already exists`);
|
alert(`Shift ${newShiftName.value} already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw new Error("Unexpected deleted schedule");
|
||||||
|
}
|
||||||
if (!newShiftRole.value) {
|
if (!newShiftRole.value) {
|
||||||
alert(`Invalid role`);
|
alert(`Invalid role`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change = {
|
const change = {
|
||||||
op: "set" as const,
|
id: newShiftId.value,
|
||||||
data: {
|
updatedAt: "",
|
||||||
id: toId(newShiftName.value),
|
name: newShiftName.value,
|
||||||
name: newShiftName.value,
|
roleId: newShiftRole.value,
|
||||||
role: newShiftRole.value,
|
description: newShiftDescription.value || undefined,
|
||||||
description: newShiftDescription.value || undefined,
|
slots: [],
|
||||||
slots: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
changes.value = replaceChange(change, changes.value);
|
changes.value = replaceChange(change, changes.value);
|
||||||
newShiftName.value = "";
|
newShiftName.value = "";
|
||||||
|
@ -226,7 +242,11 @@ async function saveShifts() {
|
||||||
try {
|
try {
|
||||||
await $fetch("/api/schedule", {
|
await $fetch("/api/schedule", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { rota: changes.value },
|
body: {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: "",
|
||||||
|
shifts: changes.value
|
||||||
|
} satisfies ApiSchedule,
|
||||||
});
|
});
|
||||||
changes.value = [];
|
changes.value = [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
@ -236,8 +256,11 @@ async function saveShifts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shifts = computed(() => {
|
const shifts = computed(() => {
|
||||||
const data = [...schedule.value.rota ?? []].filter(shift => !props.role || shift.role === props.role);
|
if (schedule.value.deleted) {
|
||||||
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
|
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;
|
return data;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<figure class="timetable">
|
<figure class="timetable" v-if="schedule.deleted">
|
||||||
|
<p>
|
||||||
|
Error: Schedule is deleted.
|
||||||
|
</p>
|
||||||
|
</figure>
|
||||||
|
<figure class="timetable" v-else>
|
||||||
<details>
|
<details>
|
||||||
<summary>Debug</summary>
|
<summary>Debug</summary>
|
||||||
<details>
|
<details>
|
||||||
|
@ -56,7 +61,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="location in schedule.locations" :key="location.id">
|
<template v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
|
||||||
<tr v-if="locationRows.has(location.id)">
|
<tr v-if="locationRows.has(location.id)">
|
||||||
<th>{{ location.name }}</th>
|
<th>{{ location.name }}</th>
|
||||||
<td
|
<td
|
||||||
|
@ -75,7 +80,7 @@
|
||||||
<th>Shifts</th>
|
<th>Shifts</th>
|
||||||
<td :colSpan="totalColumns"></td>
|
<td :colSpan="totalColumns"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="role in schedule.roles" :key="role.id">
|
<template v-for="role in schedule.roles?.filter(r => !r.deleted)" :key="role.id">
|
||||||
<tr v-if="roleRows.has(role.id)">
|
<tr v-if="roleRows.has(role.id)">
|
||||||
<th>{{ role.name }}</th>
|
<th>{{ role.name }}</th>
|
||||||
<td
|
<td
|
||||||
|
@ -97,7 +102,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import type { Role, Schedule, ScheduleEvent, ScheduleLocation, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
|
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleLocation, ApiScheduleRole, ApiScheduleShift, ApiScheduleShiftSlot } from "~/shared/types/api";
|
||||||
|
import type { Id } from "~/shared/types/common";
|
||||||
import { pairs, setEquals } from "~/shared/utils/functions";
|
import { pairs, setEquals } from "~/shared/utils/functions";
|
||||||
|
|
||||||
const oneHourMs = 60 * 60 * 1000;
|
const oneHourMs = 60 * 60 * 1000;
|
||||||
|
@ -107,8 +113,8 @@ const oneMinMs = 60 * 1000;
|
||||||
|
|
||||||
/** Point in time where a time slots starts or ends. */
|
/** Point in time where a time slots starts or ends. */
|
||||||
type Edge =
|
type Edge =
|
||||||
| { type: "start" | "end", source: "event", slot: TimeSlot }
|
| { type: "start" | "end", source: "event", slot: ApiScheduleEventSlot }
|
||||||
| { type: "start" | "end", source: "shift", role: string, slot: ShiftSlot }
|
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
|
||||||
;
|
;
|
||||||
|
|
||||||
/** Point in time where multiple edges meet. */
|
/** Point in time where multiple edges meet. */
|
||||||
|
@ -118,8 +124,8 @@ type Junction = { ts: number, edges: Edge[] };
|
||||||
type Span = {
|
type Span = {
|
||||||
start: Junction;
|
start: Junction;
|
||||||
end: Junction,
|
end: Junction,
|
||||||
locations: Map<string, Set<TimeSlot>>,
|
locations: Map<number, Set<ApiScheduleEventSlot>>,
|
||||||
roles: Map<string, Set<ShiftSlot>>,
|
roles: Map<number, Set<ApiScheduleShiftSlot>>,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -133,7 +139,10 @@ type Stretch = {
|
||||||
spans: Span[];
|
spans: Span[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function* edgesFromEvents(events: Iterable<ScheduleEvent>, filter = (slot: TimeSlot) => true): Generator<Edge> {
|
function* edgesFromEvents(
|
||||||
|
events: Iterable<Extract<ApiScheduleEvent, { deleted?: false }>>,
|
||||||
|
filter = (slot: ApiScheduleEventSlot) => true,
|
||||||
|
): Generator<Edge> {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
for (const slot of event.slots.filter(filter)) {
|
for (const slot of event.slots.filter(filter)) {
|
||||||
if (slot.start > slot.end) {
|
if (slot.start > slot.end) {
|
||||||
|
@ -145,14 +154,17 @@ function* edgesFromEvents(events: Iterable<ScheduleEvent>, filter = (slot: TimeS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function* edgesFromShifts(shifts: Iterable<Shift>, filter = (slot: ShiftSlot) => true): Generator<Edge> {
|
function* edgesFromShifts(
|
||||||
|
shifts: Iterable<Extract<ApiScheduleShift, { deleted?: false }>>,
|
||||||
|
filter = (slot: ApiScheduleShiftSlot) => true,
|
||||||
|
): Generator<Edge> {
|
||||||
for (const shift of shifts) {
|
for (const shift of shifts) {
|
||||||
for (const slot of shift.slots.filter(filter)) {
|
for (const slot of shift.slots.filter(filter)) {
|
||||||
if (slot.start > slot.end) {
|
if (slot.start > slot.end) {
|
||||||
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
||||||
}
|
}
|
||||||
yield { type: "start", source: "shift", role: shift.role, slot };
|
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
|
||||||
yield { type: "end", source: "shift", role: shift.role, slot };
|
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -173,23 +185,25 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function* spansFromJunctions(
|
function* spansFromJunctions(
|
||||||
junctions: Iterable<Junction>, locations: ScheduleLocation[], roles: Role[] | undefined,
|
junctions: Iterable<Junction>,
|
||||||
|
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
|
||||||
|
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
|
||||||
): Generator<Span> {
|
): Generator<Span> {
|
||||||
const activeLocations = new Map(
|
const activeLocations = new Map(
|
||||||
locations.map(location => [location.id, new Set<TimeSlot>()])
|
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
|
||||||
);
|
);
|
||||||
const activeRoles = new Map(
|
const activeRoles = new Map(
|
||||||
roles?.map(role => [role.id, new Set<ShiftSlot>()])
|
roles?.map(role => [role.id, new Set<ApiScheduleShiftSlot>()])
|
||||||
);
|
);
|
||||||
for (const [start, end] of pairs(junctions)) {
|
for (const [start, end] of pairs(junctions)) {
|
||||||
for (const edge of start.edges) {
|
for (const edge of start.edges) {
|
||||||
if (edge.type === "start") {
|
if (edge.type === "start") {
|
||||||
if (edge.source === "event") {
|
if (edge.source === "event") {
|
||||||
for (const location of edge.slot.locations) {
|
for (const id of edge.slot.locationIds) {
|
||||||
activeLocations.get(location)?.add(edge.slot)
|
activeLocations.get(id)?.add(edge.slot)
|
||||||
}
|
}
|
||||||
} else if (edge.source === "shift") {
|
} else if (edge.source === "shift") {
|
||||||
activeRoles.get(edge.role)?.add(edge.slot)
|
activeRoles.get(edge.roleId)?.add(edge.slot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -210,11 +224,11 @@ function* spansFromJunctions(
|
||||||
for (const edge of end.edges) {
|
for (const edge of end.edges) {
|
||||||
if (edge.type === "end") {
|
if (edge.type === "end") {
|
||||||
if (edge.source === "event") {
|
if (edge.source === "event") {
|
||||||
for (const location of edge.slot.locations) {
|
for (const id of edge.slot.locationIds) {
|
||||||
activeLocations.get(location)?.delete(edge.slot)
|
activeLocations.get(id)?.delete(edge.slot)
|
||||||
}
|
}
|
||||||
} else if (edge.source === "shift") {
|
} else if (edge.source === "shift") {
|
||||||
activeRoles.get(edge.role)?.delete(edge.slot);
|
activeRoles.get(edge.roleId)?.delete(edge.slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,24 +339,24 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
|
||||||
|
|
||||||
function tableElementsFromStretches(
|
function tableElementsFromStretches(
|
||||||
stretches: Iterable<Stretch>,
|
stretches: Iterable<Stretch>,
|
||||||
events: ScheduleEvent[],
|
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
|
||||||
locations: ScheduleLocation[],
|
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
|
||||||
rota: Shift[] | undefined,
|
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
|
||||||
roles: Role[] | undefined,
|
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
type Col = { minutes?: number };
|
type Col = { minutes?: number };
|
||||||
type DayHead = { span: number, content?: string }
|
type DayHead = { span: number, content?: string }
|
||||||
type HourHead = { span: number, content?: string }
|
type HourHead = { span: number, content?: string }
|
||||||
type LocationCell = { span: number, slots: Set<TimeSlot>, title: string, crew?: boolean }
|
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
|
||||||
type RoleCell = { span: number, slots: Set<ShiftSlot>, title: string };
|
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, title: string };
|
||||||
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
||||||
const dayHeaders: DayHead[] = [];
|
const dayHeaders: DayHead[] = [];
|
||||||
const hourHeaders: HourHead[]= [];
|
const hourHeaders: HourHead[]= [];
|
||||||
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
|
const locationRows = new Map<number, LocationCell[]>(locations.map(location => [location.id, []]));
|
||||||
const roleRows = new Map<string, RoleCell[]>(roles?.map?.(role => [role.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 eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
|
||||||
const shiftBySlotId = new Map(rota?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
|
const shiftBySlotId = new Map(shifts?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
|
||||||
let totalColumns = 0;
|
let totalColumns = 0;
|
||||||
|
|
||||||
function startColumnGroup(className?: string) {
|
function startColumnGroup(className?: string) {
|
||||||
|
@ -354,7 +368,7 @@ function tableElementsFromStretches(
|
||||||
function startHour(content?: string) {
|
function startHour(content?: string) {
|
||||||
hourHeaders.push({ span: 0, content })
|
hourHeaders.push({ span: 0, content })
|
||||||
}
|
}
|
||||||
function startLocation(id: string, slots = new Set<TimeSlot>()) {
|
function startLocation(id: number, slots = new Set<ApiScheduleEventSlot>()) {
|
||||||
const rows = locationRows.get(id)!;
|
const rows = locationRows.get(id)!;
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
|
@ -363,7 +377,7 @@ function tableElementsFromStretches(
|
||||||
}
|
}
|
||||||
rows.push({ span: 0, slots, title: "" });
|
rows.push({ span: 0, slots, title: "" });
|
||||||
}
|
}
|
||||||
function startRole(id: string, slots = new Set<ShiftSlot>()) {
|
function startRole(id: number, slots = new Set<ApiScheduleShiftSlot>()) {
|
||||||
const rows = roleRows.get(id)!;
|
const rows = roleRows.get(id)!;
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
|
@ -487,21 +501,35 @@ function tableElementsFromStretches(
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
schedule: Schedule,
|
schedule: ApiSchedule,
|
||||||
eventSlotFilter?: (slot: TimeSlot) => boolean,
|
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
|
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
const schedule = computed(() => props.schedule);
|
const schedule = computed(() => props.schedule);
|
||||||
const junctions = computed(() => junctionsFromEdges([
|
const junctions = computed(() => {
|
||||||
...edgesFromEvents(schedule.value.events, props.eventSlotFilter),
|
if (schedule.value.deleted) {
|
||||||
...edgesFromShifts(schedule.value.rota ?? [], props.shiftSlotFilter),
|
throw Error("Unhandled deleted schedule");
|
||||||
]));
|
}
|
||||||
const stretches = computed(() => [
|
return junctionsFromEdges([
|
||||||
...stretchesFromSpans(
|
...edgesFromEvents(schedule.value.events?.filter(e => !e.deleted) ?? [], props.eventSlotFilter),
|
||||||
spansFromJunctions(junctions.value, schedule.value.locations, schedule.value.roles),
|
...edgesFromShifts(schedule.value.shifts?.filter(s => !s.deleted) ?? [], props.shiftSlotFilter),
|
||||||
oneHourMs * 5
|
])
|
||||||
)
|
});
|
||||||
])
|
const stretches = computed(() => {
|
||||||
|
if (schedule.value.deleted) {
|
||||||
|
throw Error("Unhandled deleted schedule");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...stretchesFromSpans(
|
||||||
|
spansFromJunctions(
|
||||||
|
junctions.value,
|
||||||
|
schedule.value.locations?.filter(l => !l.deleted) ?? [],
|
||||||
|
schedule.value.roles?.filter(r => !r.deleted) ?? [],
|
||||||
|
),
|
||||||
|
oneHourMs * 5
|
||||||
|
)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const timezone = computed({
|
const timezone = computed({
|
||||||
|
@ -509,9 +537,19 @@ const timezone = computed({
|
||||||
set: (value: string) => { accountStore.timezone = value },
|
set: (value: string) => { accountStore.timezone = value },
|
||||||
});
|
});
|
||||||
|
|
||||||
const elements = computed(() => tableElementsFromStretches(
|
const elements = computed(() => {
|
||||||
stretches.value, schedule.value.events, schedule.value.locations, schedule.value.rota, schedule.value.roles, accountStore.activeTimezone
|
if (schedule.value.deleted) {
|
||||||
));
|
throw Error("Unhandled deleted schedule");
|
||||||
|
}
|
||||||
|
return tableElementsFromStretches(
|
||||||
|
stretches.value,
|
||||||
|
schedule.value.events?.filter(e => !e.deleted) ?? [],
|
||||||
|
schedule.value.locations?.filter(l => !l.deleted) ?? [],
|
||||||
|
schedule.value.shifts?.filter(s => !s.deleted) ?? [],
|
||||||
|
schedule.value.roles?.filter(r => !r.deleted) ?? [],
|
||||||
|
accountStore.activeTimezone
|
||||||
|
);
|
||||||
|
});
|
||||||
const totalColumns = computed(() => elements.value.totalColumns);
|
const totalColumns = computed(() => elements.value.totalColumns);
|
||||||
const columnGroups = computed(() => elements.value.columnGroups);
|
const columnGroups = computed(() => elements.value.columnGroups);
|
||||||
const dayHeaders = computed(() => elements.value.dayHeaders);
|
const dayHeaders = computed(() => elements.value.dayHeaders);
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import type { Schedule } from "~/shared/types/schedule";
|
import type { ApiEvent } from "~/shared/types/api";
|
||||||
|
|
||||||
interface AppEventMap {
|
interface AppEventMap {
|
||||||
"open": Event,
|
"open": Event,
|
||||||
"message": MessageEvent<string>,
|
"message": MessageEvent<string>,
|
||||||
"update": MessageEvent<Schedule>,
|
"update": MessageEvent<ApiEvent>,
|
||||||
"error": Event,
|
"error": Event,
|
||||||
"close": Event,
|
"close": Event,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import type { Schedule } from '~/shared/types/schedule';
|
|
||||||
|
|
||||||
export const useSchedule = () => {
|
export const useSchedule = () => {
|
||||||
useEventSource();
|
useEventSource();
|
||||||
const schedulesStore = useSchedulesStore();
|
const schedulesStore = useSchedulesStore();
|
||||||
|
|
|
@ -16,7 +16,8 @@
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
"vue-router": "latest",
|
"vue-router": "latest",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7",
|
||||||
|
"zod": "^3.25.30"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
|
"packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main v-if="schedule.deleted">
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>
|
||||||
|
Schedule has been deleted.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<main v-else>
|
||||||
<h1>Edit</h1>
|
<h1>Edit</h1>
|
||||||
<label>
|
<label>
|
||||||
Crew Filter:
|
Crew Filter:
|
||||||
|
@ -31,7 +37,7 @@
|
||||||
:selected="locationFilter === undefined"
|
:selected="locationFilter === undefined"
|
||||||
><All locations></option>
|
><All locations></option>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations"
|
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:selected="locationFilter === location.id"
|
:selected="locationFilter === location.id"
|
||||||
|
@ -54,21 +60,21 @@
|
||||||
:selected="roleFilter === undefined"
|
:selected="roleFilter === undefined"
|
||||||
><All roles></option>
|
><All roles></option>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles"
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="roleFilter === role.id"
|
:selected="roleFilter === role.id"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<ShiftScheduleTable :edit="true" :role="roleFilter" :eventSlotFilter :shiftSlotFilter />
|
<ShiftScheduleTable :edit="true" :roleId="roleFilter" :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Shifts</h2>
|
<h2>Shifts</h2>
|
||||||
<ShiftsTable :edit="true" :role="roleFilter" />
|
<ShiftsTable :edit="true" :roleId="roleFilter" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ShiftSlot, TimeSlot } from '~/shared/types/schedule';
|
import type { ApiScheduleEventSlot, ApiScheduleShiftSlot } from '~/shared/types/api';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["authenticated"],
|
middleware: ["authenticated"],
|
||||||
|
@ -95,29 +101,29 @@ const eventSlotFilter = computed(() => {
|
||||||
return () => true;
|
return () => true;
|
||||||
}
|
}
|
||||||
const cid = parseInt(crewFilter.value);
|
const cid = parseInt(crewFilter.value);
|
||||||
return (slot: TimeSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === cid) || false;
|
||||||
});
|
});
|
||||||
const shiftSlotFilter = computed(() => {
|
const shiftSlotFilter = computed(() => {
|
||||||
if (crewFilter.value === undefined || !accountStore.valid) {
|
if (crewFilter.value === undefined || !accountStore.valid) {
|
||||||
return () => true;
|
return () => true;
|
||||||
}
|
}
|
||||||
const cid = parseInt(crewFilter.value);
|
const cid = parseInt(crewFilter.value);
|
||||||
return (slot: ShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const locationFilter = computed({
|
const locationFilter = computed({
|
||||||
get: () => queryToString(route.query.location),
|
get: () => queryToNumber(route.query.location),
|
||||||
set: (value: string | undefined) => navigateTo({
|
set: (value: number | undefined) => navigateTo({
|
||||||
path: route.path,
|
path: route.path,
|
||||||
query: {
|
query: {
|
||||||
...route.query,
|
...route.query,
|
||||||
location: value,
|
location: value !== undefined ? String(value) : undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleFilter = computed({
|
const roleFilter = computed({
|
||||||
get: () => queryToString(route.query.role),
|
get: () => queryToNumber(route.query.role),
|
||||||
set: (value: string | undefined) => navigateTo({
|
set: (value: string | undefined) => navigateTo({
|
||||||
path: route.path,
|
path: route.path,
|
||||||
query: {
|
query: {
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main v-if="schedule.deleted">
|
||||||
|
<h1>Error</h1>
|
||||||
|
<p>
|
||||||
|
Schedule has been deleted.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<main v-else>
|
||||||
<h1>Schedule & Events</h1>
|
<h1>Schedule & Events</h1>
|
||||||
<p>
|
<p>
|
||||||
Study carefully, we only hold these events once a year.
|
Study carefully, we only hold these events once a year.
|
||||||
|
@ -42,10 +48,10 @@
|
||||||
</label>
|
</label>
|
||||||
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<EventCard v-for="event in schedule.events.filter(e => e.slots.some(eventSlotFilter))" :event/>
|
<EventCard v-for="event in schedule.events?.filter(e => !e.deleted && e.slots.some(eventSlotFilter))" :event/>
|
||||||
<h2>Locations</h2>
|
<h2>Locations</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="location in schedule.locations" :key="location.id">
|
<li v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
|
||||||
<h3>{{ location.name }}</h3>
|
<h3>{{ location.name }}</h3>
|
||||||
{{ location.description ?? "No description provided" }}
|
{{ location.description ?? "No description provided" }}
|
||||||
</li>
|
</li>
|
||||||
|
@ -54,7 +60,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ShiftSlot, TimeSlot } from '~/shared/types/schedule';
|
import type { ApiScheduleShiftSlot, ApiScheduleEventSlot } from '~/shared/types/api';
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const { data: accounts } = await useAccounts();
|
const { data: accounts } = await useAccounts();
|
||||||
|
@ -73,27 +79,27 @@ const filter = computed({
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventSlotFilter = computed(() => {
|
const eventSlotFilter = computed(() => {
|
||||||
if (filter.value === undefined || !accountStore.valid) {
|
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
|
||||||
return () => true;
|
return () => true;
|
||||||
}
|
}
|
||||||
const aid = accountStore.id;
|
const aid = accountStore.id;
|
||||||
if (filter.value === "my-schedule") {
|
if (filter.value === "my-schedule") {
|
||||||
const ids = new Set(accountStore.interestedIds);
|
const slotIds = new Set(accountStore.interestedEventSlotIds);
|
||||||
for (const event of schedule.value.events) {
|
for (const event of schedule.value.events ?? []) {
|
||||||
if (ids.has(event.id)) {
|
if (!event.deleted && accountStore.interestedEventIds.has(event.id)) {
|
||||||
for (const slot of event.slots) {
|
for (const slot of event.slots) {
|
||||||
ids.add(slot.id);
|
slotIds.add(slot.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (slot: TimeSlot) => ids.has(slot.id) || slot.assigned?.some(id => id === aid) || false;
|
return (slot: ApiScheduleEventSlot) => slotIds.has(slot.id) || slot.assigned?.some(id => id === aid) || false;
|
||||||
}
|
}
|
||||||
if (filter.value === "assigned") {
|
if (filter.value === "assigned") {
|
||||||
return (slot: TimeSlot) => slot.assigned?.some(id => id === aid) || false;
|
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === aid) || false;
|
||||||
}
|
}
|
||||||
if (filter.value.startsWith("crew-")) {
|
if (filter.value.startsWith("crew-")) {
|
||||||
const cid = parseInt(filter.value.slice(5));
|
const cid = parseInt(filter.value.slice(5));
|
||||||
return (slot: TimeSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === cid) || false;
|
||||||
}
|
}
|
||||||
return () => false;
|
return () => false;
|
||||||
});
|
});
|
||||||
|
@ -103,11 +109,11 @@ const shiftSlotFilter = computed(() => {
|
||||||
}
|
}
|
||||||
if (filter.value === "my-schedule" || filter.value === "assigned") {
|
if (filter.value === "my-schedule" || filter.value === "assigned") {
|
||||||
const aid = accountStore.id;
|
const aid = accountStore.id;
|
||||||
return (slot: ShiftSlot) => slot.assigned?.some(id => id === aid) || false;
|
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === aid) || false;
|
||||||
}
|
}
|
||||||
if (filter.value.startsWith("crew-")) {
|
if (filter.value.startsWith("crew-")) {
|
||||||
const cid = parseInt(filter.value.slice(5));
|
const cid = parseInt(filter.value.slice(5));
|
||||||
return (slot: ShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
||||||
}
|
}
|
||||||
return () => false;
|
return () => false;
|
||||||
});
|
});
|
||||||
|
|
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
@ -29,6 +29,9 @@ importers:
|
||||||
web-push:
|
web-push:
|
||||||
specifier: ^3.6.7
|
specifier: ^3.6.7
|
||||||
version: 3.6.7
|
version: 3.6.7
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.30
|
||||||
|
version: 3.25.30
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/luxon':
|
'@types/luxon':
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
|
@ -3498,8 +3501,8 @@ packages:
|
||||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
zod@3.25.23:
|
zod@3.25.30:
|
||||||
resolution: {integrity: sha512-Od2bdMosahjSrSgJtakrwjMDb1zM1A3VIHCPGveZt/3/wlrTWBya2lmEh2OYe4OIu8mPTmmr0gnLHIWQXdtWBg==}
|
resolution: {integrity: sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
@ -3929,7 +3932,7 @@ snapshots:
|
||||||
unixify: 1.0.0
|
unixify: 1.0.0
|
||||||
urlpattern-polyfill: 8.0.2
|
urlpattern-polyfill: 8.0.2
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
zod: 3.25.23
|
zod: 3.25.30
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
- rollup
|
- rollup
|
||||||
|
@ -7318,4 +7321,4 @@ snapshots:
|
||||||
compress-commons: 6.0.2
|
compress-commons: 6.0.2
|
||||||
readable-stream: 4.7.0
|
readable-stream: 4.7.0
|
||||||
|
|
||||||
zod@3.25.23: {}
|
zod@3.25.30: {}
|
||||||
|
|
|
@ -1,39 +1,28 @@
|
||||||
import { Account } from "~/shared/types/account";
|
|
||||||
import { readAccounts, writeAccounts } from "~/server/database";
|
import { readAccounts, writeAccounts } from "~/server/database";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import { apiAccountPatchSchema } from "~/shared/types/api";
|
||||||
|
import { z } from "zod/v4-mini";
|
||||||
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireServerSession(event);
|
const session = await requireServerSession(event);
|
||||||
const body: Pick<Account, "interestedIds" | "timezone"> = await readBody(event);
|
const { success, error, data: patch } = apiAccountPatchSchema.safeParse(await readBody(event));
|
||||||
if (
|
if (!success) {
|
||||||
body.interestedIds !== undefined
|
|
||||||
&& (
|
|
||||||
!(body.interestedIds instanceof Array)
|
|
||||||
|| !body.interestedIds.every(id => typeof id === "string")
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
status: 400,
|
status: 400,
|
||||||
message: "Invalid interestedIds",
|
statusText: "Bad Request",
|
||||||
|
message: z.prettifyError(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.timezone !== undefined) {
|
if (patch.timezone?.length) {
|
||||||
if (typeof body.timezone !== "string") {
|
const zonedTime = DateTime.local({ locale: "en-US" }).setZone(patch.timezone);
|
||||||
|
if (!zonedTime.isValid) {
|
||||||
throw createError({
|
throw createError({
|
||||||
status: 400,
|
status: 400,
|
||||||
message: "Invalid timezone",
|
message: "Invalid timezone: " + zonedTime.invalidExplanation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (body.timezone.length) {
|
|
||||||
const zonedTime = DateTime.local({ locale: "en-US" }).setZone(body.timezone);
|
|
||||||
if (!zonedTime.isValid) {
|
|
||||||
throw createError({
|
|
||||||
status: 400,
|
|
||||||
message: "Invalid timezone: " + zonedTime.invalidExplanation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await readAccounts();
|
const accounts = await readAccounts();
|
||||||
|
@ -42,16 +31,23 @@ export default defineEventHandler(async (event) => {
|
||||||
throw Error("Account does not exist");
|
throw Error("Account does not exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.interestedIds !== undefined) {
|
if (patch.interestedEventIds !== undefined) {
|
||||||
if (body.interestedIds.length) {
|
if (patch.interestedEventIds.length) {
|
||||||
sessionAccount.interestedIds = body.interestedIds;
|
sessionAccount.interestedEventIds = patch.interestedEventIds;
|
||||||
} else {
|
} else {
|
||||||
delete sessionAccount.interestedIds;
|
delete sessionAccount.interestedEventIds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body.timezone !== undefined) {
|
if (patch.interestedEventSlotIds !== undefined) {
|
||||||
if (body.timezone)
|
if (patch.interestedEventSlotIds.length) {
|
||||||
sessionAccount.timezone = body.timezone;
|
sessionAccount.interestedEventSlotIds = patch.interestedEventSlotIds;
|
||||||
|
} else {
|
||||||
|
delete sessionAccount.interestedEventSlotIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.timezone !== undefined) {
|
||||||
|
if (patch.timezone)
|
||||||
|
sessionAccount.timezone = patch.timezone;
|
||||||
else
|
else
|
||||||
delete sessionAccount.timezone;
|
delete sessionAccount.timezone;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { readAccounts, writeAccounts, nextAccountId } from "~/server/database";
|
import { readAccounts, writeAccounts, nextAccountId } from "~/server/database";
|
||||||
import { Account } from "~/shared/types/account";
|
import type { ApiAccount } from "~/shared/types/api";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
let session = await getServerSession(event);
|
let session = await getServerSession(event);
|
||||||
|
@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
|
|
||||||
const accounts = await readAccounts();
|
const accounts = await readAccounts();
|
||||||
let account: Account;
|
let account: ApiAccount;
|
||||||
if (typeof name === "string") {
|
if (typeof name === "string") {
|
||||||
if (name === "") {
|
if (name === "") {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { readAccounts, readSubscriptions } from "~/server/database";
|
import { readAccounts, readSubscriptions } from "~/server/database";
|
||||||
import { AccountSession } from "~/shared/types/account";
|
import type { ApiSession } from "~/shared/types/api";
|
||||||
|
|
||||||
export default defineEventHandler(async (event): Promise<AccountSession | undefined> => {
|
export default defineEventHandler(async (event): Promise<ApiSession | undefined> => {
|
||||||
const session = await getServerSession(event);
|
const session = await getServerSession(event);
|
||||||
if (!session)
|
if (!session)
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -1,41 +1,8 @@
|
||||||
import type { SchedulePatch } from "~/shared/types/schedule";
|
import { z } from "zod/v4-mini";
|
||||||
import { readAccounts, readSchedule, writeSchedule } from "~/server/database";
|
import { readAccounts, readSchedule, writeSchedule } from "~/server/database";
|
||||||
import { broadcastUpdate } from "~/server/streams";
|
import { broadcastEvent } from "~/server/streams";
|
||||||
import { applyChangeArray } from "~/shared/utils/changes";
|
import { apiScheduleSchema } from "~/shared/types/api";
|
||||||
|
import { applyUpdatesToArray } from "~/shared/utils/update";
|
||||||
function isChange(change: unknown) {
|
|
||||||
return (
|
|
||||||
typeof change === "object"
|
|
||||||
&& change !== null
|
|
||||||
&& "op" in change
|
|
||||||
&& (
|
|
||||||
change.op === "set" || change.op === "del"
|
|
||||||
)
|
|
||||||
&& "data" in change
|
|
||||||
&& typeof change.data === "object"
|
|
||||||
&& change.data !== null
|
|
||||||
&& "id" in change.data
|
|
||||||
&& typeof change.data.id === "string"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChangeArray(data: unknown) {
|
|
||||||
return data instanceof Array && data.every(item => isChange(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPatch(data: unknown): SchedulePatch {
|
|
||||||
if (
|
|
||||||
typeof data !== "object"
|
|
||||||
|| data === null
|
|
||||||
|| data instanceof Array
|
|
||||||
|| "locations" in data && !isChangeArray(data.locations)
|
|
||||||
|| "events" in data && !isChangeArray(data.events)
|
|
||||||
|| "roles" in data && !isChangeArray(data.roles)
|
|
||||||
|| "rota" in data && !isChangeArray(data.rota)
|
|
||||||
)
|
|
||||||
throw new Error("Invalid patch data")
|
|
||||||
return data // TODO: Actually validate the whole structure with e.g ajv or zod
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireServerSession(event);
|
const session = await requireServerSession(event);
|
||||||
|
@ -53,22 +20,43 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { success, error, data: update } = apiScheduleSchema.safeParse(await readBody(event));
|
||||||
|
if (!success) {
|
||||||
|
throw createError({
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad Request",
|
||||||
|
message: z.prettifyError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update.deleted) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Not implemented",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const schedule = await readSchedule();
|
const schedule = await readSchedule();
|
||||||
const patch = await readValidatedBody(event, isPatch);
|
|
||||||
|
if (schedule.deleted) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Not implemented",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate edit restrictions for crew
|
// Validate edit restrictions for crew
|
||||||
if (account.type === "crew") {
|
if (account.type === "crew") {
|
||||||
if (patch.locations?.length) {
|
if (update.locations?.length) {
|
||||||
throw createError({
|
throw createError({
|
||||||
status: 403,
|
status: 403,
|
||||||
statusMessage: "Forbidden",
|
statusMessage: "Forbidden",
|
||||||
message: "Only admin accounts can edit locations.",
|
message: "Only admin accounts can edit locations.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const event of patch.events ?? []) {
|
for (const event of update.events ?? []) {
|
||||||
const id = event.op === "set" ? event.data.id : event.id;
|
const original = schedule.events?.find(e => e.id === event.id);
|
||||||
const original = schedule.events.find(e => e.id === id);
|
if (original && !original.deleted && !original.crew) {
|
||||||
if (original && !original.crew) {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
status: 403,
|
status: 403,
|
||||||
statusMessage: "Forbidden",
|
statusMessage: "Forbidden",
|
||||||
|
@ -78,11 +66,30 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.events) applyChangeArray(patch.events, schedule.events);
|
// Update schedule
|
||||||
if (patch.locations) applyChangeArray(patch.locations, schedule.locations);
|
const updatedFrom = schedule.updatedAt;
|
||||||
if (patch.roles) applyChangeArray(patch.roles, schedule.roles = schedule.roles ?? []);
|
update.updatedAt = new Date().toISOString();
|
||||||
if (patch.rota) applyChangeArray(patch.rota, schedule.rota = schedule.rota ?? []);
|
if (update.events) {
|
||||||
|
for (const event of update.events) event.updatedAt = update.updatedAt;
|
||||||
|
applyUpdatesToArray(update.events, schedule.events = schedule.events ?? []);
|
||||||
|
}
|
||||||
|
if (update.locations) {
|
||||||
|
for (const location of update.locations) location.updatedAt = update.updatedAt;
|
||||||
|
applyUpdatesToArray(update.locations, schedule.locations = schedule.locations ?? []);
|
||||||
|
}
|
||||||
|
if (update.roles) {
|
||||||
|
for (const role of update.roles) role.updatedAt = update.updatedAt;
|
||||||
|
applyUpdatesToArray(update.roles, schedule.roles = schedule.roles ?? []);
|
||||||
|
}
|
||||||
|
if (update.shifts) {
|
||||||
|
for (const shift of update.shifts) shift.updatedAt = update.updatedAt;
|
||||||
|
applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
await writeSchedule(schedule);
|
await writeSchedule(schedule);
|
||||||
await broadcastUpdate(schedule);
|
await broadcastEvent({
|
||||||
|
type: "schedule-update",
|
||||||
|
updatedFrom,
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { readAccounts, readSchedule } from "~/server/database";
|
import { readAccounts, readSchedule } from "~/server/database";
|
||||||
import { Account } from "~/shared/types/account";
|
import type { ApiAccount } from "~/shared/types/api";
|
||||||
import { canSeeCrew } from "../utils/schedule";
|
import { canSeeCrew } from "../utils/schedule";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await getServerSession(event);
|
const session = await getServerSession(event);
|
||||||
let account: Account | undefined;
|
let account: ApiAccount | undefined;
|
||||||
if (session) {
|
if (session) {
|
||||||
const accounts = await readAccounts()
|
const accounts = await readAccounts()
|
||||||
account = accounts.find(account => account.id === session.accountId);
|
account = accounts.find(account => account.id === session.accountId);
|
||||||
}
|
}
|
||||||
const schedule = await readSchedule();
|
const schedule = await readSchedule();
|
||||||
return canSeeCrew(account?.type) ? schedule : filterSchedule(schedule);
|
return canSeeCrew(account?.type) ? schedule : filterSchedule(schedule);
|
||||||
})
|
});
|
||||||
|
|
|
@ -1,14 +1,26 @@
|
||||||
import { readSubscriptions, writeSubscriptions } from "~/server/database";
|
import { readSubscriptions, writeSubscriptions } from "~/server/database";
|
||||||
import { Subscription } from "~/shared/types/account";
|
import { type ApiSubscription, apiSubscriptionSchema } from "~/shared/types/api";
|
||||||
|
import { z } from "zod/v4-mini";
|
||||||
|
|
||||||
|
const subscriptionSchema = z.strictObject({
|
||||||
|
subscription: apiSubscriptionSchema.def.shape.push,
|
||||||
|
});
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = await requireServerSession(event);
|
const session = await requireServerSession(event);
|
||||||
const body: { subscription: PushSubscriptionJSON } = await readBody(event);
|
const { success, error, data: body } = subscriptionSchema.safeParse(await readBody(event));
|
||||||
|
if (!success) {
|
||||||
|
throw createError({
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad Request",
|
||||||
|
message: z.prettifyError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
const subscriptions = await readSubscriptions();
|
const subscriptions = await readSubscriptions();
|
||||||
const existingIndex = subscriptions.findIndex(
|
const existingIndex = subscriptions.findIndex(
|
||||||
sub => sub.type === "push" && sub.sessionId === session.id
|
sub => sub.type === "push" && sub.sessionId === session.id
|
||||||
);
|
);
|
||||||
const subscription: Subscription = {
|
const subscription: ApiSubscription = {
|
||||||
type: "push",
|
type: "push",
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
push: body.subscription
|
push: body.subscription
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { Schedule } from "~/shared/types/schedule";
|
import type { ApiAccount, ApiSchedule, ApiSubscription } from "~/shared/types/api";
|
||||||
import { Account, Subscription } from "~/shared/types/account";
|
|
||||||
import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule";
|
import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule";
|
||||||
|
|
||||||
export interface ServerSession {
|
export interface ServerSession {
|
||||||
|
@ -51,12 +50,12 @@ export async function readSchedule() {
|
||||||
return readJson(schedulePath, generateDemoSchedule);
|
return readJson(schedulePath, generateDemoSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSchedule(schedule: Schedule) {
|
export async function writeSchedule(schedule: ApiSchedule) {
|
||||||
await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
|
await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readSubscriptions() {
|
export async function readSubscriptions() {
|
||||||
let subscriptions = await readJson<Subscription[]>(subscriptionsPath, []);
|
let subscriptions = await readJson<ApiSubscription[]>(subscriptionsPath, []);
|
||||||
if (subscriptions.length && "keys" in subscriptions[0]) {
|
if (subscriptions.length && "keys" in subscriptions[0]) {
|
||||||
// Discard old format
|
// Discard old format
|
||||||
subscriptions = [];
|
subscriptions = [];
|
||||||
|
@ -64,7 +63,7 @@ export async function readSubscriptions() {
|
||||||
return subscriptions;
|
return subscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeSubscriptions(subscriptions: Subscription[]) {
|
export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
|
||||||
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +80,7 @@ export async function readAccounts() {
|
||||||
return await readJson(accountsPath, generateDemoAccounts);
|
return await readJson(accountsPath, generateDemoAccounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeAccounts(accounts: Account[]) {
|
export async function writeAccounts(accounts: ApiAccount[]) {
|
||||||
await writeFile(accountsPath, JSON.stringify(accounts, undefined, "\t") + "\n", "utf-8");
|
await writeFile(accountsPath, JSON.stringify(accounts, undefined, "\t") + "\n", "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,30 +1,41 @@
|
||||||
import { Account } from "~/shared/types/account";
|
import type { ApiAccount, ApiSchedule, ApiScheduleEventSlot, ApiScheduleShiftSlot } from "~/shared/types/api";
|
||||||
import { Role, Schedule, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
|
|
||||||
import { toId } from "~/shared/utils/functions";
|
import { toId } from "~/shared/utils/functions";
|
||||||
|
|
||||||
const locations = [
|
const locations = [
|
||||||
{
|
{
|
||||||
|
id: 1,
|
||||||
name: "Stage",
|
name: "Stage",
|
||||||
description: "Inside the main building."
|
description: "Inside the main building.",
|
||||||
|
updatedAt: "d0 18:21",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 2,
|
||||||
name: "Clubhouse",
|
name: "Clubhouse",
|
||||||
description: "That big red building in the middle of the park."
|
description: "That big red building in the middle of the park.",
|
||||||
|
updatedAt: "d0 18:25",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 4,
|
||||||
name: "Summerhouse",
|
name: "Summerhouse",
|
||||||
description: "Next to the campfire by the lake"
|
description: "Next to the campfire by the lake",
|
||||||
|
updatedAt: "d0 18:22",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 6,
|
||||||
name: "Campfire",
|
name: "Campfire",
|
||||||
description: "Next to the big tree by the lake."
|
description: "Next to the big tree by the lake.",
|
||||||
|
updatedAt: "d0 18:41",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 7,
|
||||||
name: "Outside",
|
name: "Outside",
|
||||||
description: "Takes place somewhere outside."
|
description: "Takes place somewhere outside.",
|
||||||
|
updatedAt: "d0 18:37",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
let slotId = 1;
|
||||||
|
let eventId = 1;
|
||||||
const events = [
|
const events = [
|
||||||
{
|
{
|
||||||
name: "Arcade",
|
name: "Arcade",
|
||||||
|
@ -133,17 +144,24 @@ const events = [
|
||||||
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
|
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
|
||||||
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16:00 1h clubhouse"]},
|
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16:00 1h clubhouse"]},
|
||||||
{ name: "Feedback Panel", slots: ["d5 12:00 1h clubhouse"]},
|
{ name: "Feedback Panel", slots: ["d5 12:00 1h clubhouse"]},
|
||||||
];
|
].map(({ slots, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
id: eventId++,
|
||||||
|
slots: slots.map(slot => `${slot} ${slotId++}`),
|
||||||
|
}));
|
||||||
|
|
||||||
const roles: Role[] = [
|
|
||||||
{ id: "medic", name: "Medic" },
|
const idMedic = 1;
|
||||||
{ id: "security", name: "Security" },
|
const idSecurity = 2;
|
||||||
|
const roles = [
|
||||||
|
{ id: idMedic, name: "Medic", updatedAt: "d1 12:34" },
|
||||||
|
{ id: idSecurity, name: "Security", updatedAt: "d1 12:39" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const rota = [
|
const shifts = [
|
||||||
{
|
{
|
||||||
name: "Medic Early",
|
name: "Medic Early",
|
||||||
role: "medic",
|
roleId: idMedic,
|
||||||
slots: [
|
slots: [
|
||||||
"d1 12:00 4h",
|
"d1 12:00 4h",
|
||||||
"d2 12:00 4h",
|
"d2 12:00 4h",
|
||||||
|
@ -154,7 +172,7 @@ const rota = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Medic Late",
|
name: "Medic Late",
|
||||||
role: "medic",
|
roleId: idMedic,
|
||||||
slots: [
|
slots: [
|
||||||
"d1 16:00 7h",
|
"d1 16:00 7h",
|
||||||
"d2 16:00 6h",
|
"d2 16:00 6h",
|
||||||
|
@ -164,7 +182,7 @@ const rota = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Security Early",
|
name: "Security Early",
|
||||||
role: "security",
|
roleId: idSecurity,
|
||||||
slots: [
|
slots: [
|
||||||
"d1 12:00 6h",
|
"d1 12:00 6h",
|
||||||
"d2 12:00 6h",
|
"d2 12:00 6h",
|
||||||
|
@ -175,7 +193,7 @@ const rota = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Security Late",
|
name: "Security Late",
|
||||||
role: "security",
|
roleId: idSecurity,
|
||||||
slots: [
|
slots: [
|
||||||
"d1 18:00 5h",
|
"d1 18:00 5h",
|
||||||
"d2 18:00 4h",
|
"d2 18:00 4h",
|
||||||
|
@ -183,13 +201,17 @@ const rota = [
|
||||||
"d4 18:00 5h",
|
"d4 18:00 5h",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
].map(({ slots, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
id: eventId++,
|
||||||
|
slots: slots.map(slot => `${slot} ${slotId++}`),
|
||||||
|
}));
|
||||||
|
|
||||||
function toIso(date: Date) {
|
function toIso(date: Date) {
|
||||||
return date.toISOString().replace(":00.000Z", "Z");
|
return date.toISOString().replace(":00.000Z", "Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDates(origin: Date, day: string, start: string, duration: string) {
|
function toDate(origin: Date, day: string, start: string) {
|
||||||
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
|
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
|
||||||
const dayNumber = parseInt(day.slice(1));
|
const dayNumber = parseInt(day.slice(1));
|
||||||
|
|
||||||
|
@ -198,6 +220,11 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
|
||||||
startDate.setUTCHours(startDate.getUTCHours() + startHours);
|
startDate.setUTCHours(startDate.getUTCHours() + startHours);
|
||||||
startDate.setUTCMinutes(startDate.getUTCMinutes() + startMinutes);
|
startDate.setUTCMinutes(startDate.getUTCMinutes() + startMinutes);
|
||||||
|
|
||||||
|
return startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDates(origin: Date, day: string, start: string, duration: string) {
|
||||||
|
const startDate = toDate(origin, day, start);
|
||||||
const [_, durationHours, durationMinutes] = /(?:(\d+)h)?(?:(\d+)m)?/.exec(duration)!;
|
const [_, durationHours, durationMinutes] = /(?:(\d+)h)?(?:(\d+)m)?/.exec(duration)!;
|
||||||
const durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
|
const durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
|
||||||
const endDate = new Date(startDate.getTime() + durationTotal * 60e3);
|
const endDate = new Date(startDate.getTime() + durationTotal * 60e3);
|
||||||
|
@ -205,33 +232,35 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
|
||||||
return [startDate, endDate];
|
return [startDate, endDate];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>, idToAssigned: Map<string, number[]>): TimeSlot {
|
function toSlot(origin: Date, shorthand: string, counts: Map<number, number>, idToAssigned: Map<number, number[]>): ApiScheduleEventSlot {
|
||||||
const [day, start, duration, location] = shorthand.split(" ");
|
const [day, start, duration, location, idStr] = shorthand.split(" ");
|
||||||
const [startDate, endDate] = toDates(origin, day, start, duration);
|
const [startDate, endDate] = toDates(origin, day, start, duration);
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${id}-${index}`,
|
id,
|
||||||
start: toIso(startDate),
|
start: toIso(startDate),
|
||||||
end: toIso(endDate),
|
end: toIso(endDate),
|
||||||
locations: [location],
|
locationIds: [locations.find(l => toId(l.name) === location)!.id],
|
||||||
assigned: idToAssigned.get(`${id}-${index}`),
|
assigned: idToAssigned.get(id),
|
||||||
interested: counts.get(`${id}-${index}`),
|
interested: counts.get(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toShift(origin: Date, id: string, shorthand: string, index: number, idToAssigned: Map<string, number[]>): ShiftSlot {
|
function toShift(origin: Date, shorthand: string, idToAssigned: Map<number, number[]>): ApiScheduleShiftSlot {
|
||||||
const [day, start, duration] = shorthand.split(" ");
|
const [day, start, duration, idStr] = shorthand.split(" ");
|
||||||
const [startDate, endDate] = toDates(origin, day, start, duration);
|
const [startDate, endDate] = toDates(origin, day, start, duration);
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${id}-${index}`,
|
id,
|
||||||
start: toIso(startDate),
|
start: toIso(startDate),
|
||||||
end: toIso(endDate),
|
end: toIso(endDate),
|
||||||
assigned: idToAssigned.get(`${id}-${index}`),
|
assigned: idToAssigned.get(id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDemoSchedule(): Schedule {
|
export function generateDemoSchedule(): ApiSchedule {
|
||||||
const origin = new Date();
|
const origin = new Date();
|
||||||
const utcOffset = 1;
|
const utcOffset = 1;
|
||||||
origin.setUTCDate(origin.getUTCDate() - origin.getUTCDay() + 1); // Go to Monday
|
origin.setUTCDate(origin.getUTCDate() - origin.getUTCDay() + 1); // Go to Monday
|
||||||
|
@ -240,101 +269,98 @@ export function generateDemoSchedule(): Schedule {
|
||||||
origin.setUTCSeconds(0);
|
origin.setUTCSeconds(0);
|
||||||
origin.setUTCMilliseconds(0);
|
origin.setUTCMilliseconds(0);
|
||||||
|
|
||||||
const counts = new Map<string, number>()
|
const eventCounts = new Map<number, number>()
|
||||||
|
const slotCounts = new Map<number, number>()
|
||||||
const accounts = generateDemoAccounts();
|
const accounts = generateDemoAccounts();
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
for (const id of account.interestedIds ?? []) {
|
for (const id of account.interestedEventIds ?? []) {
|
||||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const id of account.interestedEventSlotIds ?? []) {
|
||||||
|
slotCounts.set(id, (slotCounts.get(id) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seed = 2;
|
function assignSlots(events: { id: number, slots: string[] }[], count: number) {
|
||||||
const idToAssigned = new Map<string, number[]>();
|
const idToAssigned = new Map<number, number[]>();
|
||||||
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
||||||
const assignedIds: string[] = [];
|
const assignedIds = new Set<number>;
|
||||||
const slotsToAdd = Math.floor(random() * 20);
|
const usedEvents = new Set<number>;
|
||||||
while (assignedIds.length < slotsToAdd) {
|
const slotsToAdd = Math.floor(random() * count);
|
||||||
const event = events[Math.floor(random() * events.length)];
|
while (assignedIds.size < slotsToAdd) {
|
||||||
const eventId = toId(event.name);
|
const event = events[Math.floor(random() * events.length)];
|
||||||
if (assignedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
|
if (usedEvents.has(event.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
if (event.slots.length === 1 || random() < 0.8) {
|
|
||||||
for (const index of event.slots.map((_, index) => index)) {
|
|
||||||
assignedIds.push(toId(`${toId(event.name)}-${index}`));
|
|
||||||
}
|
}
|
||||||
} else {
|
if (event.slots.length === 1 || random() < 0.8) {
|
||||||
for (const index of event.slots.map((_, index) => index)) {
|
for (const slot of event.slots) {
|
||||||
if (random() < 0.5) {
|
const id = parseInt(slot.split(" ").slice(-1)[0]);
|
||||||
assignedIds.push(toId(`${toId(event.name)}-${index}`));
|
assignedIds.add(id);
|
||||||
|
usedEvents.add(event.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const slot of event.slots) {
|
||||||
|
if (random() < 0.5) {
|
||||||
|
const id = parseInt(slot.split(" ").slice(-1)[0]);
|
||||||
|
assignedIds.add(id);
|
||||||
|
usedEvents.add(event.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
for (const id of assignedIds) {
|
||||||
for (const id of assignedIds) {
|
const assigned = idToAssigned.get(id);
|
||||||
const assigned = idToAssigned.get(id);
|
if (assigned) {
|
||||||
if (assigned) {
|
assigned.push(account.id);
|
||||||
assigned.push(account.id);
|
} else {
|
||||||
} else {
|
idToAssigned.set(id, [account.id]);
|
||||||
idToAssigned.set(id, [account.id]);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return idToAssigned;
|
||||||
}
|
}
|
||||||
|
seed = 2;
|
||||||
|
const eventSlotIdToAssigned = assignSlots(events, 20);
|
||||||
|
|
||||||
seed = 5;
|
seed = 5;
|
||||||
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
const shiftSlotIdToAssigned = assignSlots(shifts, 3);
|
||||||
const assignedIds: string[] = [];
|
|
||||||
const slotsToAdd = Math.floor(random() * 3);
|
|
||||||
while (assignedIds.length < slotsToAdd) {
|
|
||||||
const shift = rota[Math.floor(random() * rota.length)];
|
|
||||||
const shiftId = toId(shift.name);
|
|
||||||
if (assignedIds.some(id => id.replace(/-\d+$/, "") === shiftId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (shift.slots.length === 1 || random() < 0.8) {
|
|
||||||
for (const index of shift.slots.map((_, index) => index)) {
|
|
||||||
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const index of shift.slots.map((_, index) => index)) {
|
|
||||||
if (random() < 0.5) {
|
|
||||||
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of assignedIds) {
|
|
||||||
const assigned = idToAssigned.get(id);
|
|
||||||
if (assigned) {
|
|
||||||
assigned.push(account.id);
|
|
||||||
} else {
|
|
||||||
idToAssigned.set(id, [account.id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: 111,
|
||||||
|
updatedAt: toIso(toDate(origin, "d2", "10:01")),
|
||||||
events: events.map(
|
events: events.map(
|
||||||
({ name, crew, description, slots }) => ({
|
({ id, name, crew, description, slots }) => ({
|
||||||
id: toId(name),
|
id,
|
||||||
name,
|
name,
|
||||||
crew,
|
crew,
|
||||||
description,
|
description,
|
||||||
interested: counts.get(toId(name)),
|
interested: eventCounts.get(id),
|
||||||
slots: slots.map((shorthand, index) => toSlot(origin, toId(name), shorthand, index, counts, idToAssigned))
|
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
|
||||||
|
updatedAt: toIso(toDate(origin, "d0", "15:11")),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
locations: locations.map(
|
locations: locations.map(
|
||||||
({ name, description }) => ({ id: toId(name), name, description })
|
({ id, name, description, updatedAt }) => ({
|
||||||
),
|
id,
|
||||||
roles,
|
|
||||||
rota: rota.map(
|
|
||||||
({ name, role, slots }) => ({
|
|
||||||
id: toId(name),
|
|
||||||
name,
|
name,
|
||||||
role,
|
description,
|
||||||
slots: slots.map((shorthand, index) => toShift(origin, toId(name), shorthand, index, idToAssigned))
|
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
roles: roles.map(
|
||||||
|
({ id, name, updatedAt }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
shifts: shifts.map(
|
||||||
|
({ id, name, roleId, slots }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
roleId,
|
||||||
|
slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)),
|
||||||
|
updatedAt: toIso(toDate(origin, "d0", "13:23")),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
@ -364,9 +390,9 @@ function random() {
|
||||||
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
|
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDemoAccounts(): Account[] {
|
export function generateDemoAccounts(): ApiAccount[] {
|
||||||
seed = 1;
|
seed = 1;
|
||||||
const accounts: Account[] = [];
|
const accounts: ApiAccount[] = [];
|
||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
accounts.push({
|
accounts.push({
|
||||||
|
@ -378,38 +404,45 @@ export function generateDemoAccounts(): Account[] {
|
||||||
|
|
||||||
seed = 1;
|
seed = 1;
|
||||||
// These have a much higher probability of being in someone's interested list.
|
// These have a much higher probability of being in someone's interested list.
|
||||||
const desiredEvent = ["opening", "closing", "fursuit-games"];
|
const desiredEvent = ["opening", "closing", "fursuit-games"].map(
|
||||||
|
id => events.find(e => toId(e.name) === id)!.id
|
||||||
|
);
|
||||||
const nonCrewEvents = events.filter(event => !event.crew);
|
const nonCrewEvents = events.filter(event => !event.crew);
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const interestedIds: string[] = [];
|
const interestedEventIds = new Set<number>;
|
||||||
|
const interestedSlotIds = new Set<number>;
|
||||||
|
const usedEvents = new Set<number>;
|
||||||
for (const id of desiredEvent) {
|
for (const id of desiredEvent) {
|
||||||
if (random() < 0.5) {
|
if (random() < 0.5) {
|
||||||
interestedIds.push(id);
|
interestedEventIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsToAdd = Math.floor(random() * 10);
|
const eventsToAdd = Math.floor(random() * 10);
|
||||||
while (interestedIds.length < eventsToAdd) {
|
while (interestedEventIds.size + interestedSlotIds.size < eventsToAdd) {
|
||||||
const event = nonCrewEvents[Math.floor(random() * nonCrewEvents.length)];
|
const event = nonCrewEvents[Math.floor(random() * nonCrewEvents.length)];
|
||||||
const eventId = toId(event.name);
|
if (usedEvents.has(event.id)) {
|
||||||
if (interestedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.slots.length === 1 || random() < 0.8) {
|
if (event.slots.length === 1 || random() < 0.8) {
|
||||||
interestedIds.push(toId(event.name))
|
interestedEventIds.add(event.id)
|
||||||
} else {
|
} else {
|
||||||
for (const index of event.slots.map((_, index) => index)) {
|
for (const slot of event.slots) {
|
||||||
if (random() < 0.5) {
|
if (random() < 0.5) {
|
||||||
interestedIds.push(toId(`${toId(event.name)}-${index}`));
|
const id = parseInt(slot.split(" ")[4], 10);
|
||||||
|
interestedSlotIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interestedIds.length) {
|
if (interestedEventIds.size) {
|
||||||
account.interestedIds = interestedIds;
|
account.interestedEventIds = [...interestedEventIds];
|
||||||
|
}
|
||||||
|
if (interestedSlotIds.size) {
|
||||||
|
account.interestedEventSlotIds = [...interestedSlotIds];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return accounts;
|
return accounts;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Schedule } from "~/shared/types/schedule"
|
|
||||||
import { readAccounts } from "~/server/database";
|
import { readAccounts } from "~/server/database";
|
||||||
import { canSeeCrew } from "./utils/schedule";
|
import { canSeeCrew } from "./utils/schedule";
|
||||||
|
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
|
||||||
|
|
||||||
function sendMessage(
|
function sendMessage(
|
||||||
stream: WritableStream<string>,
|
stream: WritableStream<string>,
|
||||||
|
@ -65,22 +65,58 @@ export function cancelSessionStreams(sessionId: number) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function broadcastUpdate(schedule: Schedule) {
|
const encodeEventCache = new WeakMap<ApiEvent, Map<ApiAccount["type"] | undefined, string>>();
|
||||||
|
function encodeEvent(event: ApiEvent, accountType: ApiAccount["type"] | undefined) {
|
||||||
|
const cache = encodeEventCache.get(event);
|
||||||
|
const cacheEntry = cache?.get(accountType);
|
||||||
|
if (cacheEntry) {
|
||||||
|
return cacheEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: string;
|
||||||
|
if (event.type === "schedule-update") {
|
||||||
|
if (!canSeeCrew(accountType)) {
|
||||||
|
event = {
|
||||||
|
type: event.type,
|
||||||
|
updatedFrom: event.updatedFrom,
|
||||||
|
data: filterSchedule(event.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
data = JSON.stringify(event);
|
||||||
|
} else {
|
||||||
|
throw Error(`encodeEvent cannot encode ${event.type} event`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache) {
|
||||||
|
cache.set(accountType, data);
|
||||||
|
} else {
|
||||||
|
encodeEventCache.set(event, new Map([[accountType, data]]));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function broadcastEvent(event: ApiEvent) {
|
||||||
const id = Date.now();
|
const id = Date.now();
|
||||||
console.log(`broadcasting update to ${streams.size} clients`);
|
console.log(`broadcasting update to ${streams.size} clients`);
|
||||||
if (!streams.size) {
|
if (!streams.size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accounts = await readAccounts();
|
const accounts = await readAccounts();
|
||||||
const filteredSchedule = filterSchedule(schedule);
|
|
||||||
for (const [stream, streamData] of streams) {
|
for (const [stream, streamData] of streams) {
|
||||||
let accountType: string | undefined;
|
// Account events are specially handled and only sent to the account they belong to.
|
||||||
if (streamData.accountId !== undefined) {
|
if (event.type === "account-update") {
|
||||||
accountType = accounts.find(a => a.id === streamData.accountId)?.type
|
if (streamData.accountId === event.data.id) {
|
||||||
|
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${JSON.stringify(event)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
let accountType: ApiAccount["type"] | undefined;
|
||||||
|
if (streamData.accountId !== undefined) {
|
||||||
|
accountType = accounts.find(a => a.id === streamData.accountId)?.type
|
||||||
|
}
|
||||||
|
const data = encodeEvent(event, accountType)
|
||||||
|
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
|
||||||
}
|
}
|
||||||
const data = JSON.stringify(canSeeCrew(accountType) ? schedule : filteredSchedule);
|
|
||||||
const message = `id: ${id}\nevent: update\ndata: ${data}\n\n`
|
|
||||||
sendMessage(stream, message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,61 @@
|
||||||
import { Account } from '~/shared/types/account';
|
|
||||||
import { Schedule } from '~/shared/types/schedule';
|
|
||||||
import { readSchedule, writeSchedule } from '~/server/database';
|
import { readSchedule, writeSchedule } from '~/server/database';
|
||||||
import { broadcastUpdate } from '~/server/streams';
|
import { broadcastEvent } from '~/server/streams';
|
||||||
|
import type { ApiAccount, ApiSchedule } from '~/shared/types/api';
|
||||||
|
|
||||||
export async function updateScheduleInterestedCounts(accounts: Account[]) {
|
export async function updateScheduleInterestedCounts(accounts: ApiAccount[]) {
|
||||||
const counts = new Map();
|
const eventCounts = new Map<number, number>();
|
||||||
for (const account of accounts)
|
const eventSlotCounts = new Map<number, number>();
|
||||||
if (account.interestedIds)
|
for (const account of accounts) {
|
||||||
for (const id of account.interestedIds)
|
if (account.interestedEventIds)
|
||||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
for (const id of account.interestedEventIds)
|
||||||
|
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
|
||||||
|
if (account.interestedEventSlotIds)
|
||||||
|
for (const id of account.interestedEventSlotIds)
|
||||||
|
eventSlotCounts.set(id, (eventSlotCounts.get(id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
const schedule = await readSchedule();
|
const schedule = await readSchedule();
|
||||||
for (const event of schedule.events) {
|
if (schedule.deleted) {
|
||||||
event.interested = counts.get(event.id);
|
throw new Error("Deleted schedule not implemented");
|
||||||
|
}
|
||||||
|
const update: ApiSchedule = {
|
||||||
|
id: schedule.id,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
const updatedFrom = schedule.updatedAt;
|
||||||
|
for (const event of schedule.events ?? []) {
|
||||||
|
let modified = false;
|
||||||
|
if (event.deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let count = eventCounts.get(event.id);
|
||||||
|
if (count !== event.interested) {
|
||||||
|
event.interested = eventCounts.get(event.id);
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
for (const slot of event.slots) {
|
for (const slot of event.slots) {
|
||||||
slot.interested = counts.get(slot.id);
|
let slotCount = eventSlotCounts.get(slot.id);
|
||||||
|
if (slotCount !== slot.interested) {
|
||||||
|
slot.interested = slotCount;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modified) {
|
||||||
|
event.updatedAt = update.updatedAt;
|
||||||
|
update.events!.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!update.events!.length) {
|
||||||
|
return; // No changes
|
||||||
|
}
|
||||||
|
schedule.updatedAt = updatedFrom;
|
||||||
await writeSchedule(schedule);
|
await writeSchedule(schedule);
|
||||||
await broadcastUpdate(schedule);
|
await broadcastEvent({
|
||||||
|
type: "schedule-update",
|
||||||
|
updatedFrom,
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canSeeCrew(accountType: string | undefined) {
|
export function canSeeCrew(accountType: string | undefined) {
|
||||||
|
@ -26,17 +63,28 @@ export function canSeeCrew(accountType: string | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters out crew visible only parts of schedule */
|
/** Filters out crew visible only parts of schedule */
|
||||||
export function filterSchedule(schedule: Schedule): Schedule {
|
export function filterSchedule(schedule: ApiSchedule): ApiSchedule {
|
||||||
|
if (schedule.deleted) {
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
id: schedule.id,
|
||||||
|
updatedAt: schedule.updatedAt,
|
||||||
locations: schedule.locations,
|
locations: schedule.locations,
|
||||||
events: schedule.events
|
events: (schedule.events ?? [])
|
||||||
.filter(event => !event.crew)
|
.map(event => (
|
||||||
.map(event => ({
|
event.deleted
|
||||||
...event,
|
? event
|
||||||
slots: event.slots.map(slot => ({
|
: event.crew
|
||||||
...slot,
|
// Pretend crew events are deleted.
|
||||||
assigned: undefined,
|
? { id: event.id, deleted: true, updatedAt: event.updatedAt }
|
||||||
})),
|
: {
|
||||||
|
...event,
|
||||||
|
slots: event.slots.map(slot => ({
|
||||||
|
...slot,
|
||||||
|
assigned: undefined,
|
||||||
|
}
|
||||||
|
)),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
import { nextSessionId, readSessions, readSubscriptions, ServerSession, writeSessions, writeSubscriptions } from "~/server/database";
|
import { nextSessionId, readSessions, readSubscriptions, type ServerSession, writeSessions, writeSubscriptions } from "~/server/database";
|
||||||
|
|
||||||
const oneYearSeconds = 365 * 24 * 60 * 60;
|
const oneYearSeconds = 365 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
|
25
shared/types/account.d.ts
vendored
25
shared/types/account.d.ts
vendored
|
@ -1,25 +0,0 @@
|
||||||
export interface Account {
|
|
||||||
id: number,
|
|
||||||
type: "anonymous" | "regular" | "crew" | "admin",
|
|
||||||
/** Name of the account. Not present on anonymous accounts */
|
|
||||||
name?: string,
|
|
||||||
interestedIds?: string[],
|
|
||||||
timezone?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Subscription {
|
|
||||||
type: "push",
|
|
||||||
sessionId: number,
|
|
||||||
push: PushSubscriptionJSON,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
id: number,
|
|
||||||
accountId: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountSession {
|
|
||||||
id: number,
|
|
||||||
account: Account,
|
|
||||||
push: boolean,
|
|
||||||
}
|
|
111
shared/types/api.ts
Normal file
111
shared/types/api.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { z } from "zod/v4-mini";
|
||||||
|
import { defineEntity, idSchema, type Id } from "~/shared/types/common";
|
||||||
|
|
||||||
|
export interface ApiAccount {
|
||||||
|
id: Id,
|
||||||
|
type: "anonymous" | "regular" | "crew" | "admin",
|
||||||
|
/** Name of the account. Not present on anonymous accounts */
|
||||||
|
name?: string,
|
||||||
|
interestedEventIds?: number[],
|
||||||
|
interestedEventSlotIds?: number[],
|
||||||
|
timezone?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAccountPatchSchema = z.object({
|
||||||
|
name: z.optional(z.string()),
|
||||||
|
interestedEventIds: z.optional(z.array(z.number())),
|
||||||
|
interestedEventSlotIds: z.optional(z.array(z.number())),
|
||||||
|
timezone: z.optional(z.string()),
|
||||||
|
});
|
||||||
|
export type ApiAccountPatch = z.infer<typeof apiAccountPatchSchema>;
|
||||||
|
|
||||||
|
export const apiSubscriptionSchema = z.object({
|
||||||
|
type: z.literal("push"),
|
||||||
|
sessionId: z.number(),
|
||||||
|
push: z.object({
|
||||||
|
endpoint: z.optional(z.string()),
|
||||||
|
expirationTime: z.nullish(z.number()),
|
||||||
|
keys: z.record(z.string(), z.string()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type ApiSubscription = z.infer<typeof apiSubscriptionSchema>;
|
||||||
|
|
||||||
|
export interface ApiSession {
|
||||||
|
id: Id,
|
||||||
|
account?: ApiAccount,
|
||||||
|
push: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiScheduleLocationSchema = defineEntity({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.optional(z.string()),
|
||||||
|
});
|
||||||
|
export type ApiScheduleLocation = z.infer<typeof apiScheduleLocationSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleEventSlotSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
start: z.string(),
|
||||||
|
end: z.string(),
|
||||||
|
locationIds: z.array(idSchema),
|
||||||
|
assigned: z.optional(z.array(z.number())),
|
||||||
|
interested: z.optional(z.number()),
|
||||||
|
});
|
||||||
|
export type ApiScheduleEventSlot = z.infer<typeof apiScheduleEventSlotSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleEventSchema = defineEntity({
|
||||||
|
name: z.string(),
|
||||||
|
crew: z.optional(z.boolean()),
|
||||||
|
host: z.optional(z.string()),
|
||||||
|
cancelled: z.optional(z.boolean()),
|
||||||
|
description: z.optional(z.string()),
|
||||||
|
interested: z.optional(z.number()),
|
||||||
|
slots: z.array(apiScheduleEventSlotSchema),
|
||||||
|
});
|
||||||
|
export type ApiScheduleEvent = z.infer<typeof apiScheduleEventSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleRoleSchema = defineEntity({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.optional(z.string()),
|
||||||
|
});
|
||||||
|
export type ApiScheduleRole = z.infer<typeof apiScheduleRoleSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleShiftSlotSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
start: z.string(),
|
||||||
|
end: z.string(),
|
||||||
|
assigned: z.optional(z.array(z.number())),
|
||||||
|
});
|
||||||
|
export type ApiScheduleShiftSlot = z.infer<typeof apiScheduleShiftSlotSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleShiftSchema = defineEntity({
|
||||||
|
roleId: idSchema,
|
||||||
|
name: z.string(),
|
||||||
|
description: z.optional(z.string()),
|
||||||
|
slots: z.array(apiScheduleShiftSlotSchema),
|
||||||
|
});
|
||||||
|
export type ApiScheduleShift = z.infer<typeof apiScheduleShiftSchema>;
|
||||||
|
|
||||||
|
export const apiScheduleSchema = defineEntity({
|
||||||
|
id: z.literal(111),
|
||||||
|
locations: z.optional(z.array(apiScheduleLocationSchema)),
|
||||||
|
events: z.optional(z.array(apiScheduleEventSchema)),
|
||||||
|
roles: z.optional(z.array(apiScheduleRoleSchema)),
|
||||||
|
shifts: z.optional(z.array(apiScheduleShiftSchema)),
|
||||||
|
});
|
||||||
|
export type ApiSchedule = z.infer<typeof apiScheduleSchema>;
|
||||||
|
|
||||||
|
export interface ApiAccountUpdate {
|
||||||
|
type: "account-update",
|
||||||
|
data: ApiAccount,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiScheduleUpdate {
|
||||||
|
type: "schedule-update",
|
||||||
|
updatedFrom?: string,
|
||||||
|
data: ApiSchedule,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiEvent =
|
||||||
|
| ApiAccountUpdate
|
||||||
|
| ApiScheduleUpdate
|
||||||
|
;
|
25
shared/types/common.ts
Normal file
25
shared/types/common.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { z } from "zod/v4-mini";
|
||||||
|
|
||||||
|
export const idSchema = z.number();
|
||||||
|
export type Id = z.infer<typeof idSchema>;
|
||||||
|
|
||||||
|
export const entityLivingSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
updatedAt: z.string(),
|
||||||
|
deleted: z.optional(z.literal(false)),
|
||||||
|
});
|
||||||
|
export type EnityLiving = z.infer<typeof entityLivingSchema>;
|
||||||
|
|
||||||
|
export const entityToombstoneSchema = z.object({
|
||||||
|
id: idSchema,
|
||||||
|
updatedAt: z.string(),
|
||||||
|
deleted: z.literal(true),
|
||||||
|
});
|
||||||
|
export type EntityToombstone = z.infer<typeof entityToombstoneSchema>;
|
||||||
|
|
||||||
|
export const entitySchema = z.discriminatedUnion("deleted", [entityLivingSchema, entityToombstoneSchema]);
|
||||||
|
export type Entity = z.infer<typeof entitySchema>;
|
||||||
|
|
||||||
|
export function defineEntity<T extends {}>(fields: T) {
|
||||||
|
return z.discriminatedUnion("deleted", [z.extend(entityLivingSchema, fields), entityToombstoneSchema]);
|
||||||
|
}
|
65
shared/types/schedule.d.ts
vendored
65
shared/types/schedule.d.ts
vendored
|
@ -1,65 +0,0 @@
|
||||||
export interface ScheduleEvent {
|
|
||||||
name: string,
|
|
||||||
id: string,
|
|
||||||
crew?: boolean,
|
|
||||||
host?: string,
|
|
||||||
cancelled?: boolean,
|
|
||||||
description?: string,
|
|
||||||
interested?: number,
|
|
||||||
slots: TimeSlot[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleLocation {
|
|
||||||
name: string,
|
|
||||||
id: string,
|
|
||||||
description?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeSlot {
|
|
||||||
id: string,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
locations: string[],
|
|
||||||
assigned?: number[],
|
|
||||||
interested?: number,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Shift {
|
|
||||||
name: string,
|
|
||||||
id: string,
|
|
||||||
role: string,
|
|
||||||
description?: string,
|
|
||||||
slots: ShiftSlot[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Role {
|
|
||||||
name: string,
|
|
||||||
id: string,
|
|
||||||
description?: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShiftSlot {
|
|
||||||
id: string,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
assigned?: number[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Schedule {
|
|
||||||
locations: ScheduleLocation[],
|
|
||||||
events: ScheduleEvent[],
|
|
||||||
roles?: Role[],
|
|
||||||
rota?: Shift[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChangeRecord<T extends { id: string }> =
|
|
||||||
| { op: "set", data: T }
|
|
||||||
| { op: "del", data: { id: string }}
|
|
||||||
;
|
|
||||||
|
|
||||||
export interface SchedulePatch {
|
|
||||||
locations?: ChangeRecord<ScheduleLocation>[],
|
|
||||||
events?: ChangeRecord<ScheduleEvent>[],
|
|
||||||
roles?: ChangeRecord<Role>[],
|
|
||||||
rota?: ChangeRecord<Shift>[],
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
import type { ChangeRecord } from "~/shared/types/schedule";
|
|
||||||
|
|
||||||
export function applyChange<T extends { id: string }>(change: ChangeRecord<T>, data: T[]) {
|
|
||||||
const index = data.findIndex(item => item.id === change.data.id);
|
|
||||||
if (change.op === "del") {
|
|
||||||
if (index !== -1)
|
|
||||||
data.splice(index, 1);
|
|
||||||
} else if (change.op === "set") {
|
|
||||||
if (index !== -1)
|
|
||||||
data.splice(index, 1, change.data);
|
|
||||||
else
|
|
||||||
data.push(change.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyChangeArray<T extends { id: string }>(changes: ChangeRecord<T>[], data: T[]) {
|
|
||||||
// Note: quadratic complexity due to findIndex in applyChange
|
|
||||||
for (const change of changes) {
|
|
||||||
applyChange(change, data);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,6 +6,15 @@ export function* enumerate<T>(iterable: Iterable<T>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filters an iterable based on the passed predicate function */
|
||||||
|
export function* filter<T, S extends T>(it: Iterable<T>, predicate: (value: T) => value is S) {
|
||||||
|
for (const value of it) {
|
||||||
|
if (predicate(value)) {
|
||||||
|
yield value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Converts a name to an id */
|
/** Converts a name to an id */
|
||||||
export function toId(name: string) {
|
export function toId(name: string) {
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
|
14
shared/utils/update.ts
Normal file
14
shared/utils/update.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Entity } from "~/shared/types/common";
|
||||||
|
|
||||||
|
export function applyUpdatesToArray<T extends Entity>(updates: T[], entities: T[]) {
|
||||||
|
const idMap = new Map(entities.map((e, i) => [e.id, i]));
|
||||||
|
for (const update of updates) {
|
||||||
|
const index = idMap.get(update.id);
|
||||||
|
if (index !== undefined) {
|
||||||
|
entities[index] = update;
|
||||||
|
} else {
|
||||||
|
idMap.set(update.id, entities.length);
|
||||||
|
entities.push(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Account } from "~/shared/types/account";
|
import type { ApiAccount, ApiAccountPatch } from "~/shared/types/api";
|
||||||
|
|
||||||
export const useAccountStore = defineStore("account", () => {
|
export const useAccountStore = defineStore("account", () => {
|
||||||
const runtimeConfig = useRuntimeConfig();
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
@ -8,8 +8,9 @@ export const useAccountStore = defineStore("account", () => {
|
||||||
id: ref<number>(),
|
id: ref<number>(),
|
||||||
name: ref<string>(),
|
name: ref<string>(),
|
||||||
timezone: ref<string>(),
|
timezone: ref<string>(),
|
||||||
type: ref<Account["type"]>(),
|
type: ref<ApiAccount["type"]>(),
|
||||||
interestedIds: ref<Set<string>>(new Set()),
|
interestedEventIds: ref<Set<number>>(new Set()),
|
||||||
|
interestedEventSlotIds: ref<Set<number>>(new Set()),
|
||||||
};
|
};
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
|
@ -19,7 +20,8 @@ export const useAccountStore = defineStore("account", () => {
|
||||||
state.name.value = account?.name;
|
state.name.value = account?.name;
|
||||||
state.timezone.value = account?.timezone;
|
state.timezone.value = account?.timezone;
|
||||||
state.type.value = account?.type;
|
state.type.value = account?.type;
|
||||||
state.interestedIds.value = new Set(account?.interestedIds ?? []);
|
state.interestedEventIds.value = new Set(account?.interestedEventIds ?? []);
|
||||||
|
state.interestedEventSlotIds.value = new Set(account?.interestedEventSlotIds ?? []);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getters = {
|
const getters = {
|
||||||
|
@ -31,23 +33,34 @@ export const useAccountStore = defineStore("account", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
async toggleInterestedId(id: string, slotIds?: string[]) {
|
async toggleInterestedId(type: "event" | "slot", id: number, slotIds?: number[]) {
|
||||||
if (!state.interestedIds.value) {
|
let newEventIds = new Set(state.interestedEventIds.value);
|
||||||
throw Error("accountStore.toggleInterestedId: Invalid state")
|
let newSlotIds = new Set(state.interestedEventSlotIds.value);
|
||||||
}
|
if (type === "event") {
|
||||||
let newIds = [...state.interestedIds.value ?? []];
|
if (newEventIds.has(id)) {
|
||||||
if (state.interestedIds.value.has(id)) {
|
newEventIds.delete(id)
|
||||||
newIds = newIds.filter(newId => newId !== id);
|
} else {
|
||||||
} else {
|
newEventIds.add(id);
|
||||||
newIds.push(id);
|
if (slotIds) {
|
||||||
if (slotIds) {
|
for (const slotId of slotIds) {
|
||||||
const filterIds = new Set(slotIds);
|
newSlotIds.delete(slotId)
|
||||||
newIds = newIds.filter(newId => !filterIds.has(newId));
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (type === "slot") {
|
||||||
|
if (newSlotIds.has(id)) {
|
||||||
|
newSlotIds.delete(id);
|
||||||
|
} else {
|
||||||
|
newSlotIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const patch: ApiAccountPatch = {
|
||||||
|
interestedEventIds: [...newEventIds],
|
||||||
|
interestedEventSlotIds: [...newSlotIds],
|
||||||
}
|
}
|
||||||
await $fetch("/api/auth/account", {
|
await $fetch("/api/auth/account", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { interestedIds: newIds },
|
body: patch,
|
||||||
})
|
})
|
||||||
await sessionStore.fetch();
|
await sessionStore.fetch();
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { Schedule } from "~/shared/types/schedule";
|
import type { ApiSchedule } from "~/shared/types/api";
|
||||||
|
import { applyUpdatesToArray } from "~/shared/utils/update";
|
||||||
|
|
||||||
interface SyncOperation {
|
interface SyncOperation {
|
||||||
controller: AbortController,
|
controller: AbortController,
|
||||||
promise: Promise<Ref<Schedule>>,
|
promise: Promise<Ref<ApiSchedule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSchedulesStore = defineStore("schedules", () => {
|
export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
|
@ -10,7 +11,7 @@ export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
activeScheduleId: ref<number | undefined>(111),
|
activeScheduleId: ref<number | undefined>(111),
|
||||||
schedules: ref<Map<number, Ref<Schedule>>>(new Map()),
|
schedules: ref<Map<number, Ref<ApiSchedule>>>(new Map()),
|
||||||
pendingSyncs: ref<Map<number, SyncOperation>>(new Map()),
|
pendingSyncs: ref<Map<number, SyncOperation>>(new Map()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -82,9 +83,25 @@ export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
appEventSource?.addEventListener("update", (event) => {
|
appEventSource?.addEventListener("update", (event) => {
|
||||||
|
if (event.data.type !== "schedule-update") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const schedule = state.schedules.value.get(111);
|
const schedule = state.schedules.value.get(111);
|
||||||
if (schedule) {
|
const update = event.data.data;
|
||||||
schedule.value = event.data;
|
// XXX validate updatedFrom/updatedAt here
|
||||||
|
if (schedule && !schedule.value.deleted && !update.deleted) {
|
||||||
|
if (update.locations) {
|
||||||
|
applyUpdatesToArray(update.locations, schedule.value.locations = schedule.value.locations ?? []);
|
||||||
|
}
|
||||||
|
if (update.events) {
|
||||||
|
applyUpdatesToArray(update.events, schedule.value.events = schedule.value.events ?? []);
|
||||||
|
}
|
||||||
|
if (update.roles) {
|
||||||
|
applyUpdatesToArray(update.roles, schedule.value.roles = schedule.value.roles ?? []);
|
||||||
|
}
|
||||||
|
if (update.shifts) {
|
||||||
|
applyUpdatesToArray(update.shifts, schedule.value.shifts = schedule.value.shifts ?? []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { appendResponseHeader } from "h3";
|
import { appendResponseHeader } from "h3";
|
||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
import type { Account } from "~/shared/types/account";
|
import type { ApiAccount } from "~/shared/types/api";
|
||||||
|
|
||||||
const fetchSessionWithCookie = async (event?: H3Event) => {
|
const fetchSessionWithCookie = async (event?: H3Event) => {
|
||||||
// Client side
|
// Client side
|
||||||
|
@ -21,7 +21,7 @@ const fetchSessionWithCookie = async (event?: H3Event) => {
|
||||||
|
|
||||||
export const useSessionStore = defineStore("session", () => {
|
export const useSessionStore = defineStore("session", () => {
|
||||||
const state = {
|
const state = {
|
||||||
account: ref<Account>(),
|
account: ref<ApiAccount>(),
|
||||||
id: ref<number>(),
|
id: ref<number>(),
|
||||||
push: ref<boolean>(false),
|
push: ref<boolean>(false),
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,3 +7,11 @@ export function queryToString(item?: null | LocationQueryValue | LocationQueryVa
|
||||||
return queryToString(item[0])
|
return queryToString(item[0])
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queryToNumber(item?: null | LocationQueryValue | LocationQueryValue[]) {
|
||||||
|
if (item === null || item === undefined)
|
||||||
|
return undefined;
|
||||||
|
if (item instanceof Array)
|
||||||
|
return queryToNumber(item[0])
|
||||||
|
return Number.parseInt(item, 10);
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue