Refactor API types and sync logic
All checks were successful
/ build (push) Successful in 2m5s
/ deploy (push) Successful in 16s

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:
Hornwitser 2025-06-11 21:05:17 +02:00
parent 251e83f640
commit fe06d0d6bd
36 changed files with 1242 additions and 834 deletions

View file

@ -1,5 +1,10 @@
<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>
<p>{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested">
@ -8,10 +13,10 @@
<p v-if="accountStore.valid">
<button
class="interested"
:class="{ active: accountStore.interestedIds.has(event.id) }"
@click="toggle(event.id, event.slots.map(slot => slot.id))"
:class="{ active: accountStore.interestedEventIds.has(event.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>
</p>
@ -22,11 +27,11 @@
<button
v-if="accountStore.valid && event.slots.length > 1"
class="interested"
:disabled="accountStore.interestedIds.has(event.id)"
:class="{ active: accountStore.interestedIds.has(event.id) || accountStore.interestedIds.has(slot.id) }"
@click="toggle(slot.id)"
:disabled="accountStore.interestedEventIds.has(event.id)"
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(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>
<template v-if="slot.interested">
({{ slot.interested }} interested)
@ -42,10 +47,10 @@
<script lang="ts" setup>
import { DateTime } from 'luxon';
import type { ScheduleEvent } from '~/shared/types/schedule';
import type { ApiScheduleEvent } from '~/shared/types/api';
defineProps<{
event: ScheduleEvent
event: ApiScheduleEvent
}>()
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");
}
async function toggle(id: string, slotIds?: string[]) {
await accountStore.toggleInterestedId(id, slotIds);
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
await accountStore.toggleInterestedId(type, id, slotIds);
}
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -14,7 +17,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="event in events"
v-for="event in events.filter(e => !e.deleted)"
:key="event.id"
:class="{ removed: removed.has(event.id) }"
>
@ -52,7 +55,7 @@
@click="delEvent(event.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === event.id)"
v-if="changes.some(c => c.id === event.id)"
type="button"
@click="revertEvent(event.id)"
>Revert</button>
@ -95,7 +98,7 @@
</template>
<template v-else>
<tr
v-for="event in events"
v-for="event in events.filter(e => !e.deleted)"
:key="event.id"
>
<td>{{ event.id }}</td>
@ -126,9 +129,9 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, ScheduleEvent } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleEvent } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
defineProps<{
edit?: boolean,
@ -137,18 +140,18 @@ defineProps<{
const schedule = await useSchedule();
const accountStore = useAccountStore();
function canEdit(event: ScheduleEvent) {
return event.crew || accountStore.canEditPublic;
function canEdit(event: ApiScheduleEvent) {
return !event.deleted && (event.crew || accountStore.canEditPublic);
}
const changes = ref<ChangeRecord<ScheduleEvent>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleEvent[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<ScheduleEvent>,
changes: ChangeRecord<ScheduleEvent>[],
change: ApiScheduleEvent,
changes: ApiScheduleEvent[],
) {
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];
if (index !== -1)
@ -157,15 +160,15 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<ScheduleEvent>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleEvent[]) {
return changes.filter(change => change.id !== id);
}
const newEventName = ref("");
const newEventDescription = ref("");
const newEventPublic = ref(false);
function editEvent(
event: ScheduleEvent,
event: Extract<ApiScheduleEvent, { deleted?: false }>,
edits: { name?: string, description?: string, crew?: boolean }
) {
const copy = { ...event };
@ -178,21 +181,23 @@ function editEvent(
if (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);
}
function delEvent(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertEvent(id: string) {
function revertEvent(id: number) {
changes.value = revertChange(id, changes.value);
}
function eventExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.events.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.events?.some(e => !e.deleted && toId(e.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newEvent() {
@ -200,15 +205,17 @@ function newEvent() {
alert(`Event ${newEventName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const id = Math.max(1, ...schedule.value.events?.map(e => e.id) ?? []) + 1;
const change = {
op: "set" as const,
data: {
id: toId(newEventName.value),
name: newEventName.value,
description: newEventDescription.value || undefined,
crew: !newEventPublic.value || undefined,
slots: [],
},
id,
updatedAt: "",
name: newEventName.value,
description: newEventDescription.value || undefined,
crew: !newEventPublic.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newEventName.value = "";
@ -219,7 +226,11 @@ async function saveEvents() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { events: changes.value },
body: {
id: 111,
updatedAt: "",
events: changes.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -229,8 +240,11 @@ async function saveEvents() {
}
const events = computed(() => {
const data = [...schedule.value.events];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.events ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -11,7 +11,7 @@
</thead>
<tbody>
<tr
v-for="location in locations"
v-for="location in locations.filter(l => !l.deleted)"
:key="location.id"
:class="{ removed: removed.has(location.id) }"
>
@ -38,7 +38,7 @@
@click="delLocation(location.id)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === location.id)"
v-if="changes.some(c => c.id === location.id)"
type="button"
@click="revertLocation(location.id)"
>Revert</button>
@ -52,7 +52,7 @@
</tr>
<tr v-if='edit'>
<td>
{{ toId(newLocationName) }}
{{ newLocationId }}
</td>
<td>
<input
@ -88,9 +88,9 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, ScheduleLocation } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleLocation } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
defineProps<{
edit?: boolean
@ -98,15 +98,15 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ChangeRecord<ScheduleLocation>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleLocation[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<ScheduleLocation>,
changes: ChangeRecord<ScheduleLocation>[],
change: ApiScheduleLocation,
changes: ApiScheduleLocation[],
) {
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];
if (index !== -1)
@ -115,29 +115,36 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<ScheduleLocation>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleLocation[]) {
return changes.filter(change => change.id !== id);
}
const newLocationName = ref("");
function setLocation(location: ScheduleLocation) {
const change = { op: "set" as const, data: location };
const newLocationId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.locations?.map(l => l.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
function setLocation(location: ApiScheduleLocation) {
changes.value = replaceChange(location, changes.value);
}
function delLocation(id: number) {
const change = { id, updatedAt: "", deleted: true as const };
changes.value = replaceChange(change, changes.value);
}
function delLocation(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertLocation(id: string) {
function revertLocation(id: number) {
changes.value = revertChange(id, changes.value);
}
function newLocation(name: string) {
const change = {
op: "set" as const,
data: {
id: toId(name),
name,
},
id: newLocationId.value,
updatedAt: "",
name,
};
changes.value = replaceChange(change, changes.value);
}
@ -145,7 +152,11 @@ async function saveLocations() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { locations: changes.value },
body: {
id: 111,
updatedAt: "",
locations: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -155,8 +166,11 @@ async function saveLocations() {
}
const locations = computed(() => {
const data = [...schedule.value.locations];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.locations ?? []];
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -12,7 +15,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="role in roles"
v-for="role in roles.filter(r => !r.deleted)"
:key="role.id"
:class="{ removed: removed.has(role.id) }"
>
@ -38,14 +41,14 @@
@click="delRole(role.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === role.id)"
v-if="changes.some(c => c.id === role.id)"
type="button"
@click="revertRole(role.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ toId(newRoleName) }}</td>
<td>{{ newRoleId }}</td>
<td>
<input
type="text"
@ -73,7 +76,7 @@
</template>
<template v-else>
<tr
v-for="role in roles"
v-for="role in roles.filter(r => !r.deleted)"
:key="role.id"
>
<td>{{ role.id }}</td>
@ -102,8 +105,8 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, Role } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleRole } from '~/shared/types/api';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { toId } from '~/shared/utils/functions';
defineProps<{
@ -112,14 +115,14 @@ defineProps<{
const schedule = await useSchedule();
const changes = ref<ChangeRecord<Role>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleRole[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<Role>,
changes: ChangeRecord<Role>[],
change: ApiScheduleRole,
changes: ApiScheduleRole[],
) {
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];
if (index !== -1)
@ -128,14 +131,24 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<Role>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleRole[]) {
return changes.filter(change => change.id !== id);
}
const newRoleName = ref("");
const newRoleId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.roles?.map(r => r.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
const newRoleDescription = ref("");
function editRole(
role: Role,
role: Extract<ApiScheduleRole, { deleted?: false }>,
edits: { name?: string, description?: string }
) {
const copy = { ...role };
@ -145,21 +158,23 @@ function editRole(
if (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);
}
function delRole(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertRole(id: string) {
function revertRole(id: number) {
changes.value = revertChange(id, changes.value);
}
function roleExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.roles?.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.roles?.some(r => !r.deleted && toId(r.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newRole() {
@ -167,14 +182,15 @@ function newRole() {
alert(`Role ${newRoleName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const change = {
op: "set" as const,
data: {
id: toId(newRoleName.value),
name: newRoleName.value,
description: newRoleDescription.value || undefined,
slots: [],
},
id: newRoleId.value,
updatedAt: "",
name: newRoleName.value,
description: newRoleDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newRoleName.value = "";
@ -184,7 +200,11 @@ async function saveRoles() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { roles: changes.value },
body: {
id: 111,
updatedAt: "",
roles: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -194,8 +214,11 @@ async function saveRoles() {
}
const roles = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.roles ?? []];
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
@ -47,7 +50,7 @@
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations"
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
@ -99,14 +102,14 @@
<td>{{ status(es) }}</td>
<td>
<select
:value="es.location"
@change="editEventSlot(es, { location: ($event as any).target.value })"
:value="es.locationId"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations"
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="location.id === es.location"
:selected="location.id === es.locationId"
>{{ location.name }}</option>
</select>
</td>
@ -124,7 +127,7 @@
@click="delEventSlot(es)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === es.id)"
v-if="changes.some(c => c.id === es.id)"
type="button"
@click="revertEventSlot(es.id)"
>Revert</button>
@ -186,7 +189,7 @@
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.location }}</td>
<td>{{ es.locationId }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
</template>
</tr>
@ -205,13 +208,13 @@
<b>EventSlot changes</b>
<ol>
<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>
</ol>
<b>ScheduleEvent changes</b>
<ol>
<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>
</ol>
</details>
@ -220,25 +223,28 @@
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
import type { ChangeRecord, Schedule, ScheduleEvent, ScheduleLocation, ShiftSlot, TimeSlot } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
import type { Entity } from '~/shared/types/common';
import { enumerate, pairs } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
const props = defineProps<{
edit?: boolean,
location?: string,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
locationId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
interface EventSlot {
type: "slot",
id: string,
event?: ScheduleEvent,
slot?: TimeSlot,
origLocation: string,
id: number,
updatedAt: string,
deleted?: boolean,
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
slot?: ApiScheduleEventSlot,
origLocation: number,
name: string,
location: string,
locationId: number,
assigned: number[],
start: DateTime,
end: DateTime,
@ -250,57 +256,69 @@ interface Gap {
event?: undefined,
slot?: undefined,
name?: undefined,
location?: string,
locationId?: number,
start: DateTime,
end: DateTime,
}
function status(eventSlot: EventSlot) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (
!eventSlot.event
|| eventSlot.event.name !== eventSlot.name
) {
const event = schedule.value.events.find(event => event.name === eventSlot.name);
const event = schedule.value.events?.find(event => !event.deleted && event.name === eventSlot.name);
return event ? "L" : "N";
}
return eventSlot.event.slots.length === 1 ? "" : eventSlot.event.slots.length;
}
// Filter out set records where a del record exists for the same id.
function filterSetOps<T extends { op: "set" | "del", data: { id: string }}>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
function filterSetOps<T extends Entity>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
}
function findEvent(
eventSlot: EventSlot,
changes: ChangeRecord<ScheduleEvent>[],
schedule: Schedule,
changes: ApiScheduleEvent[],
schedule: ApiSchedule,
) {
let setEvent = changes.find(
c => c.op === "set" && c.data.name === eventSlot.name
)?.data as ScheduleEvent | undefined;
if (schedule.deleted) {
throw new Error("Unexpected deleted schedule");
}
let setEvent = changes.filter(
c => !c.deleted
).find(
c => c.name === eventSlot.name
);
if (!setEvent && eventSlot.event && eventSlot.event.name === eventSlot.name) {
setEvent = eventSlot.event;
}
if (!setEvent) {
setEvent = schedule.events.find(e => e.name === eventSlot.name);
setEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.name);
}
let delEvent;
if (eventSlot.event) {
delEvent = changes.find(
c => c.op === "set" && c.data.name === eventSlot.event!.name
)?.data as ScheduleEvent | undefined;
delEvent = changes.filter(c => !c.deleted).find(
c => c.name === eventSlot.event!.name
);
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 };
}
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 (oldSlot.locations.length === 1 && oldSlot.locations[0] === location) {
if (oldSlot.locationIds.length === 1 && oldSlot.locationIds[0] === locationId) {
return {
...event,
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(
s => s.id !== oldSlot.id ? 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 nextId = Math.max(-1, ...event.slots.map(s => {
const id = /-(\d+)$/.exec(s.id)?.[1];
return id ? parseInt(id) : 0;
})) + 1;
const nextId = Math.max(0, ...schedule.value.events?.filter(e => !e.deleted).flatMap(e => e.slots.map(slot => slot.id)) ?? []) + 1;
const start = eventSlot.start.toUTC().toISO({ suppressSeconds: true })!;
const end = eventSlot.end.toUTC().toISO({ suppressSeconds: true })!;
@ -332,7 +353,7 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
oldSlot
&& oldSlot.id === eventSlot.id
&& (
oldSlot.locations.length <= 1
oldSlot.locationIds.length <= 1
|| oldSlot.start === start && oldSlot.end === end
)
) {
@ -341,12 +362,12 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
slots: event.slots.map(s => {
if (s.id !== oldSlot.id)
return s;
const locations = new Set(s.locations);
locations.delete(eventSlot.origLocation)
locations.add(eventSlot.location);
const locationIds = new Set(s.locationIds);
locationIds.delete(eventSlot.origLocation)
locationIds.add(eventSlot.locationId);
return {
...s,
locations: [...locations],
locationIds: [...locationIds],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
@ -363,8 +384,8 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
return {
...event,
slots: [...event.slots, {
id: oldSlot ? oldSlot.id : `${event.id}-${nextId}`,
locations: [eventSlot.location],
id: oldSlot ? oldSlot.id : nextId,
locationIds: [eventSlot.locationId],
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
start,
end,
@ -373,31 +394,29 @@ function mergeSlot(event: ScheduleEvent, eventSlot: EventSlot): ScheduleEvent {
}
const scheduleChanges = computed(() => {
let eventChanges: ChangeRecord<ScheduleEvent>[] = [];
let eventChanges: Extract<ApiScheduleEvent, { deleted?: false}>[] = [];
for (const change of filterSetOps(changes.value)) {
if (change.op === "set") {
let { setEvent, delEvent } = findEvent(change.data, eventChanges, schedule.value);
if (!change.deleted) {
let { setEvent, delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent && delEvent !== setEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change.data);
eventChanges = removeSlot(eventChanges, delEvent, change);
}
if (!setEvent) {
setEvent = {
id: toId(change.data.name),
name: change.data.name,
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
crew: true,
slots: [],
};
}
eventChanges = replaceChange({
op: "set",
data: mergeSlot(setEvent, change.data),
}, eventChanges);
eventChanges = replaceChange(mergeSlot(setEvent, change), eventChanges);
} else if (change.op === "del") {
let { delEvent } = findEvent(change.data, eventChanges, schedule.value);
} else if (change.deleted) {
let { delEvent } = findEvent(change, eventChanges, schedule.value);
if (delEvent) {
eventChanges = removeSlot(eventChanges, delEvent, change.data);
eventChanges = removeSlot(eventChanges, delEvent, change);
}
}
}
@ -405,21 +424,28 @@ const scheduleChanges = computed(() => {
});
const schedulePreview = computed(() => {
const events = [...schedule.value.events]
applyChangeArray(scheduleChanges.value, events);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const events = [...schedule.value.events ?? []]
applyUpdatesToArray(scheduleChanges.value, events);
return {
...schedule.value,
events,
};
});
function removeSlot(eventChanges: 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);
if (oldSlot) {
eventChanges = replaceChange({
op: "set",
data: removeSlotLocation(event, oldSlot, eventSlot.origLocation),
}, eventChanges);
eventChanges = replaceChange(
removeSlotLocation(event, oldSlot, eventSlot.origLocation),
eventChanges,
);
}
return eventChanges;
}
@ -427,17 +453,15 @@ function removeSlot(eventChanges: ChangeRecord<ScheduleEvent>[], event: Schedule
const accountStore = useAccountStore();
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[]>([]);
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 }}>(
function replaceChange<T extends Entity>(
change: T,
changes: T[],
) {
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];
if (index !== -1)
@ -446,8 +470,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
copy.push(change);
return copy;
}
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
return changes.filter(change => change.data.id !== id);
function revertChange<T extends Entity>(id: number, changes: T[]) {
return changes.filter(change => change.id !== id);
}
const oneDayMs = 24 * 60 * 60 * 1000;
@ -472,9 +496,9 @@ const newEventEnd = computed({
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newEventLocation = ref(props.location);
watch(() => props.location, () => {
newEventLocation.value = props.location;
const newEventLocation = ref(props.locationId);
watch(() => props.locationId, () => {
newEventLocation.value = props.locationId;
});
function endFromTime(start: DateTime, time: string) {
@ -499,7 +523,7 @@ function editEventSlot(
end?: string,
duration?: string,
name?: string,
location?: string,
locationId?: number,
assigned?: number[],
}
) {
@ -512,7 +536,6 @@ function editEventSlot(
};
}
if (edits.end !== undefined) {
eventSlot = {
...eventSlot,
end: endFromTime(eventSlot.start, edits.end),
@ -530,10 +553,10 @@ function editEventSlot(
name: edits.name,
};
}
if (edits.location !== undefined) {
if (edits.locationId !== undefined) {
eventSlot = {
...eventSlot,
location: edits.location,
locationId: edits.locationId,
};
}
if (edits.assigned !== undefined) {
@ -542,20 +565,22 @@ function editEventSlot(
assigned: edits.assigned,
};
}
const change = { op: "set" as const, data: eventSlot };
changes.value = replaceChange(change, changes.value);
changes.value = replaceChange(eventSlot, changes.value);
}
function delEventSlot(eventSlot: EventSlot) {
const change = { op: "del" as const, data: eventSlot };
const change = {
...eventSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertEventSlot(id: string) {
function revertEventSlot(id: number) {
changes.value = revertChange(id, changes.value);
}
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const location = newEventLocation.value;
if (!location) {
const locationId = newEventLocation.value;
if (!locationId) {
alert("Invalid location");
return;
}
@ -580,27 +605,30 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time");
return;
}
const change: ChangeRecord<EventSlot> = {
op: "set" as const,
data: {
type: "slot",
id: `$new-${Date.now()}`,
name,
origLocation: location,
location,
assigned: [],
start,
end,
},
const change: EventSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origLocation: locationId,
locationId,
assigned: [],
start,
end,
};
newEventName.value = "";
changes.value = replaceChange(change, changes.value);
}
async function saveEventSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { events: scheduleChanges.value },
body: {
id: 111,
updatedAt: "",
events: scheduleChanges.value,
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -620,30 +648,36 @@ function gapFormat(gap: Gap) {
}
const eventSlots = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data: (EventSlot | Gap)[] = [];
for (const event of schedule.value.events) {
for (const event of schedule.value.events ?? []) {
if (event.deleted)
continue;
for (const slot of event.slots) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
for (const location of slot.locations) {
if (props.location !== undefined && location !== props.location)
for (const locationId of slot.locationIds) {
if (props.locationId !== undefined && locationId !== props.locationId)
continue;
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
event,
slot,
name: event.name,
location,
locationId,
assigned: slot.assigned ?? [],
origLocation: location,
origLocation: locationId,
start: DateTime.fromISO(slot.start, { 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());
// Insert gaps
@ -654,7 +688,7 @@ const eventSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
location: props.location,
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
end: second.start,
}]);

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
<table>
<thead>
@ -47,7 +50,7 @@
v-model="newShiftRole"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
@ -99,14 +102,14 @@
<td>{{ status(ss) }}</td>
<td>
<select
:value="ss.role"
@change="editShiftSlot(ss, { role: ($event as any).target.value })"
:value="ss.roleId"
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === ss.role"
:selected="role.id === ss.roleId"
>{{ role.name }}</option>
</select>
</td>
@ -124,7 +127,7 @@
@click="delShiftSlot(ss)"
>Remove</button>
<button
v-if="changes.some(c => c.data.id === ss.id)"
v-if="changes.some(c => c.id === ss.id)"
type="button"
@click="revertShiftSlot(ss.id)"
>Revert</button>
@ -184,7 +187,7 @@
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td>
<td>{{ ss.role }}</td>
<td>{{ ss.roleId }}</td>
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
</template>
</tr>
@ -203,13 +206,13 @@
<b>ShiftSlot changes</b>
<ol>
<li v-for="change in changes">
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ op: change.op, data }))(change.data as any), undefined, " ") }}</code></pre>
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
</li>
</ol>
<b>Shift changes</b>
<ol>
<li v-for="change in shiftChanges">
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => ({ 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>
</ol>
</details>
@ -218,25 +221,28 @@
<script lang="ts" setup>
import { DateTime, Duration } from 'luxon';
import type { ChangeRecord, Schedule, Shift, Role, ShiftSlot as ShiftTimeSlot, TimeSlot} from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { ApiSchedule, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
import { applyUpdatesToArray } from '~/shared/utils/update';
import { enumerate, pairs } from '~/shared/utils/functions';
import type { Entity } from '~/shared/types/common';
const props = defineProps<{
edit?: boolean,
role?: string,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftTimeSlot) => boolean,
roleId?: number,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
interface ShiftSlot {
type: "slot",
id: string,
shift?: Shift,
slot?: ShiftTimeSlot,
origRole: string,
id: number,
updatedAt: string,
deleted?: boolean,
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
slot?: ApiScheduleShiftSlot,
origRole: number,
name: string,
role: string,
roleId: number,
assigned: number[],
start: DateTime,
end: DateTime,
@ -248,40 +254,47 @@ interface Gap {
shift?: undefined,
slot?: undefined,
name?: undefined,
role?: string,
roleId?: number,
start: DateTime,
end: DateTime,
}
function status(shiftSlot: ShiftSlot) {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (
!shiftSlot.shift
|| shiftSlot.shift.name !== shiftSlot.name
) {
const shift = schedule.value.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 shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
}
// Filter out set records where a del record exists for the same id.
function filterSetOps<T extends { op: "set" | "del", data: { id: string }}>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.op === "del").map(c => c.data.id));
return changes.filter(c => c.op !== "set" || !deleteIds.has(c.data.id));
function filterSetOps<T extends Entity>(changes: T[]) {
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
}
function findShift(
shiftSlot: ShiftSlot,
changes: ChangeRecord<Shift>[],
schedule: Schedule,
changes: ApiScheduleShift[],
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.op === "set"
&& c.data.name === shiftSlot.name
&& c.data.role === shiftSlot.role
c.name === shiftSlot.name
&& c.roleId === shiftSlot.roleId
)
)?.data as Shift | undefined;
);
if (
!setShift
&& shiftSlot.shift
@ -290,32 +303,35 @@ function findShift(
setShift = shiftSlot.shift;
}
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;
if (shiftSlot.shift) {
delShift = changes.find(
c => c.op === "set" && c.data.name === shiftSlot.shift!.name
)?.data as Shift | undefined;
delShift = changes.filter(c => !c.deleted).find(
c => c.name === shiftSlot.shift!.name
);
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 };
}
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 nextId = Math.max(-1, ...shift.slots.map(s => {
const id = /-(\d+)$/.exec(s.id)?.[1];
return id ? parseInt(id) : 0;
})) + 1;
const nextId = Math.max(0, ...schedule.value.shifts?.filter(s => !s.deleted).flatMap(s => s.slots.map(slot => slot.id)) ?? []) + 1;
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
if (shift.role !== shiftSlot.role) {
console.warn(`Attempt to add slot id=${shiftSlot.id} role=${shiftSlot.role} to shift id=${shift.id} role=${shift.role}`);
if (shift.roleId !== shiftSlot.roleId) {
console.warn(`Attempt to add slot id=${shiftSlot.id} roleId=${shiftSlot.roleId} to shift id=${shift.id} roleId=${shift.roleId}`);
}
// Edit slot in-place if possible
@ -330,7 +346,7 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
return {
...shift,
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,
start,
end,
@ -339,35 +355,33 @@ function mergeSlot(shift: Shift, shiftSlot: ShiftSlot): Shift {
}
const shiftChanges = computed(() => {
let eventChanges: ChangeRecord<Shift>[] = [];
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
for (const change of filterSetOps(changes.value)) {
if (change.op === "set") {
let { setShift, delShift } = findShift(change.data, eventChanges, schedule.value);
if (!change.deleted) {
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
if (delShift && delShift !== setShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
eventChanges = removeSlot(eventChanges, delShift, change);
}
if (!setShift) {
setShift = {
id: toId(change.data.name),
name: change.data.name,
role: change.data.role,
id: Math.floor(Math.random() * -1000), // XXX This wont work.
updatedAt: "",
name: change.name,
roleId: change.roleId,
slots: [],
};
}
setShift = {
...setShift,
role: change.data.role,
roleId: change.roleId,
}
eventChanges = replaceChange({
op: "set",
data: mergeSlot(setShift, change.data),
}, eventChanges);
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
} else if (change.op === "del") {
let { delShift } = findShift(change.data, eventChanges, schedule.value);
} else if (change.deleted) {
let { delShift } = findShift(change, eventChanges, schedule.value);
if (delShift) {
eventChanges = removeSlot(eventChanges, delShift, change.data);
eventChanges = removeSlot(eventChanges, delShift, change);
}
}
}
@ -375,20 +389,27 @@ const shiftChanges = computed(() => {
});
const schedulePreview = computed(() => {
const rota = [...schedule.value.rota ?? []]
applyChangeArray(shiftChanges.value, rota);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const shifts = [...schedule.value.shifts ?? []]
applyUpdatesToArray(shiftChanges.value, shifts);
return {
...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);
if (oldSlot) {
eventChanges = replaceChange({
op: "set",
data: { ...shift, slots: shift.slots.filter(s => s.id !== oldSlot.id) },
...shift,
slots: shift.slots.filter(s => s.id !== oldSlot.id)
}, eventChanges);
}
return eventChanges;
@ -397,17 +418,15 @@ function removeSlot(eventChanges: ChangeRecord<Shift>[], shift: Shift, shiftSlot
const accountStore = useAccountStore();
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[]>([]);
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 }}>(
function replaceChange<T extends Entity>(
change: T,
changes: T[],
) {
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];
if (index !== -1)
@ -416,8 +435,8 @@ function replaceChange<T extends { op: "set" | "del", data: { id: string }}>(
copy.push(change);
return copy;
}
function revertChange<T extends { op: "set" | "del", data: { id: string }}>(id: string, changes: T[]) {
return changes.filter(change => change.data.id !== id);
function revertChange<T extends Entity>(id: number, changes: T[]) {
return changes.filter(change => change.id !== id);
}
const oneDayMs = 24 * 60 * 60 * 1000;
@ -442,9 +461,9 @@ const newShiftEnd = computed({
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newShiftRole = ref(props.role);
watch(() => props.role, () => {
newShiftRole.value = props.role;
const newShiftRole = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
});
function endFromTime(start: DateTime, time: string) {
@ -469,7 +488,7 @@ function editShiftSlot(
end?: string,
duration?: string,
name?: string,
role?: string,
roleId?: number,
assigned?: number[],
}
) {
@ -482,7 +501,6 @@ function editShiftSlot(
};
}
if (edits.end !== undefined) {
shiftSlot = {
...shiftSlot,
end: endFromTime(shiftSlot.start, edits.end),
@ -500,19 +518,19 @@ function editShiftSlot(
name: edits.name,
};
}
if (edits.role !== undefined) {
if (edits.roleId !== undefined) {
let changesCopy = changes.value;
for (const slot of shiftSlots.value) {
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
changesCopy = replaceChange({
op: "set",
data: { ...slot, role: edits.role }
...slot,
roleId: edits.roleId,
}, changesCopy);
}
}
changesCopy = replaceChange({
op: "set",
data: { ...shiftSlot, role: edits.role }
...shiftSlot,
roleId: edits.roleId,
}, changesCopy);
changes.value = changesCopy;
return;
@ -523,20 +541,22 @@ function editShiftSlot(
assigned: edits.assigned,
};
}
const change = { op: "set" as const, data: shiftSlot };
changes.value = replaceChange(change, changes.value);
changes.value = replaceChange(shiftSlot, changes.value);
}
function delShiftSlot(shiftSlot: ShiftSlot) {
const change = { op: "del" as const, data: shiftSlot };
const change = {
...shiftSlot,
deleted: true,
};
changes.value = replaceChange(change, changes.value);
}
function revertShiftSlot(id: string) {
function revertShiftSlot(id: number) {
changes.value = revertChange(id, changes.value);
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const role = newShiftRole.value;
if (!role) {
const roleId = newShiftRole.value;
if (!roleId) {
alert("Invalid role");
return;
}
@ -561,18 +581,16 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time");
return;
}
const change: ChangeRecord<ShiftSlot> = {
op: "set" as const,
data: {
type: "slot",
id: `$new-${Date.now()}`,
name,
origRole: role,
role,
assigned: [],
start,
end,
},
const change: ShiftSlot = {
type: "slot",
updatedAt: "",
id: Math.floor(Math.random() * -1000), // XXX this wont work.
name,
origRole: roleId,
roleId,
assigned: [],
start,
end,
};
newShiftName.value = "";
changes.value = replaceChange(change, changes.value);
@ -581,7 +599,11 @@ async function saveShiftSlots() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { rota: shiftChanges.value },
body: {
id: 111,
updatedAt: "",
shifts: shiftChanges.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -601,9 +623,12 @@ function gapFormat(gap: Gap) {
}
const shiftSlots = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data: (ShiftSlot | Gap)[] = [];
for (const shift of schedule.value.rota ?? []) {
if (props.role !== undefined && shift.role !== props.role)
for (const shift of schedule.value.shifts ?? []) {
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
continue;
for (const slot of shift.slots) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
@ -611,18 +636,19 @@ const shiftSlots = computed(() => {
data.push({
type: "slot",
id: slot.id,
updatedAt: "",
shift,
slot,
name: shift.name,
role: shift.role,
roleId: shift.roleId,
assigned: slot.assigned ?? [],
origRole: shift.role,
origRole: shift.roleId,
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
});
}
}
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());
// Insert gaps
@ -633,7 +659,7 @@ const shiftSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
role: props.role,
roleId: props.roleId,
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
end: second.start,
}]);

View file

@ -1,5 +1,8 @@
<template>
<div>
<div v-if="schedule.deleted">
Error: Unexpected deleted schedule.
</div>
<div v-else>
<table>
<thead>
<tr>
@ -14,7 +17,7 @@
<tbody>
<template v-if="edit">
<tr
v-for="shift in shifts"
v-for="shift in shifts?.filter(s => !s.deleted)"
:key="shift.id"
:class="{ removed: removed.has(shift.id) }"
>
@ -28,14 +31,14 @@
</td>
<td>
<select
:value="shift.role"
@change="editShift(shift, { role: ($event as any).target.value })"
:value="shift.roleId"
@change="editShift(shift, { roleId: ($event as any).target.value })"
>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="shift.role === role.id"
:selected="shift.roleId === role.id"
>{{ role.name }}</option>
</select>
</td>
@ -54,14 +57,14 @@
@click="delShift(shift.id)"
>Delete</button>
<button
v-if="changes.some(c => c.data.id === shift.id)"
v-if="changes.some(c => c.id === shift.id)"
type="button"
@click="revertShift(shift.id)"
>Revert</button>
</td>
</tr>
<tr>
<td>{{ toId(newShiftName) }}</td>
<td>{{ newShiftId }}</td>
<td>
<input
type="text"
@ -71,7 +74,7 @@
<td>
<select v-model="newShiftRole">
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="role.id === newShiftRole"
@ -100,12 +103,12 @@
</template>
<template v-else>
<tr
v-for="shift in shifts"
v-for="shift in shifts?.filter(s => !s.deleted)"
:key="shift.id"
>
<td>{{ shift.id }}</td>
<td>{{ shift.name }}</td>
<td>{{ shift.role }}</td>
<td>{{ shift.roleId }}</td>
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
<td>{{ shift.description }}</td>
</tr>
@ -131,25 +134,25 @@
</template>
<script lang="ts" setup>
import type { ChangeRecord, Shift } from '~/shared/types/schedule';
import { applyChangeArray } from '~/shared/utils/changes';
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
import { toId } from '~/shared/utils/functions';
import { applyUpdatesToArray } from '~/shared/utils/update';
const props = defineProps<{
edit?: boolean,
role?: string,
roleId?: number,
}>();
const schedule = await useSchedule();
const changes = ref<ChangeRecord<Shift>[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.op === "del").map(c => c.data.id)));
const changes = ref<ApiScheduleShift[]>([]);
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
function replaceChange(
change: ChangeRecord<Shift>,
changes: ChangeRecord<Shift>[],
change: ApiScheduleShift,
changes: ApiScheduleShift[],
) {
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];
if (index !== -1)
@ -158,19 +161,29 @@ function replaceChange(
copy.push(change);
return copy;
}
function revertChange(id: string, changes: ChangeRecord<Shift>[]) {
return changes.filter(change => change.data.id !== id);
function revertChange(id: number, changes: ApiScheduleShift[]) {
return changes.filter(change => change.id !== id);
}
const newShiftName = ref("");
const newShiftRole = ref(props.role);
watch(() => props.role, () => {
newShiftRole.value = props.role;
const newShiftId = computed(() => {
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
return Math.max(
1,
...schedule.value.shifts?.map(r => r.id) ?? [],
...changes.value.map(c => c.id)
) + 1;
});
const newShiftRole = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRole.value = props.roleId;
});
const newShiftDescription = ref("");
function editShift(
shift: Shift,
edits: { name?: string, description?: string, role?: string }
shift: Extract<ApiScheduleShift, { deleted?: false }>,
edits: { name?: string, description?: string, roleId?: number }
) {
const copy = { ...shift };
if (edits.name !== undefined) {
@ -179,24 +192,26 @@ function editShift(
if (edits.description !== undefined) {
copy.description = edits.description || undefined;
}
if (edits.role !== undefined) {
copy.role = edits.role;
if (edits.roleId !== undefined) {
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);
}
function delShift(id: string) {
const change = { op: "del" as const, data: { id } };
changes.value = replaceChange(change, changes.value);
}
function revertShift(id: string) {
function revertShift(id: number) {
changes.value = revertChange(id, changes.value);
}
function shiftExists(name: string) {
const id = toId(name);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
name = toId(name);
return (
schedule.value.rota?.some(e => e.id === id)
|| changes.value.some(c => c.data.id === id)
schedule.value.shifts?.some(s => !s.deleted && toId(s.name) === name)
|| changes.value.some(c => !c.deleted && c.name === name)
);
}
function newShift() {
@ -204,19 +219,20 @@ function newShift() {
alert(`Shift ${newShiftName.value} already exists`);
return;
}
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
if (!newShiftRole.value) {
alert(`Invalid role`);
return;
}
const change = {
op: "set" as const,
data: {
id: toId(newShiftName.value),
name: newShiftName.value,
role: newShiftRole.value,
description: newShiftDescription.value || undefined,
slots: [],
},
id: newShiftId.value,
updatedAt: "",
name: newShiftName.value,
roleId: newShiftRole.value,
description: newShiftDescription.value || undefined,
slots: [],
};
changes.value = replaceChange(change, changes.value);
newShiftName.value = "";
@ -226,7 +242,11 @@ async function saveShifts() {
try {
await $fetch("/api/schedule", {
method: "PATCH",
body: { rota: changes.value },
body: {
id: 111,
updatedAt: "",
shifts: changes.value
} satisfies ApiSchedule,
});
changes.value = [];
} catch (err: any) {
@ -236,8 +256,11 @@ async function saveShifts() {
}
const shifts = computed(() => {
const data = [...schedule.value.rota ?? []].filter(shift => !props.role || shift.role === props.role);
applyChangeArray(changes.value.filter(change => change.op === "set"), data);
if (schedule.value.deleted) {
throw new Error("Unexpected deleted schedule");
}
const data = [...schedule.value.shifts ?? []].filter(shift => !shift.deleted && (!props.roleId || shift.roleId === props.roleId));
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
return data;
});
</script>

View file

@ -1,5 +1,10 @@
<template>
<figure class="timetable">
<figure class="timetable" v-if="schedule.deleted">
<p>
Error: Schedule is deleted.
</p>
</figure>
<figure class="timetable" v-else>
<details>
<summary>Debug</summary>
<details>
@ -56,7 +61,7 @@
</tr>
</thead>
<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)">
<th>{{ location.name }}</th>
<td
@ -75,7 +80,7 @@
<th>Shifts</th>
<td :colSpan="totalColumns"></td>
</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)">
<th>{{ role.name }}</th>
<td
@ -97,7 +102,8 @@
<script setup lang="ts">
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";
const oneHourMs = 60 * 60 * 1000;
@ -107,8 +113,8 @@ const oneMinMs = 60 * 1000;
/** Point in time where a time slots starts or ends. */
type Edge =
| { type: "start" | "end", source: "event", slot: TimeSlot }
| { type: "start" | "end", source: "shift", role: string, slot: ShiftSlot }
| { type: "start" | "end", source: "event", slot: ApiScheduleEventSlot }
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
;
/** Point in time where multiple edges meet. */
@ -118,8 +124,8 @@ type Junction = { ts: number, edges: Edge[] };
type Span = {
start: Junction;
end: Junction,
locations: Map<string, Set<TimeSlot>>,
roles: Map<string, Set<ShiftSlot>>,
locations: Map<number, Set<ApiScheduleEventSlot>>,
roles: Map<number, Set<ApiScheduleShiftSlot>>,
};
/**
@ -133,7 +139,10 @@ type Stretch = {
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 slot of event.slots.filter(filter)) {
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 slot of shift.slots.filter(filter)) {
if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`);
}
yield { type: "start", source: "shift", role: shift.role, slot };
yield { type: "end", source: "shift", role: shift.role, slot };
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
}
}
}
@ -173,23 +185,25 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
}
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> {
const activeLocations = new Map(
locations.map(location => [location.id, new Set<TimeSlot>()])
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
);
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 edge of start.edges) {
if (edge.type === "start") {
if (edge.source === "event") {
for (const location of edge.slot.locations) {
activeLocations.get(location)?.add(edge.slot)
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.add(edge.slot)
}
} 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) {
if (edge.type === "end") {
if (edge.source === "event") {
for (const location of edge.slot.locations) {
activeLocations.get(location)?.delete(edge.slot)
for (const id of edge.slot.locationIds) {
activeLocations.get(id)?.delete(edge.slot)
}
} 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(
stretches: Iterable<Stretch>,
events: ScheduleEvent[],
locations: ScheduleLocation[],
rota: Shift[] | undefined,
roles: Role[] | undefined,
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
timezone: string,
) {
type Col = { minutes?: number };
type DayHead = { span: number, content?: string }
type HourHead = { span: number, content?: string }
type LocationCell = { span: number, slots: Set<TimeSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ShiftSlot>, title: string };
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, title: string };
const columnGroups: { className?: string, cols: Col[] }[] = [];
const dayHeaders: DayHead[] = [];
const hourHeaders: HourHead[]= [];
const locationRows = new Map<string, LocationCell[]>(locations.map(location => [location.id, []]));
const roleRows = new Map<string, RoleCell[]>(roles?.map?.(role => [role.id, []]));
const locationRows = new Map<number, LocationCell[]>(locations.map(location => [location.id, []]));
const roleRows = new Map<number, RoleCell[]>(roles.map(role => [role.id, []]));
const eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
const shiftBySlotId = new Map(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;
function startColumnGroup(className?: string) {
@ -354,7 +368,7 @@ function tableElementsFromStretches(
function startHour(content?: string) {
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)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -363,7 +377,7 @@ function tableElementsFromStretches(
}
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)!;
if (rows.length) {
const row = rows[rows.length - 1];
@ -487,21 +501,35 @@ function tableElementsFromStretches(
}
const props = defineProps<{
schedule: Schedule,
eventSlotFilter?: (slot: TimeSlot) => boolean,
shiftSlotFilter?: (slot: ShiftSlot) => boolean,
schedule: ApiSchedule,
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
}>();
const schedule = computed(() => props.schedule);
const junctions = computed(() => junctionsFromEdges([
...edgesFromEvents(schedule.value.events, props.eventSlotFilter),
...edgesFromShifts(schedule.value.rota ?? [], props.shiftSlotFilter),
]));
const stretches = computed(() => [
...stretchesFromSpans(
spansFromJunctions(junctions.value, schedule.value.locations, schedule.value.roles),
oneHourMs * 5
)
])
const junctions = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return junctionsFromEdges([
...edgesFromEvents(schedule.value.events?.filter(e => !e.deleted) ?? [], props.eventSlotFilter),
...edgesFromShifts(schedule.value.shifts?.filter(s => !s.deleted) ?? [], props.shiftSlotFilter),
])
});
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 timezone = computed({
@ -509,9 +537,19 @@ const timezone = computed({
set: (value: string) => { accountStore.timezone = value },
});
const elements = computed(() => tableElementsFromStretches(
stretches.value, schedule.value.events, schedule.value.locations, schedule.value.rota, schedule.value.roles, accountStore.activeTimezone
));
const elements = computed(() => {
if (schedule.value.deleted) {
throw Error("Unhandled deleted schedule");
}
return tableElementsFromStretches(
stretches.value,
schedule.value.events?.filter(e => !e.deleted) ?? [],
schedule.value.locations?.filter(l => !l.deleted) ?? [],
schedule.value.shifts?.filter(s => !s.deleted) ?? [],
schedule.value.roles?.filter(r => !r.deleted) ?? [],
accountStore.activeTimezone
);
});
const totalColumns = computed(() => elements.value.totalColumns);
const columnGroups = computed(() => elements.value.columnGroups);
const dayHeaders = computed(() => elements.value.dayHeaders);

View file

@ -1,9 +1,9 @@
import type { Schedule } from "~/shared/types/schedule";
import type { ApiEvent } from "~/shared/types/api";
interface AppEventMap {
"open": Event,
"message": MessageEvent<string>,
"update": MessageEvent<Schedule>,
"update": MessageEvent<ApiEvent>,
"error": Event,
"close": Event,
}

View file

@ -1,5 +1,3 @@
import type { Schedule } from '~/shared/types/schedule';
export const useSchedule = () => {
useEventSource();
const schedulesStore = useSchedulesStore();

View file

@ -16,7 +16,8 @@
"pinia": "^3.0.2",
"vue": "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",
"pnpm": {

View file

@ -1,5 +1,11 @@
<template>
<main>
<main v-if="schedule.deleted">
<h1>Error</h1>
<p>
Schedule has been deleted.
</p>
</main>
<main v-else>
<h1>Edit</h1>
<label>
Crew Filter:
@ -31,7 +37,7 @@
:selected="locationFilter === undefined"
>&lt;All locations&gt;</option>
<option
v-for="location in schedule.locations"
v-for="location in schedule.locations?.filter(l => !l.deleted)"
:key="location.id"
:value="location.id"
:selected="locationFilter === location.id"
@ -54,21 +60,21 @@
:selected="roleFilter === undefined"
>&lt;All roles&gt;</option>
<option
v-for="role in schedule.roles"
v-for="role in schedule.roles?.filter(r => !r.deleted)"
:key="role.id"
:value="role.id"
:selected="roleFilter === role.id"
>{{ role.name }}</option>
</select>
</label>
<ShiftScheduleTable :edit="true" :role="roleFilter" :eventSlotFilter :shiftSlotFilter />
<ShiftScheduleTable :edit="true" :roleId="roleFilter" :eventSlotFilter :shiftSlotFilter />
<h2>Shifts</h2>
<ShiftsTable :edit="true" :role="roleFilter" />
<ShiftsTable :edit="true" :roleId="roleFilter" />
</main>
</template>
<script lang="ts" setup>
import type { ShiftSlot, TimeSlot } from '~/shared/types/schedule';
import type { ApiScheduleEventSlot, ApiScheduleShiftSlot } from '~/shared/types/api';
definePageMeta({
middleware: ["authenticated"],
@ -95,29 +101,29 @@ const eventSlotFilter = computed(() => {
return () => true;
}
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(() => {
if (crewFilter.value === undefined || !accountStore.valid) {
return () => true;
}
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({
get: () => queryToString(route.query.location),
set: (value: string | undefined) => navigateTo({
get: () => queryToNumber(route.query.location),
set: (value: number | undefined) => navigateTo({
path: route.path,
query: {
...route.query,
location: value,
location: value !== undefined ? String(value) : undefined,
},
}),
});
const roleFilter = computed({
get: () => queryToString(route.query.role),
get: () => queryToNumber(route.query.role),
set: (value: string | undefined) => navigateTo({
path: route.path,
query: {

View file

@ -1,5 +1,11 @@
<template>
<main>
<main v-if="schedule.deleted">
<h1>Error</h1>
<p>
Schedule has been deleted.
</p>
</main>
<main v-else>
<h1>Schedule & Events</h1>
<p>
Study carefully, we only hold these events once a year.
@ -42,10 +48,10 @@
</label>
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
<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>
<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>
{{ location.description ?? "No description provided" }}
</li>
@ -54,7 +60,7 @@
</template>
<script setup lang="ts">
import type { ShiftSlot, TimeSlot } from '~/shared/types/schedule';
import type { ApiScheduleShiftSlot, ApiScheduleEventSlot } from '~/shared/types/api';
const accountStore = useAccountStore();
const { data: accounts } = await useAccounts();
@ -73,27 +79,27 @@ const filter = computed({
});
const eventSlotFilter = computed(() => {
if (filter.value === undefined || !accountStore.valid) {
if (filter.value === undefined || !accountStore.valid || schedule.value.deleted) {
return () => true;
}
const aid = accountStore.id;
if (filter.value === "my-schedule") {
const ids = new Set(accountStore.interestedIds);
for (const event of schedule.value.events) {
if (ids.has(event.id)) {
const slotIds = new Set(accountStore.interestedEventSlotIds);
for (const event of schedule.value.events ?? []) {
if (!event.deleted && accountStore.interestedEventIds.has(event.id)) {
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") {
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-")) {
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;
});
@ -103,11 +109,11 @@ const shiftSlotFilter = computed(() => {
}
if (filter.value === "my-schedule" || filter.value === "assigned") {
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-")) {
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;
});

11
pnpm-lock.yaml generated
View file

@ -29,6 +29,9 @@ importers:
web-push:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: ^3.25.30
version: 3.25.30
devDependencies:
'@types/luxon':
specifier: ^3.4.2
@ -3498,8 +3501,8 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod@3.25.23:
resolution: {integrity: sha512-Od2bdMosahjSrSgJtakrwjMDb1zM1A3VIHCPGveZt/3/wlrTWBya2lmEh2OYe4OIu8mPTmmr0gnLHIWQXdtWBg==}
zod@3.25.30:
resolution: {integrity: sha512-VolhdEtu6TJr/fzGuHA/SZ5ixvXqA6ADOG9VRcQ3rdOKmF5hkmcJbyaQjUH5BgmpA9gej++zYRX7zjSmdReIwA==}
snapshots:
@ -3929,7 +3932,7 @@ snapshots:
unixify: 1.0.0
urlpattern-polyfill: 8.0.2
yargs: 17.7.2
zod: 3.25.23
zod: 3.25.30
transitivePeerDependencies:
- encoding
- rollup
@ -7318,4 +7321,4 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod@3.25.23: {}
zod@3.25.30: {}

View file

@ -1,39 +1,28 @@
import { Account } from "~/shared/types/account";
import { readAccounts, writeAccounts } from "~/server/database";
import { DateTime } from "luxon";
import { apiAccountPatchSchema } from "~/shared/types/api";
import { z } from "zod/v4-mini";
export default defineEventHandler(async (event) => {
const session = await requireServerSession(event);
const body: Pick<Account, "interestedIds" | "timezone"> = await readBody(event);
if (
body.interestedIds !== undefined
&& (
!(body.interestedIds instanceof Array)
|| !body.interestedIds.every(id => typeof id === "string")
)
) {
const { success, error, data: patch } = apiAccountPatchSchema.safeParse(await readBody(event));
if (!success) {
throw createError({
status: 400,
message: "Invalid interestedIds",
statusText: "Bad Request",
message: z.prettifyError(error),
});
}
if (body.timezone !== undefined) {
if (typeof body.timezone !== "string") {
if (patch.timezone?.length) {
const zonedTime = DateTime.local({ locale: "en-US" }).setZone(patch.timezone);
if (!zonedTime.isValid) {
throw createError({
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();
@ -42,16 +31,23 @@ export default defineEventHandler(async (event) => {
throw Error("Account does not exist");
}
if (body.interestedIds !== undefined) {
if (body.interestedIds.length) {
sessionAccount.interestedIds = body.interestedIds;
if (patch.interestedEventIds !== undefined) {
if (patch.interestedEventIds.length) {
sessionAccount.interestedEventIds = patch.interestedEventIds;
} else {
delete sessionAccount.interestedIds;
delete sessionAccount.interestedEventIds;
}
}
if (body.timezone !== undefined) {
if (body.timezone)
sessionAccount.timezone = body.timezone;
if (patch.interestedEventSlotIds !== undefined) {
if (patch.interestedEventSlotIds.length) {
sessionAccount.interestedEventSlotIds = patch.interestedEventSlotIds;
} else {
delete sessionAccount.interestedEventSlotIds;
}
}
if (patch.timezone !== undefined) {
if (patch.timezone)
sessionAccount.timezone = patch.timezone;
else
delete sessionAccount.timezone;
}

View file

@ -1,5 +1,5 @@
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) => {
let session = await getServerSession(event);
@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
const name = formData.get("name");
const accounts = await readAccounts();
let account: Account;
let account: ApiAccount;
if (typeof name === "string") {
if (name === "") {
throw createError({

View file

@ -1,7 +1,7 @@
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);
if (!session)
return;

View file

@ -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 { broadcastUpdate } from "~/server/streams";
import { applyChangeArray } from "~/shared/utils/changes";
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
}
import { broadcastEvent } from "~/server/streams";
import { apiScheduleSchema } from "~/shared/types/api";
import { applyUpdatesToArray } from "~/shared/utils/update";
export default defineEventHandler(async (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 patch = await readValidatedBody(event, isPatch);
if (schedule.deleted) {
throw createError({
statusCode: 400,
statusMessage: "Not implemented",
});
}
// Validate edit restrictions for crew
if (account.type === "crew") {
if (patch.locations?.length) {
if (update.locations?.length) {
throw createError({
status: 403,
statusMessage: "Forbidden",
message: "Only admin accounts can edit locations.",
});
}
for (const event of patch.events ?? []) {
const id = event.op === "set" ? event.data.id : event.id;
const original = schedule.events.find(e => e.id === id);
if (original && !original.crew) {
for (const event of update.events ?? []) {
const original = schedule.events?.find(e => e.id === event.id);
if (original && !original.deleted && !original.crew) {
throw createError({
status: 403,
statusMessage: "Forbidden",
@ -78,11 +66,30 @@ export default defineEventHandler(async (event) => {
}
}
if (patch.events) applyChangeArray(patch.events, schedule.events);
if (patch.locations) applyChangeArray(patch.locations, schedule.locations);
if (patch.roles) applyChangeArray(patch.roles, schedule.roles = schedule.roles ?? []);
if (patch.rota) applyChangeArray(patch.rota, schedule.rota = schedule.rota ?? []);
// Update schedule
const updatedFrom = schedule.updatedAt;
update.updatedAt = new Date().toISOString();
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 broadcastUpdate(schedule);
await broadcastEvent({
type: "schedule-update",
updatedFrom,
data: update,
});
})

View file

@ -1,14 +1,14 @@
import { readAccounts, readSchedule } from "~/server/database";
import { Account } from "~/shared/types/account";
import type { ApiAccount } from "~/shared/types/api";
import { canSeeCrew } from "../utils/schedule";
export default defineEventHandler(async (event) => {
const session = await getServerSession(event);
let account: Account | undefined;
let account: ApiAccount | undefined;
if (session) {
const accounts = await readAccounts()
account = accounts.find(account => account.id === session.accountId);
}
const schedule = await readSchedule();
return canSeeCrew(account?.type) ? schedule : filterSchedule(schedule);
})
});

View file

@ -1,14 +1,26 @@
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) => {
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 existingIndex = subscriptions.findIndex(
sub => sub.type === "push" && sub.sessionId === session.id
);
const subscription: Subscription = {
const subscription: ApiSubscription = {
type: "push",
sessionId: session.id,
push: body.subscription

View file

@ -1,6 +1,5 @@
import { readFile, unlink, writeFile } from "node:fs/promises";
import { Schedule } from "~/shared/types/schedule";
import { Account, Subscription } from "~/shared/types/account";
import type { ApiAccount, ApiSchedule, ApiSubscription } from "~/shared/types/api";
import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule";
export interface ServerSession {
@ -51,12 +50,12 @@ export async function readSchedule() {
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");
}
export async function readSubscriptions() {
let subscriptions = await readJson<Subscription[]>(subscriptionsPath, []);
let subscriptions = await readJson<ApiSubscription[]>(subscriptionsPath, []);
if (subscriptions.length && "keys" in subscriptions[0]) {
// Discard old format
subscriptions = [];
@ -64,7 +63,7 @@ export async function readSubscriptions() {
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");
}
@ -81,7 +80,7 @@ export async function readAccounts() {
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");
}

View file

@ -1,30 +1,41 @@
import { Account } from "~/shared/types/account";
import { Role, Schedule, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
import type { ApiAccount, ApiSchedule, ApiScheduleEventSlot, ApiScheduleShiftSlot } from "~/shared/types/api";
import { toId } from "~/shared/utils/functions";
const locations = [
{
id: 1,
name: "Stage",
description: "Inside the main building."
description: "Inside the main building.",
updatedAt: "d0 18:21",
},
{
id: 2,
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",
description: "Next to the campfire by the lake"
description: "Next to the campfire by the lake",
updatedAt: "d0 18:22",
},
{
id: 6,
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",
description: "Takes place somewhere outside."
description: "Takes place somewhere outside.",
updatedAt: "d0 18:37",
}
]
let slotId = 1;
let eventId = 1;
const events = [
{
name: "Arcade",
@ -133,17 +144,24 @@ const events = [
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16: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" },
{ id: "security", name: "Security" },
const idMedic = 1;
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",
role: "medic",
roleId: idMedic,
slots: [
"d1 12:00 4h",
"d2 12:00 4h",
@ -154,7 +172,7 @@ const rota = [
},
{
name: "Medic Late",
role: "medic",
roleId: idMedic,
slots: [
"d1 16:00 7h",
"d2 16:00 6h",
@ -164,7 +182,7 @@ const rota = [
},
{
name: "Security Early",
role: "security",
roleId: idSecurity,
slots: [
"d1 12:00 6h",
"d2 12:00 6h",
@ -175,7 +193,7 @@ const rota = [
},
{
name: "Security Late",
role: "security",
roleId: idSecurity,
slots: [
"d1 18:00 5h",
"d2 18:00 4h",
@ -183,13 +201,17 @@ const rota = [
"d4 18:00 5h",
],
},
]
].map(({ slots, ...rest }) => ({
...rest,
id: eventId++,
slots: slots.map(slot => `${slot} ${slotId++}`),
}));
function toIso(date: Date) {
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 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.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 durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
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];
}
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>, idToAssigned: Map<string, number[]>): TimeSlot {
const [day, start, duration, location] = shorthand.split(" ");
function toSlot(origin: Date, shorthand: string, counts: Map<number, number>, idToAssigned: Map<number, number[]>): ApiScheduleEventSlot {
const [day, start, duration, location, idStr] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
const id = parseInt(idStr, 10);
return {
id: `${id}-${index}`,
id,
start: toIso(startDate),
end: toIso(endDate),
locations: [location],
assigned: idToAssigned.get(`${id}-${index}`),
interested: counts.get(`${id}-${index}`),
locationIds: [locations.find(l => toId(l.name) === location)!.id],
assigned: idToAssigned.get(id),
interested: counts.get(id),
};
}
function toShift(origin: Date, id: string, shorthand: string, index: number, idToAssigned: Map<string, number[]>): ShiftSlot {
const [day, start, duration] = shorthand.split(" ");
function toShift(origin: Date, shorthand: string, idToAssigned: Map<number, number[]>): ApiScheduleShiftSlot {
const [day, start, duration, idStr] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
const id = parseInt(idStr, 10);
return {
id: `${id}-${index}`,
id,
start: toIso(startDate),
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 utcOffset = 1;
origin.setUTCDate(origin.getUTCDate() - origin.getUTCDay() + 1); // Go to Monday
@ -240,101 +269,98 @@ export function generateDemoSchedule(): Schedule {
origin.setUTCSeconds(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();
for (const account of accounts) {
for (const id of account.interestedIds ?? []) {
counts.set(id, (counts.get(id) ?? 0) + 1);
for (const id of account.interestedEventIds ?? []) {
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
}
for (const id of account.interestedEventSlotIds ?? []) {
slotCounts.set(id, (slotCounts.get(id) ?? 0) + 1);
}
}
seed = 2;
const idToAssigned = new Map<string, number[]>();
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
const assignedIds: string[] = [];
const slotsToAdd = Math.floor(random() * 20);
while (assignedIds.length < slotsToAdd) {
const event = events[Math.floor(random() * events.length)];
const eventId = toId(event.name);
if (assignedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
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}`));
function assignSlots(events: { id: number, slots: string[] }[], count: number) {
const idToAssigned = new Map<number, number[]>();
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
const assignedIds = new Set<number>;
const usedEvents = new Set<number>;
const slotsToAdd = Math.floor(random() * count);
while (assignedIds.size < slotsToAdd) {
const event = events[Math.floor(random() * events.length)];
if (usedEvents.has(event.id)) {
continue;
}
} else {
for (const index of event.slots.map((_, index) => index)) {
if (random() < 0.5) {
assignedIds.push(toId(`${toId(event.name)}-${index}`));
if (event.slots.length === 1 || random() < 0.8) {
for (const slot of event.slots) {
const id = parseInt(slot.split(" ").slice(-1)[0]);
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) {
const assigned = idToAssigned.get(id);
if (assigned) {
assigned.push(account.id);
} else {
idToAssigned.set(id, [account.id]);
for (const id of assignedIds) {
const assigned = idToAssigned.get(id);
if (assigned) {
assigned.push(account.id);
} else {
idToAssigned.set(id, [account.id]);
}
}
}
return idToAssigned;
}
seed = 2;
const eventSlotIdToAssigned = assignSlots(events, 20);
seed = 5;
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
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]);
}
}
}
const shiftSlotIdToAssigned = assignSlots(shifts, 3);
return {
id: 111,
updatedAt: toIso(toDate(origin, "d2", "10:01")),
events: events.map(
({ name, crew, description, slots }) => ({
id: toId(name),
({ id, name, crew, description, slots }) => ({
id,
name,
crew,
description,
interested: counts.get(toId(name)),
slots: slots.map((shorthand, index) => toSlot(origin, toId(name), shorthand, index, counts, idToAssigned))
interested: eventCounts.get(id),
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
updatedAt: toIso(toDate(origin, "d0", "15:11")),
})
),
locations: locations.map(
({ name, description }) => ({ id: toId(name), name, description })
),
roles,
rota: rota.map(
({ name, role, slots }) => ({
id: toId(name),
({ id, name, description, updatedAt }) => ({
id,
name,
role,
slots: slots.map((shorthand, index) => toShift(origin, toId(name), shorthand, index, idToAssigned))
description,
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;
}
export function generateDemoAccounts(): Account[] {
export function generateDemoAccounts(): ApiAccount[] {
seed = 1;
const accounts: Account[] = [];
const accounts: ApiAccount[] = [];
for (const name of names) {
accounts.push({
@ -378,38 +404,45 @@ export function generateDemoAccounts(): Account[] {
seed = 1;
// 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);
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) {
if (random() < 0.5) {
interestedIds.push(id);
interestedEventIds.add(id);
}
}
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 eventId = toId(event.name);
if (interestedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
if (usedEvents.has(event.id)) {
continue;
}
if (event.slots.length === 1 || random() < 0.8) {
interestedIds.push(toId(event.name))
interestedEventIds.add(event.id)
} else {
for (const index of event.slots.map((_, index) => index)) {
for (const slot of event.slots) {
if (random() < 0.5) {
interestedIds.push(toId(`${toId(event.name)}-${index}`));
const id = parseInt(slot.split(" ")[4], 10);
interestedSlotIds.add(id);
}
}
}
}
if (interestedIds.length) {
account.interestedIds = interestedIds;
if (interestedEventIds.size) {
account.interestedEventIds = [...interestedEventIds];
}
if (interestedSlotIds.size) {
account.interestedEventSlotIds = [...interestedSlotIds];
}
}
return accounts;

View file

@ -1,6 +1,6 @@
import { Schedule } from "~/shared/types/schedule"
import { readAccounts } from "~/server/database";
import { canSeeCrew } from "./utils/schedule";
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
function sendMessage(
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();
console.log(`broadcasting update to ${streams.size} clients`);
if (!streams.size) {
return;
}
const accounts = await readAccounts();
const filteredSchedule = filterSchedule(schedule);
for (const [stream, streamData] of streams) {
let accountType: string | undefined;
if (streamData.accountId !== undefined) {
accountType = accounts.find(a => a.id === streamData.accountId)?.type
// Account events are specially handled and only sent to the account they belong to.
if (event.type === "account-update") {
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);
}
}

View file

@ -1,24 +1,61 @@
import { Account } from '~/shared/types/account';
import { Schedule } from '~/shared/types/schedule';
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[]) {
const counts = new Map();
for (const account of accounts)
if (account.interestedIds)
for (const id of account.interestedIds)
counts.set(id, (counts.get(id) ?? 0) + 1);
export async function updateScheduleInterestedCounts(accounts: ApiAccount[]) {
const eventCounts = new Map<number, number>();
const eventSlotCounts = new Map<number, number>();
for (const account of accounts) {
if (account.interestedEventIds)
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();
for (const event of schedule.events) {
event.interested = counts.get(event.id);
if (schedule.deleted) {
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) {
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 broadcastUpdate(schedule);
await broadcastEvent({
type: "schedule-update",
updatedFrom,
data: update,
});
}
export function canSeeCrew(accountType: string | undefined) {
@ -26,17 +63,28 @@ export function canSeeCrew(accountType: string | undefined) {
}
/** 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 {
id: schedule.id,
updatedAt: schedule.updatedAt,
locations: schedule.locations,
events: schedule.events
.filter(event => !event.crew)
.map(event => ({
...event,
slots: event.slots.map(slot => ({
...slot,
assigned: undefined,
})),
events: (schedule.events ?? [])
.map(event => (
event.deleted
? event
: event.crew
// Pretend crew events are deleted.
? { id: event.id, deleted: true, updatedAt: event.updatedAt }
: {
...event,
slots: event.slots.map(slot => ({
...slot,
assigned: undefined,
}
)),
})),
}
}

View file

@ -1,5 +1,5 @@
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;

View file

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

View file

@ -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>[],
}

View file

@ -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);
}
}

View file

@ -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 */
export function toId(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-");

14
shared/utils/update.ts Normal file
View 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);
}
}
}

View file

@ -1,4 +1,4 @@
import type { Account } from "~/shared/types/account";
import type { ApiAccount, ApiAccountPatch } from "~/shared/types/api";
export const useAccountStore = defineStore("account", () => {
const runtimeConfig = useRuntimeConfig();
@ -8,8 +8,9 @@ export const useAccountStore = defineStore("account", () => {
id: ref<number>(),
name: ref<string>(),
timezone: ref<string>(),
type: ref<Account["type"]>(),
interestedIds: ref<Set<string>>(new Set()),
type: ref<ApiAccount["type"]>(),
interestedEventIds: ref<Set<number>>(new Set()),
interestedEventSlotIds: ref<Set<number>>(new Set()),
};
watchEffect(() => {
@ -19,7 +20,8 @@ export const useAccountStore = defineStore("account", () => {
state.name.value = account?.name;
state.timezone.value = account?.timezone;
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 = {
@ -31,23 +33,34 @@ export const useAccountStore = defineStore("account", () => {
};
const actions = {
async toggleInterestedId(id: string, slotIds?: string[]) {
if (!state.interestedIds.value) {
throw Error("accountStore.toggleInterestedId: Invalid state")
}
let newIds = [...state.interestedIds.value ?? []];
if (state.interestedIds.value.has(id)) {
newIds = newIds.filter(newId => newId !== id);
} else {
newIds.push(id);
if (slotIds) {
const filterIds = new Set(slotIds);
newIds = newIds.filter(newId => !filterIds.has(newId));
async toggleInterestedId(type: "event" | "slot", id: number, slotIds?: number[]) {
let newEventIds = new Set(state.interestedEventIds.value);
let newSlotIds = new Set(state.interestedEventSlotIds.value);
if (type === "event") {
if (newEventIds.has(id)) {
newEventIds.delete(id)
} else {
newEventIds.add(id);
if (slotIds) {
for (const slotId of slotIds) {
newSlotIds.delete(slotId)
}
}
}
} 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", {
method: "PATCH",
body: { interestedIds: newIds },
body: patch,
})
await sessionStore.fetch();
},

View file

@ -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 {
controller: AbortController,
promise: Promise<Ref<Schedule>>,
promise: Promise<Ref<ApiSchedule>>,
}
export const useSchedulesStore = defineStore("schedules", () => {
@ -10,7 +11,7 @@ export const useSchedulesStore = defineStore("schedules", () => {
const state = {
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()),
};
@ -82,9 +83,25 @@ export const useSchedulesStore = defineStore("schedules", () => {
})
appEventSource?.addEventListener("update", (event) => {
if (event.data.type !== "schedule-update") {
return;
}
const schedule = state.schedules.value.get(111);
if (schedule) {
schedule.value = event.data;
const update = event.data.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 ?? []);
}
}
});

View file

@ -1,6 +1,6 @@
import { appendResponseHeader } 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) => {
// Client side
@ -21,7 +21,7 @@ const fetchSessionWithCookie = async (event?: H3Event) => {
export const useSessionStore = defineStore("session", () => {
const state = {
account: ref<Account>(),
account: ref<ApiAccount>(),
id: ref<number>(),
push: ref<boolean>(false),
};

View file

@ -7,3 +7,11 @@ export function queryToString(item?: null | LocationQueryValue | LocationQueryVa
return queryToString(item[0])
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);
}