Refactor ClientSchedule to mutable types
All checks were successful
/ build (push) Successful in 1m30s
/ deploy (push) Successful in 16s

Use a single mutable location, event, slot, etc, for each unique
resource that keeps track of the local editable client copy and
the server copy of the data contained in it.

This makes it much simpler to update these data structures as I can take
advantage of the v-model bindings in Vue.js and work with the system
instead of against it.
This commit is contained in:
Hornwitser 2025-06-23 22:46:39 +02:00
parent d48fb035b4
commit e3ff872b5c
16 changed files with 1213 additions and 1125 deletions

View file

@ -91,41 +91,39 @@
<td> <td>
<input <input
type="text" type="text"
:value="es.name" v-model="es.event.name"
@input="editEvent(es, { name: ($event as any).target.value })"
> >
</td> </td>
<td>{{ status(es) }}</td> <td>{{ status(es) }}</td>
<td> <td>
<select <select
:value="es.location.id" :value="es.locationId"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })" @change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
> >
<option <option
v-for="location in schedule.locations.values()" v-for="location in schedule.locations.values()"
:key="location.id" :key="location.id"
:value="location.id" :value="location.id"
:selected="location.id === es.location.id" :selected="location.id === es.locationId"
>{{ location.name }}</option> >{{ location.name }}</option>
</select> </select>
</td> </td>
<td> <td>
<AssignedCrew <AssignedCrew
:edit="true" :edit="true"
:modelValue="es.assigned" v-model="es.slot.assigned"
@update:modelValue="editEventSlot(es, { assigned: $event })"
/> />
</td> </td>
<td> <td>
<button <button
:disabled="es.deleted" :disabled="es.deleted"
type="button" type="button"
@click="editEventSlot(es, { deleted: true })" @click="es.slot.deleted = true"
>Remove</button> >Remove</button>
<button <button
v-if="schedule.isModifiedEventSlot(es.id)" v-if="es.slot.isModified()"
type="button" type="button"
@click="revertEventSlot(es.id)" @click="es.event.discardSlot(es.slot.id)"
>Revert</button> >Revert</button>
</td> </td>
</template> </template>
@ -185,7 +183,7 @@
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td> <td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td> <td>{{ es.name }}</td>
<td>{{ status(es) }}</td> <td>{{ status(es) }}</td>
<td>{{ es.location.id }}</td> <td>{{ es.locationId }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td> <td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
</template> </template>
</tr> </tr>
@ -213,7 +211,7 @@ interface EventSlot {
event: ClientScheduleEvent, event: ClientScheduleEvent,
slot: ClientScheduleEventSlot, slot: ClientScheduleEventSlot,
name: string, name: string,
location: ClientScheduleLocation, locationId: Id,
assigned: Set<Id>, assigned: Set<Id>,
start: DateTime, start: DateTime,
end: DateTime, end: DateTime,
@ -290,48 +288,30 @@ function durationFromTime(time: string) {
return duration; return duration;
} }
const newEventName = ref(""); const newEventName = ref("");
function editEvent(
eventSlot: EventSlot,
edits: Parameters<ClientSchedule["editEvent"]>[1],
) {
schedule.value.editEvent(eventSlot.event, edits);
}
function editEventSlot( function editEventSlot(
eventSlot: EventSlot, eventSlot: EventSlot,
edits: { edits: {
deleted?: boolean,
start?: string, start?: string,
end?: string, end?: string,
duration?: string, duration?: string,
locationId?: Id, locationId?: Id,
assigned?: Set<Id>,
} }
) { ) {
const computedEdits: Parameters<ClientSchedule["editEventSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
if (edits.start) { if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale }); const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start; eventSlot.slot.start = start;
computedEdits.end = start.plus(eventSlot.end.diff(eventSlot.start)); eventSlot.slot.end = start.plus(eventSlot.end.diff(eventSlot.start));
} }
if (edits.end !== undefined) { if (edits.end !== undefined) {
computedEdits.end = endFromTime(eventSlot.start, edits.end); eventSlot.slot.end = endFromTime(eventSlot.start, edits.end);
} }
if (edits.duration !== undefined) { if (edits.duration !== undefined) {
computedEdits.end = eventSlot.start.plus(durationFromTime(edits.duration)); eventSlot.slot.end = eventSlot.start.plus(durationFromTime(edits.duration));
} }
if (edits.locationId !== undefined) { if (edits.locationId !== undefined) {
const location = schedule.value.locations.get(edits.locationId); eventSlot.slot.locationIds = new Set([edits.locationId]);
if (location)
computedEdits.locations = [location];
} }
schedule.value.editEventSlot(eventSlot.slot, computedEdits);
}
function revertEventSlot(id: Id) {
schedule.value.restoreEventSlot(id);
} }
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) { function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value; const name = newEventName.value;
@ -341,8 +321,7 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid event"); alert("Invalid event");
return; return;
} }
const location = schedule.value.locations.get(newEventLocation.value!); if (newEventLocation.value === undefined) {
if (!location) {
alert("Invalid location"); alert("Invalid location");
return; return;
} }
@ -367,18 +346,19 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time"); alert("Invalid start and/or end time");
return; return;
} }
const slot = new ClientScheduleEventSlot( const slot = ClientScheduleEventSlot.create(
schedule.value,
schedule.value.nextClientId--, schedule.value.nextClientId--,
false,
event.id, event.id,
start, start,
end, end,
[location], new Set([newEventLocation.value]),
new Set(), new Set(),
0, 0,
); );
schedule.value.eventSlots.set(slot.id, slot);
event.slotIds.add(slot.id);
newEventName.value = ""; newEventName.value = "";
schedule.value.setEventSlot(slot);
} }
const oneHourMs = 60 * 60 * 1000; const oneHourMs = 60 * 60 * 1000;
@ -399,8 +379,8 @@ const eventSlots = computed(() => {
for (const slot of event.slots.values()) { for (const slot of event.slots.values()) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot)) if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue; continue;
for (const location of slot.locations) { for (const locationId of slot.locationIds) {
if (props.locationId !== undefined && location.id !== props.locationId) if (props.locationId !== undefined && locationId !== props.locationId)
continue; continue;
data.push({ data.push({
type: "slot", type: "slot",
@ -409,7 +389,7 @@ const eventSlots = computed(() => {
event, event,
slot, slot,
name: event.name, name: event.name,
location, locationId,
assigned: slot.assigned ?? [], assigned: slot.assigned ?? [],
start: slot.start, start: slot.start,
end: slot.end, end: slot.end,

View file

@ -22,17 +22,15 @@
<td> <td>
<input <input
type="text" type="text"
:value="event.name"
:disabled="!canEdit(event)" :disabled="!canEdit(event)"
@input="editEvent(event, { name: ($event as any).target.value })" v-model="event.name"
> >
</td> </td>
<td> <td>
<input <input
type="text" type="text"
:disabled="!canEdit(event)" :disabled="!canEdit(event)"
:value="event.description" v-model="event.description"
@input="editEvent(event, { description: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
@ -41,7 +39,7 @@
:disabled="!accountStore.canEditPublic" :disabled="!accountStore.canEditPublic"
:value="!event.crew" :value="!event.crew"
:checked="!event.crew" :checked="!event.crew"
@change="editEvent(event, { crew: !($event as any).target.value })" @change="event.crew = !($event.target as HTMLInputElement).value"
> >
</td> </td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td> <td>{{ event.slots.size ? event.slots.size : "" }}</td>
@ -49,12 +47,12 @@
<button <button
type="button" type="button"
:disabled="!canEdit(event) || event.deleted" :disabled="!canEdit(event) || event.deleted"
@click="editEvent(event, { deleted: true })" @click="event.deleted = true"
>Delete</button> >Delete</button>
<button <button
v-if="schedule.isModifiedEvent(event.id)" v-if="event.isModified()"
type="button" type="button"
@click="revertEvent(event.id)" @click="schedule.events.discardId(event.id)"
>Revert</button> >Revert</button>
</td> </td>
</tr> </tr>
@ -111,8 +109,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { Id } from '~/shared/types/common'; import { Info } from '~/shared/utils/luxon';
import { DateTime } from '~/shared/utils/luxon';
import { toId } from '~/shared/utils/functions'; import { toId } from '~/shared/utils/functions';
defineProps<{ defineProps<{
@ -129,15 +126,6 @@ function canEdit(event: ClientScheduleEvent) {
const newEventName = ref(""); const newEventName = ref("");
const newEventDescription = ref(""); const newEventDescription = ref("");
const newEventPublic = ref(false); const newEventPublic = ref(false);
function editEvent(
event: ClientScheduleEvent,
edits: Parameters<ClientSchedule["editEvent"]>[1],
) {
schedule.value.editEvent(event, edits);
}
function revertEvent(id: Id) {
schedule.value.restoreEvent(id);
}
function eventExists(name: string) { function eventExists(name: string) {
name = toId(name); name = toId(name);
return ( return (
@ -149,19 +137,21 @@ function newEvent() {
alert(`Event ${newEventName.value} already exists`); alert(`Event ${newEventName.value} already exists`);
return; return;
} }
const event = new ClientScheduleEvent( const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale;
const event = ClientScheduleEvent.create(
schedule.value,
schedule.value.nextClientId--, schedule.value.nextClientId--,
DateTime.now(),
false,
newEventName.value, newEventName.value,
!newEventPublic.value, !newEventPublic.value,
"", "",
false, false,
newEventDescription.value, newEventDescription.value,
0, 0,
new Map(), new Set(),
{ zone, locale },
); );
schedule.value.setEvent(event); schedule.value.events.add(event);
newEventName.value = ""; newEventName.value = "";
newEventDescription.value = ""; newEventDescription.value = "";
newEventPublic.value = false; newEventPublic.value = false;

View file

@ -20,27 +20,25 @@
<td> <td>
<input <input
type="text" type="text"
:value="location.name" v-model="location.name"
@input="editLocation(location, { name: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<input <input
type="text" type="text"
:value="location.description" v-model="location.description"
@input="editLocation(location, { description: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<button <button
:disabled="location.deleted" :disabled="location.deleted"
type="button" type="button"
@click="editLocation(location, { deleted: true })" @click="location.deleted = true"
>Remove</button> >Remove</button>
<button <button
v-if="schedule.isModifiedLocation(location.id)" v-if="location.isModified()"
type="button" type="button"
@click="revertLocation(location.id)" @click="schedule.locations.discardId(location.id)"
>Revert</button> >Revert</button>
</td> </td>
</template> </template>
@ -79,40 +77,28 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DateTime } from '~/shared/utils/luxon'; import { Info } from '~/shared/utils/luxon';
import type { Id } from '~/shared/types/common';
defineProps<{ defineProps<{
edit?: boolean edit?: boolean
}>(); }>();
const schedule = await useSchedule(); const schedule = await useSchedule();
const accountStore = useAccountStore();
const newLocationName = ref(""); const newLocationName = ref("");
const newLocationDescription = ref(""); const newLocationDescription = ref("");
function editLocation(
location: ClientScheduleLocation,
edits: Parameters<ClientSchedule["editLocation"]>[1],
) {
try {
schedule.value.editLocation(location, edits);
} catch (err: any) {
alert(err.message);
}
}
function revertLocation(id: Id) {
schedule.value.restoreLocation(id);
}
function newLocation() { function newLocation() {
const location = new ClientScheduleLocation( const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale;
const location = ClientScheduleLocation.create(
schedule.value.nextClientId--, schedule.value.nextClientId--,
DateTime.now(),
false,
newLocationName.value, newLocationName.value,
newLocationDescription.value, newLocationDescription.value,
{ zone, locale },
); );
schedule.value.setLocation(location); schedule.value.locations.add(location);
newLocationName.value = ""; newLocationName.value = "";
newLocationDescription.value = ""; newLocationDescription.value = "";
} }

View file

@ -20,27 +20,25 @@
<td> <td>
<input <input
type="text" type="text"
:value="role.name" v-model="role.name"
@input="editRole(role, { name: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<input <input
type="text" type="text"
:value="role.description" v-model="role.description"
@input="editRole(role, { description: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<button <button
type="button" type="button"
:disabled="role.deleted" :disabled="role.deleted"
@click="editRole(role, { deleted: true })" @click="role.deleted = true"
>Delete</button> >Delete</button>
<button <button
v-if="schedule.isModifiedRole(role.id)" v-if="role.isModified()"
type="button" type="button"
@click="revertRole(role.id)" @click="schedule.roles.discardId(role.id)"
>Revert</button> >Revert</button>
</td> </td>
</tr> </tr>
@ -87,36 +85,19 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DateTime } from '~/shared/utils/luxon'; import { Info } from '~/shared/utils/luxon';
import { toId } from '~/shared/utils/functions'; import { toId } from '~/shared/utils/functions';
import type { Id } from '~/shared/types/common';
defineProps<{ defineProps<{
edit?: boolean, edit?: boolean,
}>(); }>();
const schedule = await useSchedule(); const schedule = await useSchedule();
const accountStore = useAccountStore();
const newRoleName = ref(""); const newRoleName = ref("");
const newRoleDescription = ref(""); const newRoleDescription = ref("");
function editRole(
role: ClientScheduleRole,
edits: { deleted?: boolean, name?: string, description?: string }
) {
const copy = role.clone();
if (edits.deleted !== undefined) copy.deleted = edits.deleted;
if (edits.name !== undefined) copy.name = edits.name;
if (edits.description !== undefined) copy.description = edits.description;
try {
schedule.value.setRole(copy);
} catch (err: any) {
alert(err.message);
}
}
function revertRole(id: Id) {
schedule.value.restoreRole(id);
}
function roleExists(name: string) { function roleExists(name: string) {
name = toId(name); name = toId(name);
return ( return (
@ -128,14 +109,15 @@ function newRole() {
alert(`Role ${newRoleName.value} already exists`); alert(`Role ${newRoleName.value} already exists`);
return; return;
} }
const role = new ClientScheduleRole( const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale;
const role = ClientScheduleRole.create(
schedule.value.nextClientId--, schedule.value.nextClientId--,
DateTime.now(),
false,
newRoleName.value, newRoleName.value,
newRoleDescription.value, newRoleDescription.value,
{ zone, locale },
); );
schedule.value.setRole(role); schedule.value.roles.add(role);
newRoleName.value = ""; newRoleName.value = "";
newRoleDescription.value = ""; newRoleDescription.value = "";
} }

View file

@ -92,42 +92,39 @@
<td> <td>
<input <input
type="text" type="text"
:value="ss.name" v-model="ss.name"
@input="editShift(ss, { name: ($event as any).target.value })"
> >
</td> </td>
<td>{{ status(ss) }}</td> <td>{{ status(ss) }}</td>
<td> <td>
<select <select
:value="ss.role.id" v-model="ss.shift.roleId"
@change="editShift(ss, { role: schedule.roles.get(parseInt(($event as any).target.value)) })"
> >
<option <option
v-for="role in schedule.roles.values()" v-for="role in schedule.roles.values()"
:key="role.id" :key="role.id"
:value="role.id" :value="role.id"
:disabled="role.deleted" :disabled="role.deleted"
:selected="role.id === ss.role.id" :selected="role.id === ss.shift.roleId"
>{{ role.name }}</option> >{{ role.name }}</option>
</select> </select>
</td> </td>
<td> <td>
<AssignedCrew <AssignedCrew
:edit="true" :edit="true"
:modelValue="ss.assigned" v-model="ss.slot.assigned"
@update:modelValue="editShiftSlot(ss, { assigned: $event })"
/> />
</td> </td>
<td> <td>
<button <button
:disabled="ss.deleted" :disabled="ss.deleted"
type="button" type="button"
@click="editShiftSlot(ss, { deleted: true })" @click="ss.slot.deleted = true"
>Remove</button> >Remove</button>
<button <button
v-if="schedule.isModifiedShiftSlot(ss.slot.id)" v-if="ss.slot.isModified()"
type="button" type="button"
@click="revertShiftSlot(ss.id)" @click="ss.shift.discardSlot(ss.slot.id)"
>Revert</button> >Revert</button>
</td> </td>
</template> </template>
@ -185,7 +182,7 @@
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td> <td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td> <td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td> <td>{{ status(ss) }}</td>
<td>{{ ss.role.id }}</td> <td>{{ ss.roleId }}</td>
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td> <td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
</template> </template>
</tr> </tr>
@ -213,7 +210,7 @@ interface ShiftSlot {
shift: ClientScheduleShift, shift: ClientScheduleShift,
slot: ClientScheduleShiftSlot, slot: ClientScheduleShiftSlot,
name: string, name: string,
role: ClientScheduleRole, roleId: Id,
assigned: Set<Id>, assigned: Set<Id>,
start: DateTime, start: DateTime,
end: DateTime, end: DateTime,
@ -290,42 +287,26 @@ function durationFromTime(time: string) {
return duration; return duration;
} }
const newShiftName = ref(""); const newShiftName = ref("");
function editShift(
shiftSlot: ShiftSlot,
edits: Parameters<ClientSchedule["editShift"]>[1],
) {
schedule.value.editShift(shiftSlot.shift, edits);
}
function editShiftSlot( function editShiftSlot(
shiftSlot: ShiftSlot, shiftSlot: ShiftSlot,
edits: { edits: {
deleted?: boolean,
start?: string, start?: string,
end?: string, end?: string,
duration?: string, duration?: string,
assigned?: Set<Id>,
} }
) { ) {
const computedEdits: Parameters<ClientSchedule["editShiftSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
if (edits.start) { if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale }); const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start; shiftSlot.start = start;
computedEdits.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start)); shiftSlot.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
} }
if (edits.end !== undefined) { if (edits.end !== undefined) {
computedEdits.end = endFromTime(shiftSlot.start, edits.end); shiftSlot.end = endFromTime(shiftSlot.start, edits.end);
} }
if (edits.duration !== undefined) { if (edits.duration !== undefined) {
computedEdits.end = shiftSlot.start.plus(durationFromTime(edits.duration)); shiftSlot.end = shiftSlot.start.plus(durationFromTime(edits.duration));
} }
schedule.value.editShiftSlot(shiftSlot.slot, computedEdits);
}
function revertShiftSlot(id: Id) {
schedule.value.restoreShiftSlot(id);
} }
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) { function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value; const name = newShiftName.value;
@ -361,15 +342,16 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
alert("Invalid start and/or end time"); alert("Invalid start and/or end time");
return; return;
} }
const slot = new ClientScheduleShiftSlot( const slot = ClientScheduleShiftSlot.create(
schedule.value,
schedule.value.nextClientId--, schedule.value.nextClientId--,
false,
shift.id, shift.id,
start, start,
end, end,
new Set(), new Set(),
); );
schedule.value.setShiftSlot(slot); schedule.value.shiftSlots.set(slot.id, slot);
shift.slotIds.add(slot.id);
newShiftName.value = ""; newShiftName.value = "";
} }
@ -386,7 +368,7 @@ function gapFormat(gap: Gap) {
const shiftSlots = computed(() => { const shiftSlots = computed(() => {
const data: (ShiftSlot | Gap)[] = []; const data: (ShiftSlot | Gap)[] = [];
for (const shift of schedule.value.shifts.values()) { for (const shift of schedule.value.shifts.values()) {
if (props.roleId !== undefined && shift.role.id !== props.roleId) if (props.roleId !== undefined && shift.roleId !== props.roleId)
continue; continue;
for (const slot of shift.slots.values()) { for (const slot of shift.slots.values()) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot)) if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
@ -398,7 +380,7 @@ const shiftSlots = computed(() => {
shift, shift,
slot, slot,
name: shift.name, name: shift.name,
role: shift.role, roleId: shift.roleId,
assigned: slot.assigned, assigned: slot.assigned,
start: slot.start, start: slot.start,
end: slot.end, end: slot.end,

View file

@ -22,21 +22,19 @@
<td> <td>
<input <input
type="text" type="text"
:value="shift.name" v-model="shift.name"
@input="editShift(shift, { name: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<select <select
:value="shift.role.id" v-model="shift.roleId"
@change="editShift(shift, { role: schedule.roles.get(parseInt(($event as any).target.value, 10)) })"
> >
<option <option
v-for="role in schedule.roles.values()" v-for="role in schedule.roles.values()"
:key="role.id" :key="role.id"
:value="role.id" :value="role.id"
:disabled="shift.deleted" :disabled="shift.deleted"
:selected="shift.role.id === role.id" :selected="shift.roleId === role.id"
>{{ role.name }}</option> >{{ role.name }}</option>
</select> </select>
</td> </td>
@ -44,20 +42,19 @@
<td> <td>
<input <input
type="text" type="text"
:value="shift.description" v-model="shift.description"
@input="editShift(shift, { description: ($event as any).target.value })"
> >
</td> </td>
<td> <td>
<button <button
type="button" type="button"
:disabled="shift.deleted" :disabled="shift.deleted"
@click="editShift(shift, { deleted: true })" @click="shift.deleted = true"
>Delete</button> >Delete</button>
<button <button
v-if="schedule.isModifiedShift(shift.id)" v-if="shift.isModified()"
type="button" type="button"
@click="revertShift(shift.id)" @click="schedule.shifts.discardId(shift.id)"
>Revert</button> >Revert</button>
</td> </td>
</tr> </tr>
@ -107,7 +104,7 @@
> >
<td>{{ shift.id }}</td> <td>{{ shift.id }}</td>
<td>{{ shift.name }}</td> <td>{{ shift.name }}</td>
<td>{{ shift.role.id }}</td> <td>{{ shift.roleId }}</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td> <td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>{{ shift.description }}</td> <td>{{ shift.description }}</td>
</tr> </tr>
@ -118,8 +115,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DateTime } from '~/shared/utils/luxon'; import { Info } from '~/shared/utils/luxon';
import type { Id } from '~/shared/types/common';
import { toId } from '~/shared/utils/functions'; import { toId } from '~/shared/utils/functions';
const props = defineProps<{ const props = defineProps<{
@ -128,6 +124,7 @@ const props = defineProps<{
}>(); }>();
const schedule = await useSchedule(); const schedule = await useSchedule();
const accountStore = useAccountStore();
const newShiftName = ref(""); const newShiftName = ref("");
const newShiftRoleId = ref(props.roleId); const newShiftRoleId = ref(props.roleId);
@ -135,15 +132,6 @@ watch(() => props.roleId, () => {
newShiftRoleId.value = props.roleId; newShiftRoleId.value = props.roleId;
}); });
const newShiftDescription = ref(""); const newShiftDescription = ref("");
function editShift(
shift: ClientScheduleShift,
edits: Parameters<ClientSchedule["editShift"]>[1],
) {
schedule.value.editShift(shift, edits);
}
function revertShift(id: Id) {
schedule.value.restoreShift(id);
}
function shiftExists(name: string) { function shiftExists(name: string) {
name = toId(name); name = toId(name);
return ( return (
@ -155,21 +143,22 @@ function newShift() {
alert(`Shift ${newShiftName.value} already exists`); alert(`Shift ${newShiftName.value} already exists`);
return; return;
} }
const role = schedule.value.roles.get(newShiftRoleId.value!); if (newShiftRoleId.value === undefined) {
if (!role) {
alert(`Invalid role`); alert(`Invalid role`);
return; return;
} }
const shift = new ClientScheduleShift( const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale;
const shift = ClientScheduleShift.create(
schedule.value,
schedule.value.nextClientId--, schedule.value.nextClientId--,
DateTime.now(), newShiftRoleId.value,
false,
role,
newShiftName.value, newShiftName.value,
newShiftDescription.value, newShiftDescription.value,
new Map(), new Set(),
{ zone, locale },
); );
schedule.value.setShift(shift); schedule.value.shifts.add(shift);
newShiftName.value = ""; newShiftName.value = "";
newShiftDescription.value = ""; newShiftDescription.value = "";
} }

View file

@ -191,8 +191,8 @@ function* edgesFromShifts(
if (slot.start > slot.end) { if (slot.start > slot.end) {
throw new Error(`Slot ${slot.id} ends before it starts.`); throw new Error(`Slot ${slot.id} ends before it starts.`);
} }
yield { type: "start", source: "shift", roleId: shift.role.id, slot }; yield { type: "start", source: "shift", roleId: shift.roleId, slot };
yield { type: "end", source: "shift", roleId: shift.role.id, slot }; yield { type: "end", source: "shift", roleId: shift.roleId, slot };
} }
} }
} }
@ -214,8 +214,8 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
function* spansFromJunctions( function* spansFromJunctions(
junctions: Iterable<Junction>, junctions: Iterable<Junction>,
locations: Map<Id, ClientScheduleLocation>, locations: ClientMap<ClientScheduleLocation>,
roles: Map<Id, ClientScheduleRole>, roles: ClientMap<ClientScheduleRole>,
): Generator<Span> { ): Generator<Span> {
const activeLocations = new Map( const activeLocations = new Map(
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()]) [...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
@ -227,8 +227,8 @@ function* spansFromJunctions(
for (const edge of start.edges) { for (const edge of start.edges) {
if (edge.type === "start") { if (edge.type === "start") {
if (edge.source === "event") { if (edge.source === "event") {
for (const location of edge.slot.locations) { for (const locationId of edge.slot.locationIds) {
activeLocations.get(location.id)?.add(edge.slot) activeLocations.get(locationId)?.add(edge.slot)
} }
} else if (edge.source === "shift") { } else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.add(edge.slot) activeRoles.get(edge.roleId)?.add(edge.slot)
@ -252,8 +252,8 @@ function* spansFromJunctions(
for (const edge of end.edges) { for (const edge of end.edges) {
if (edge.type === "end") { if (edge.type === "end") {
if (edge.source === "event") { if (edge.source === "event") {
for (const location of edge.slot.locations) { for (const locationId of edge.slot.locationIds) {
activeLocations.get(location.id)?.delete(edge.slot) activeLocations.get(locationId)?.delete(edge.slot)
} }
} else if (edge.source === "shift") { } else if (edge.source === "shift") {
activeRoles.get(edge.roleId)?.delete(edge.slot); activeRoles.get(edge.roleId)?.delete(edge.slot);
@ -367,10 +367,10 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
function tableElementsFromStretches( function tableElementsFromStretches(
stretches: Iterable<Stretch>, stretches: Iterable<Stretch>,
events: Map<Id, ClientScheduleEvent>, events: ClientMap<ClientScheduleEvent>,
locations: Map<Id, ClientScheduleLocation>, locations: ClientMap<ClientScheduleLocation>,
shifts: Map<Id, ClientScheduleShift>, shifts: ClientMap<ClientScheduleShift>,
roles: Map<Id, ClientScheduleRole>, roles: ClientMap<ClientScheduleRole>,
timezone: string, timezone: string,
) { ) {
type Col = { minutes?: number }; type Col = { minutes?: number };

View file

@ -78,7 +78,7 @@
<TableScheduleShiftSlots :edit="true" :roleId="roleFilter" :shiftSlotFilter /> <TableScheduleShiftSlots :edit="true" :roleId="roleFilter" :shiftSlotFilter />
</template> </template>
</Tabs> </Tabs>
<p v-if="schedule.modified"> <p v-if="schedule.isModified()">
Changes are not saved yet. Changes are not saved yet.
<button <button
type="button" type="button"

View file

@ -1,8 +1,12 @@
import { Info } from "~/shared/utils/luxon"; import { Info } from "~/shared/utils/luxon";
import { ClientEntityNew } from "~/utils/client-user"; import { ClientEntity } from "~/utils/client-entity";
const typeMap: Record<string, EntityClass<ClientEntityNew>> = { const typeMap: Record<string, EntityClass<ClientEntity>> = {
"user": ClientUser, "user": ClientUser,
"schedule-location": ClientScheduleLocation,
"schedule-event": ClientScheduleEvent,
"schedule-role": ClientScheduleRole,
"schedule-shift": ClientScheduleShift,
}; };
const classMap = new Map(Object.entries(typeMap).map(([k, v]) => [v, k])); const classMap = new Map(Object.entries(typeMap).map(([k, v]) => [v, k]));

View file

@ -98,7 +98,7 @@ export const useSchedulesStore = defineStore("schedules", () => {
if (schedule && !schedule.value.deleted && !update.deleted) { if (schedule && !schedule.value.deleted && !update.deleted) {
const zone = Info.normalizeZone(accountStore.activeTimezone); const zone = Info.normalizeZone(accountStore.activeTimezone);
const locale = accountStore.activeLocale; const locale = accountStore.activeLocale;
schedule.value.applyUpdate(update, { zone, locale }) schedule.value.apiUpdate(update, { zone, locale })
} }
}); });

82
utils/client-entity.ts Normal file
View file

@ -0,0 +1,82 @@
import type { Entity, EntityLiving, Id } from "~/shared/types/common";
import { DateTime, Zone } from "~/shared/utils/luxon";
export abstract class ClientEntity {
/**
Millisecond offset used to indicate this is a new entitity.
*/
static newEntityMillis = -1;
/**
Timestamp of the entity received from server. If this is
a new entity this will have a millisecond offset equal to
{@link ClientEntity.newEntityMillis}.
*/
serverUpdatedAt: DateTime;
/**
True if the server has deleted this entity, but the client
is holding on to it in order to resolve an edit conflcit
*/
serverDeleted: boolean;
constructor(
/**
Server supplied id of this entity. Each kind of entity has its own namespace of ids.
*/
public readonly id: Id,
/**
Server's timestamp of this entity at the time it was modified. If the entity
is unmodified this will track {@link serverUpdatedAt}. If this is a new entity
it'll have a millesecond offset equal to {@link ClientEntity.newEntityMillis}.
*/
public updatedAt: DateTime,
/**
Flag indicating the client intends to delete this entity.
*/
public deleted: boolean,
) {
this.serverUpdatedAt = updatedAt;
this.serverDeleted = deleted;
}
/**
True if this entity does not yet exist on the server.
*/
isNew() {
return this.serverUpdatedAt.toMillis() === ClientEntity.newEntityMillis;
}
/**
True if both the server and the client have modified this entity
independently of each other.
*/
isConflict() {
return this.serverUpdatedAt.toMillis() !== this.updatedAt.toMillis();
}
/**
True if this entity has been modified on the client.
*/
isModified() {
return (
this.isNew()
|| this.deleted
|| this.serverDeleted
);
}
/**
Discard any client side modifications to this entity. Not allowed if
{@link serverDeleted} is true or this is a new entity.
*/
abstract discard(): void
/**
Apply an update delivered from the API to this entity.
*/
abstract apiUpdate(api: EntityLiving, opts: { zone: Zone, locale: string }): void
/**
Serialise this entity to the API format. Not allowed if {@link deleted} is true.
*/
abstract toApi(): Entity
}

View file

@ -1,5 +1,6 @@
import { ClientMap, type ApiMap } from "~/utils/client-map"; import { ClientMap } from "~/utils/client-map";
import { ClientEntityNew, ClientUser } from "~/utils/client-user"; import { ClientEntity } from "~/utils/client-entity";
import { ClientUser } from "~/utils/client-user";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon"; import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
import type { ApiUser } from "~/shared/types/api"; import type { ApiUser } from "~/shared/types/api";
@ -79,7 +80,7 @@ describe("class ClientMap", () => {
const timestamps: Record<string, DateTime> = { const timestamps: Record<string, DateTime> = {
"-": now, "-": now,
"l": laterIsNow ? now : later, "l": laterIsNow ? now : later,
"n": DateTime.fromMillis(ClientEntityNew.newEntityMillis), "n": DateTime.fromMillis(ClientEntity.newEntityMillis),
}; };
const user = new ClientUser(1, now, false, "", "regular"); const user = new ClientUser(1, now, false, "", "regular");
user.serverUpdatedAt = timestamps[serverTs]; user.serverUpdatedAt = timestamps[serverTs];

View file

@ -1,12 +1,12 @@
import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common"; import { type Entity, type EntityLiving, type Id, type Living } from "~/shared/types/common";
import { DateTime, Zone } from "~/shared/utils/luxon"; import { DateTime, Zone } from "~/shared/utils/luxon";
import { ClientEntityNew } from "~/utils/client-user"; import { ClientEntity } from "~/utils/client-entity";
export interface EntityClass<T extends ClientEntityNew> { export interface EntityClass<T extends ClientEntity> {
fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T, fromApi(api: EntityLiving, opts: { zone: Zone, locale: string }): T,
} }
export class ClientMap<T extends ClientEntityNew> { export class ClientMap<T extends ClientEntity> {
constructor( constructor(
public EntityClass: EntityClass<T>, public EntityClass: EntityClass<T>,
public map: Map<Id, T>, public map: Map<Id, T>,
@ -18,10 +18,18 @@ export class ClientMap<T extends ClientEntityNew> {
return this.map.get(id); return this.map.get(id);
} }
keys() {
return this.map.keys();
}
values() { values() {
return this.map.values(); return this.map.values();
} }
get size() {
return this.map.size;
}
isModified() { isModified() {
return [...this.map.values()].some(entity => entity.isModified()); return [...this.map.values()].some(entity => entity.isModified());
} }
@ -33,7 +41,13 @@ export class ClientMap<T extends ClientEntityNew> {
this.map.set(entity.id, entity); this.map.set(entity.id, entity);
} }
discard(id: Id) { discard() {
for (const id of this.keys()) {
this.discardId(id);
}
}
discardId(id: Id) {
const entity = this.map.get(id); const entity = this.map.get(id);
if (!entity) { if (!entity) {
throw new Error("ClientMap.discard: entity does not exist."); throw new Error("ClientMap.discard: entity does not exist.");
@ -48,7 +62,7 @@ export class ClientMap<T extends ClientEntityNew> {
} }
} }
static fromApi<T extends Entity, U extends ClientEntityNew>( static fromApi<T extends Entity, U extends ClientEntity>(
EntityClass: EntityClass<U>, EntityClass: EntityClass<U>,
entities: T[], entities: T[],
opts: { zone: Zone, locale: string }, opts: { zone: Zone, locale: string },

View file

@ -1,4 +1,5 @@
import { ClientEntity, ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "~/utils/client-schedule"; import { ClientEntity } from "~/utils/client-entity";
import { ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "~/utils/client-schedule";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { ApiSchedule } from "~/shared/types/api"; import type { ApiSchedule } from "~/shared/types/api";
import type { Id, Living } from "~/shared/types/common"; import type { Id, Living } from "~/shared/types/common";
@ -9,50 +10,64 @@ const now = DateTime.now().setLocale(locale);
const later = now.plus({ minutes: 2 }); const later = now.plus({ minutes: 2 });
const zone = now.zone; const zone = now.zone;
const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO(); const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO();
const laterIso = later.setZone(FixedOffsetZone.utcInstance).toISO();
function fixtureClientSchedule() { function fixtureClientSchedule(multiSlot = false) {
const left = new ClientScheduleLocation(1, now, false, "Left", ""); const left = new ClientScheduleLocation(1, now, false, "Left", "");
const right = new ClientScheduleLocation(2, now, false, "Right", "This is the right place"); const right = new ClientScheduleLocation(2, now, false, "Right", "This is the right place");
const events = [ const events = [
new ClientScheduleEvent( new ClientScheduleEvent(
1, now, false, "Up", false, "", false, "What's Up?", 0, 1, now, false, "Up", false, "", false, "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]),
idMap([new ClientScheduleEventSlot(1, false, 1, now, now.plus({ hours: 1 }), [left], new Set(), 0)]),
), ),
new ClientScheduleEvent( new ClientScheduleEvent(
2, now, false, "Down", false, "", false, "", 0, 2, now, false, "Down", false, "", false, "", 0, new Set(multiSlot ? [] : [2]),
idMap([new ClientScheduleEventSlot(2, false, 2, now, now.plus({ hours: 2 }), [right], new Set(), 0)]),
), ),
]; ];
const eventSlots = idMap([
new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), new Set(), 0),
new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), new Set(), 0),
]);
const red = new ClientScheduleRole(1, now, false, "Red", "Is a color."); const red = new ClientScheduleRole(1, now, false, "Red", "Is a color.");
const blue = new ClientScheduleRole(2, now, false, "Blue", ""); const blue = new ClientScheduleRole(2, now, false, "Blue", "");
const shifts = [ const shifts = [
new ClientScheduleShift( new ClientScheduleShift(
1, now, false, red, "White", "", 1, now, false, red.id, "White", "", new Set(multiSlot ? [1, 2] : [1]),
idMap([new ClientScheduleShiftSlot(1, false, 1, now, now.plus({ hours: 1 }), new Set())]),
), ),
new ClientScheduleShift( new ClientScheduleShift(
2, now, false, blue, "Black", "Is dark.", 2, now, false, blue.id, "Black", "Is dark.", new Set(multiSlot ? [] : [2]),
idMap([new ClientScheduleShiftSlot(2, false, 2, now, now.plus({ hours: 2 }), new Set())]),
), ),
]; ];
const shiftSlots = idMap([
new ClientScheduleShiftSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set()),
new ClientScheduleShiftSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set()),
]);
return new ClientSchedule( const schedule = new ClientSchedule(
111, 111,
now, now,
false, false,
new Map([ new ClientMap(ClientScheduleLocation, idMap([left, right]), new Map()),
[left.id, left], new ClientMap(ClientScheduleEvent, idMap(events), new Map()),
[right.id, right], eventSlots,
]), new ClientMap(ClientScheduleRole, idMap([red, blue]), new Map()),
new Map(events.map(event => [event.id, event])), new ClientMap(ClientScheduleShift, idMap(shifts), new Map()),
new Map([ shiftSlots,
[red.id, red],
[blue.id, blue],
]),
new Map(shifts.map(shift => [shift.id, shift])),
); );
for (const event of events.values()) {
event.schedule = schedule;
}
for (const eventSlot of eventSlots.values()) {
eventSlot.schedule = schedule;
}
for (const shift of shifts.values()) {
shift.schedule = schedule;
}
for (const shiftSlot of shiftSlots.values()) {
shiftSlot.schedule = schedule;
}
return schedule;
} }
function fixtureApiSchedule(): Living<ApiSchedule> { function fixtureApiSchedule(): Living<ApiSchedule> {
@ -149,77 +164,22 @@ describe("class ClientSchedule", () => {
expect(schedule.toApi(false)).toEqual(fixtureApiSchedule()) expect(schedule.toApi(false)).toEqual(fixtureApiSchedule())
}); });
const updatePatterns = [
"aa a aa",
"ba a aa",
"-a a aa",
"ab a ab",
"bb a aa",
"-b a ab",
"ax a ax",
"bx a ax",
"-- a aa",
"-x a ax",
"aa x --",
"ba x -a",
"-a x -a",
"ab x -b",
"bb x --",
"-b x -b",
"ax x --",
"bx x --",
"-x x --",
"-- x --",
];
for (const pattern of updatePatterns) {
test(`apply diff pattern ${pattern}`, () => {
const fixtureClient: Record<string, ClientScheduleLocation> = {
a: new ClientScheduleLocation(1, now, false, "A", ""),
b: new ClientScheduleLocation(1, now, false, "B", ""),
x: new ClientScheduleLocation(1, now, true, "X", ""),
};
const fixtureServer: Record<string, ClientScheduleLocation> = {
a: new ClientScheduleLocation(1, later, false, "A", ""),
b: new ClientScheduleLocation(1, later, false, "B", ""),
x: new ClientScheduleLocation(1, later, true, "X", ""),
};
const schedule = new ClientSchedule(111, now, false, new Map(), new Map(), new Map(), new Map());
if (fixtureClient[pattern[0]])
schedule.originalLocations.set(1, fixtureClient[pattern[0]]);
if (fixtureClient[pattern[1]])
schedule.locations.set(1, fixtureClient[pattern[1]]);
const update = fixtureServer[pattern[3]];
const expectedOriginalLocation = pattern[5] === "x" ? undefined : fixtureServer[pattern[5]];
const expectedLocation = pattern[5] === pattern[6] ? fixtureServer[pattern[6]] : fixtureClient[pattern[6]];
schedule.applyUpdate({
id: 111,
updatedAt: nowIso,
locations: [update.toApi()],
}, { zone, locale });
expect(schedule.originalLocations.get(1)).toEqual(expectedOriginalLocation);
expect(schedule.locations.get(1)).toEqual(expectedLocation);
if (pattern.slice(5) === "aa")
expect(schedule.originalLocations.get(1)).toBe(schedule.locations.get(1));
});
}
const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [ const entityTests: [string, (schedule: ClientSchedule) => ClientEntity][] = [
[ [
"location", "location",
() => new ClientScheduleLocation(3, now, false, "New location", "") () => ClientScheduleLocation.create(3, "New location", "", { zone, locale })
], ],
[ [
"event", "event",
() => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, new Map()) (schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", 0, new Set(), { zone, locale })
], ],
[ [
"role", "role",
() => new ClientScheduleRole(3, now, false, "New location", "") () => ClientScheduleRole.create(3, "New location", "", { zone, locale })
], ],
[ [
"shift", "shift",
(schedule) => new ClientScheduleShift(3, now, false, schedule.roles.get(1)!, "New location", "", new Map()) (schedule) => ClientScheduleShift.create(schedule, 3, 1, "New location", "", new Set(), { zone, locale })
], ],
] as const; ] as const;
for (const [name, create] of entityTests) { for (const [name, create] of entityTests) {
@ -228,260 +188,439 @@ describe("class ClientSchedule", () => {
test(`create`, () => { test(`create`, () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
const entity = create(schedule); const entity = create(schedule);
expect(schedule.modified).toBe(false);
// Create // Create
(schedule as any)[`set${Name}`](entity); (schedule as any)[`${name}s`].add(entity);
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect((schedule as any)[`isModified${Name}`](entity.id)).toBe(true); expect((schedule as any)[`${name}s`].get(entity.id).isModified()).toBe(true);
expect((schedule as any)[`original${Name}s`].get(entity.id)).toBe(undefined); expect((schedule as any)[`${name}s`].get(entity.id).isNew()).toBe(true);
expect((schedule as any)[`${name}s`].get(entity.id)).toBe(entity); expect((schedule as any)[`${name}s`].get(entity.id)).toBe(entity);
}); });
test("edit", () => { test("edit", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
const original = (schedule as any)[`${name}s`].get(1); const entity = (schedule as any)[`${name}s`].get(1);
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect((schedule as any)[`isModified${Name}`](1)).toBe(false); expect(entity.isModified()).toBe(false);
const originalName = entity.name;
// Edit // Edit
(schedule as any)[`edit${Name}`](original, { name: `Modified ${name}` }) entity.name = `Modified ${name}`;
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect((schedule as any)[`isModified${Name}`](1)).toBe(true); expect(entity.isModified()).toBe(true);
expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); expect(entity.serverName).toBe(originalName);
if (name === "location") {
expect(schedule.events.get(1)!.slots.get(1)!.locations[0]).toBe(schedule.locations.get(1));
} else if (name === "role") {
expect(schedule.shifts.get(1)!.role).toBe(schedule.roles.get(1));
}
}); });
if (name === "location") { if (name === "location") {
test("delete location in use throws", () => { test.skip("delete location in use throws", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect( expect(
() => { schedule.editLocation(schedule.locations.get(1)!, { deleted: true }); } () => { schedule.locations.get(1)!.deleted = true; }
).toThrow(new Error('Cannot delete location, event "Up" depends on it')); ).toThrow(new Error('Cannot delete location, event "Up" depends on it'));
}); });
} else if (name === "role") { } else if (name === "role") {
test("delete role in use throws", () => { test.skip("delete role in use throws", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect( expect(
() => { schedule.editRole(schedule.roles.get(1)!, { deleted: true }); } () => { schedule.roles.get(1)!.deleted = true; }
).toThrow(new Error('Cannot delete role, shift "White" depends on it')); ).toThrow(new Error('Cannot delete role, shift "White" depends on it'));
}); });
} }
test("delete", () => { test("delete", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
const original = (schedule as any)[`${name}s`].get(1); const entity = (schedule as any)[`${name}s`].get(1);
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect((schedule as any)[`isModified${Name}`](1)).toBe(false); expect(entity.isModified()).toBe(false);
// Delete // Delete
if (name === "location") { if (name === "location") {
schedule.editEvent(schedule.events.get(1)!, { deleted: true }); schedule.events.get(1)!.deleted = true;
} else if (name === "role") { } else if (name === "role") {
schedule.editShift(schedule.shifts.get(1)!, { deleted: true }); schedule.shifts.get(1)!.deleted = true;
} }
(schedule as any)[`edit${Name}`](original, { deleted: true }) entity.deleted = true;
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect((schedule as any)[`isModified${Name}`](1)).toBe(true); expect(entity.isModified()).toBe(true);
expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); expect(entity.serverDeleted).toBe(false);
expect((schedule as any)[`${name}s`].get(1).deleted).toBe(true); expect(entity.deleted).toBe(true);
}); });
}); });
} }
function validateSlotRelations(
slots: Map<Id, ClientScheduleShiftSlot> | Map<Id, ClientScheduleEventSlot>,
entites: Map<Id, ClientScheduleShift> | Map<Id, ClientScheduleEvent>,
prefix: string,
) {
const remainingIds = new Set(slots.keys())
for (const shift of entites.values()) {
for (const slot of shift.slots.values()) {
if (!slots.has(slot.id)) {
throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that is not in shiftSlots`);
}
if (slots.get(slot.id) !== slot) {
throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} which does not match the corresponding slot in shiftSlots.`);
}
if (!remainingIds.has(slot.id)) {
throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that has been seen twice.`);
}
remainingIds.delete(slot.id);
}
}
if (remainingIds.size) {
throw Error(`${prefix}: shiftSlots ${[...remainingIds].join(", ")} does not have a corresponding shift`);
}
}
describe("event slot", () => { describe("event slot", () => {
test("edit", () => { test("edit", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedEvent(1)).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
// Modify // Modify
schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); schedule.eventSlots.get(1)!.start = later;
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.eventSlots.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.eventSlots, schedule.events, "current"); });
validateSlotRelations(schedule.eventSlots, schedule.events, "original"); test("add and apply", () => {
const schedule = fixtureClientSchedule();
// Add
const slot = ClientScheduleEventSlot.create(
schedule,
3,
1,
now,
now.plus({ hours: 3 }),
new Set(),
new Set(),
0,
);
schedule.eventSlots.set(slot.id, slot);
schedule.events.get(1)!.slotIds.add(slot.id);
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
events: [{
id: 1,
updatedAt: nowIso,
name: "Up",
description: "What's Up?",
slots: [
{
id: 1,
start: nowIso,
end: toIso(now.plus({ hours: 1 })),
locationIds: [1],
},
{
id: 3,
start: nowIso,
end: toIso(now.plus({ hours: 3 })),
locationIds: [],
},
],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(3)!.isNewEntity).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(2);
expect(schedule.events.get(2)!.slots.size).toBe(1);
});
test("edit and apply", () => {
const schedule = fixtureClientSchedule();
// Modify
schedule.eventSlots.get(1)!.locationIds.add(2);
schedule.eventSlots.get(1)!.locationIds.delete(1);
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
events: [{
id: 1,
updatedAt: laterIso,
name: "Up",
description: "What's Up?",
slots: [{
id: 1,
start: nowIso,
end: toIso(now.plus({ hours: 1 })),
locationIds: [2],
}],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.eventSlots.get(1)!.locationIds).toEqual(new Set([2]));
expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(1);
});
test("delete and apply", () => {
const schedule = fixtureClientSchedule(true);
// delete
schedule.eventSlots.get(1)!.deleted = true;
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
events: [{
id: 1,
updatedAt: laterIso,
name: "Up",
description: "What's Up?",
slots: [{
id: 2,
start: nowIso,
end: toIso(now.plus({ hours: 2 })),
locationIds: [2],
}],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.eventSlots.has(1)).toBe(false);
expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(0);
}); });
test("move to another event", () => { test("move to another event", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedEvent(1)).toBe(false); expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
// Modify // Modify
schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); schedule.eventSlots.get(1)!.setEventId(2);
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.events.get(2)!.isModified()).toBe(true);
expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.eventSlots.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(0); expect(schedule.events.get(1)!.slots.size).toBe(0);
expect(schedule.events.get(2)!.slots.size).toBe(2); expect(schedule.events.get(2)!.slots.size).toBe(2);
validateSlotRelations(schedule.eventSlots, schedule.events, "current");
validateSlotRelations(schedule.eventSlots, schedule.events, "original");
// Move back // Move back
schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 1 }); schedule.eventSlots.get(1)!.setEventId(1);
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(true); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.eventSlots, schedule.events, "current");
validateSlotRelations(schedule.eventSlots, schedule.events, "original");
}); });
test("restore", () => { test("discard", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); schedule.eventSlots.get(1)!.start = later;
schedule.restoreEventSlot(1); schedule.eventSlots.get(1)!.discard();
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEvent(2)).toBe(false); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.eventSlots, schedule.events, "current");
validateSlotRelations(schedule.eventSlots, schedule.events, "original");
}); });
test("restore from another event", () => { test("discard from another event", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); schedule.eventSlots.get(1)!.setEventId(2);
schedule.restoreEventSlot(1); schedule.eventSlots.get(1)!.discard();
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedEvent(1)).toBe(true); expect(schedule.events.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEvent(2)).toBe(true); expect(schedule.events.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(1)).toBe(false); expect(schedule.eventSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedEventSlot(2)).toBe(false); expect(schedule.eventSlots.get(2)!.isModified()).toBe(false);
expect(schedule.events.get(1)!.slots.size).toBe(1); expect(schedule.events.get(1)!.slots.size).toBe(1);
expect(schedule.events.get(2)!.slots.size).toBe(1); expect(schedule.events.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.eventSlots, schedule.events, "current");
validateSlotRelations(schedule.eventSlots, schedule.events, "original");
}); });
}); });
describe("shift slot", () => { describe("shift slot", () => {
test("edit", () => { test("edit", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedShift(1)).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
// Modify // Modify
schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); schedule.shiftSlots.get(1)!.start = later;
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(1)!.slots.size).toBe(1);
expect(schedule.shifts.get(2)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); });
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); test("add and apply", () => {
const schedule = fixtureClientSchedule();
// Add
const slot = ClientScheduleShiftSlot.create(
schedule,
3,
1,
now,
now.plus({ hours: 3 }),
new Set(),
);
schedule.shiftSlots.set(slot.id, slot);
schedule.shifts.get(1)!.slotIds.add(slot.id);
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
shifts: [{
id: 1,
updatedAt: nowIso,
name: "White",
roleId: 1,
slots: [
{
id: 1,
start: nowIso,
end: toIso(now.plus({ hours: 1 })),
},
{
id: 3,
start: nowIso,
end: toIso(now.plus({ hours: 3 })),
},
],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(3)!.isNewEntity).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(2);
expect(schedule.shifts.get(2)!.slots.size).toBe(1);
});
test("edit and apply", () => {
const schedule = fixtureClientSchedule();
// Modify
schedule.shiftSlots.get(1)!.assigned.add(2);
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
shifts: [{
id: 1,
updatedAt: nowIso,
name: "White",
roleId: 1,
slots: [{
id: 1,
start: nowIso,
end: toIso(now.plus({ hours: 1 })),
assigned: [2],
}],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shiftSlots.get(1)!.assigned).toEqual(new Set([2]));
expect(schedule.shifts.get(1)!.slots.size).toBe(1);
});
test("delete and apply", () => {
const schedule = fixtureClientSchedule(true);
// delete
schedule.shiftSlots.get(1)!.deleted = true;
// Sanity check
expect(schedule.isModified()).toBe(true);
// Apply change
schedule.apiUpdate({
id: 111,
updatedAt: laterIso,
shifts: [{
id: 1,
updatedAt: nowIso,
name: "White",
roleId: 1,
slots: [{
id: 2,
start: nowIso,
end: toIso(now.plus({ hours: 2 })),
}],
}],
}, { zone, locale });
// Check
expect(schedule.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.shiftSlots.has(1)).toBe(false);
expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(1);
expect(schedule.shifts.get(2)!.slots.size).toBe(0);
}); });
test("move to another shift", () => { test("move to another shift", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
expect(schedule.modified).toBe(false); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedShift(1)).toBe(false); expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
// Modify // Modify
schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); schedule.shiftSlots.get(1)!.setShiftId(2);
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(true);
expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.shifts.get(2)!.isModified()).toBe(true);
expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(true);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(0); expect(schedule.shifts.get(1)!.slots.size).toBe(0);
expect(schedule.shifts.get(2)!.slots.size).toBe(2); expect(schedule.shifts.get(2)!.slots.size).toBe(2);
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current");
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original");
// Move back // Move back
schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 1 }); schedule.shiftSlots.get(1)!.setShiftId(1);
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(true); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(1)!.slots.size).toBe(1);
expect(schedule.shifts.get(2)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current");
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original");
}); });
test("restore", () => { test("discard", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); schedule.shiftSlots.get(1)!.start = later;
schedule.restoreShiftSlot(1); schedule.shiftSlots.get(1)!.discard();
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShift(2)).toBe(false); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(1)!.slots.size).toBe(1);
expect(schedule.shifts.get(2)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current");
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original");
}); });
test("restore from another shift", () => { test("discard from another shift", () => {
const schedule = fixtureClientSchedule(); const schedule = fixtureClientSchedule();
schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); schedule.shiftSlots.get(1)!.setShiftId(2);
schedule.restoreShiftSlot(1); schedule.shiftSlots.get(1)!.discard();
// Check // Check
expect(schedule.modified).toBe(true); expect(schedule.isModified()).toBe(false);
expect(schedule.isModifiedShift(1)).toBe(true); expect(schedule.shifts.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShift(2)).toBe(true); expect(schedule.shifts.get(2)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(1)).toBe(false); expect(schedule.shiftSlots.get(1)!.isModified()).toBe(false);
expect(schedule.isModifiedShiftSlot(2)).toBe(false); expect(schedule.shiftSlots.get(2)!.isModified()).toBe(false);
expect(schedule.shifts.get(1)!.slots.size).toBe(1); expect(schedule.shifts.get(1)!.slots.size).toBe(1);
expect(schedule.shifts.get(2)!.slots.size).toBe(1); expect(schedule.shifts.get(2)!.slots.size).toBe(1);
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current");
validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original");
}); });
}); });
}); });

File diff suppressed because it is too large Load diff

View file

@ -1,89 +1,9 @@
import type { ApiUser, ApiUserType } from "~/shared/types/api"; import type { ApiUser, ApiUserType } from "~/shared/types/api";
import type { Entity, EntityLiving, Id, Living } from "~/shared/types/common"; import type { Entity, EntityLiving, Id, Living } from "~/shared/types/common";
import { DateTime, Zone } from "~/shared/utils/luxon"; import { DateTime, Zone } from "~/shared/utils/luxon";
import { ClientEntity } from "~/utils/client-entity";
export abstract class ClientEntityNew { export class ClientUser extends ClientEntity {
/**
Millisecond offset used to indicate this is a new entitity.
*/
static newEntityMillis = -1;
/**
Timestamp of the entity received from server. If this is
a new entity this will have a millisecond offset equal to
{@link ClientEntityNew.newEntityMillis}.
*/
serverUpdatedAt: DateTime;
/**
True if the server has deleted this entity, but the client
is holding on to it in order to resolve an edit conflcit
*/
serverDeleted: boolean;
constructor(
/**
Server supplied id of this entity. Each kind of entity has its own namespace of ids.
*/
public readonly id: Id,
/**
Server's timestamp of this entity at the time it was modified. If the entity
is unmodified this will track {@link serverUpdatedAt}. If this is a new entity
it'll have a millesecond offset equal to {@link ClientEntityNew.newEntityMillis}.
*/
public updatedAt: DateTime,
/**
Flag indicating the client intends to delete this entity.
*/
public deleted: boolean,
) {
this.serverUpdatedAt = updatedAt;
this.serverDeleted = deleted;
}
/**
True if this entity does not yet exist on the server.
*/
isNew() {
return this.serverUpdatedAt.toMillis() === ClientEntityNew.newEntityMillis;
}
/**
True if both the server and the client have modified this entity
independently of each other.
*/
isConflict() {
return this.serverUpdatedAt.toMillis() !== this.updatedAt.toMillis();
}
/**
True if this entity has been modified on the client.
*/
isModified() {
return (
this.isNew()
|| this.deleted
|| this.serverDeleted
);
}
/**
Discard any client side modifications to this entity. Not allowed if
{@link serverDeleted} is true or this is a new entity.
*/
abstract discard(): void
/**
Apply an update delivered from the API to this entity.
*/
abstract apiUpdate(api: EntityLiving, opts: { zone: Zone, locale: string }): void
/**
Serialise this entity to the API format. Not allowed if {@link deleted} is true.
*/
abstract toApi(): Entity
}
export class ClientUser extends ClientEntityNew {
static type = "user";
serverName: string | undefined; serverName: string | undefined;
serverType: ApiUserType serverType: ApiUserType
@ -112,6 +32,7 @@ export class ClientUser extends ClientEntityNew {
throw new Error("ClientUser.discard: Cannot discard new entity.") throw new Error("ClientUser.discard: Cannot discard new entity.")
} }
this.updatedAt = this.serverUpdatedAt; this.updatedAt = this.serverUpdatedAt;
this.deleted = this.serverDeleted;
this.name = this.serverName; this.name = this.serverName;
this.type = this.serverType; this.type = this.serverType;
} }
@ -124,7 +45,7 @@ export class ClientUser extends ClientEntityNew {
) { ) {
return new this( return new this(
id, id,
DateTime.fromMillis(ClientEntityNew.newEntityMillis, opts), DateTime.fromMillis(ClientEntity.newEntityMillis, opts),
false, false,
name, name,
type, type,
@ -132,7 +53,6 @@ export class ClientUser extends ClientEntityNew {
} }
static fromApi(api: Living<ApiUser>, opts: { zone: Zone, locale: string }) { static fromApi(api: Living<ApiUser>, opts: { zone: Zone, locale: string }) {
try {
return new this( return new this(
api.id, api.id,
DateTime.fromISO(api.updatedAt, opts), DateTime.fromISO(api.updatedAt, opts),
@ -140,10 +60,6 @@ export class ClientUser extends ClientEntityNew {
api.name, api.name,
api.type, api.type,
); );
} catch (err) {
console.error(api);
throw err;
}
} }
override apiUpdate(api: Living<ApiUser>, opts: { zone: Zone, locale: string }) { override apiUpdate(api: Living<ApiUser>, opts: { zone: Zone, locale: string }) {