Implement ClientSchedule state tracking class
All checks were successful
/ build (push) Successful in 2m16s
/ deploy (push) Successful in 16s

Write the logic of keeping track of location modifications and applying
updates from the server into the ClientSchedule class.  This should
serve as the foundation for replacing the prototype in-component update
logic which have turned into an unmaintainable spagetti.
This commit is contained in:
Hornwitser 2025-06-12 21:45:34 +02:00
parent aa52a6c651
commit e100555304
5 changed files with 1005 additions and 1 deletions

View file

@ -8,7 +8,7 @@ export const entityLivingSchema = z.object({
updatedAt: z.string(),
deleted: z.optional(z.literal(false)),
});
export type EnityLiving = z.infer<typeof entityLivingSchema>;
export type EntityLiving = z.infer<typeof entityLivingSchema>;
export const entityToombstoneSchema = z.object({
id: idSchema,
@ -20,6 +20,9 @@ export type EntityToombstone = z.infer<typeof entityToombstoneSchema>;
export const entitySchema = z.discriminatedUnion("deleted", [entityLivingSchema, entityToombstoneSchema]);
export type Entity = z.infer<typeof entitySchema>;
export type Living<T extends Entity> = Extract<T, { deleted?: false }>;
export type Tombstone<T extends Entity> = Extract<T, { deleted: true }>;
export function defineEntity<T extends {}>(fields: T) {
return z.discriminatedUnion("deleted", [z.extend(entityLivingSchema, fields), entityToombstoneSchema]);
}

View file

@ -32,6 +32,29 @@ export function* pairs<T>(iterable: Iterable<T>) {
}
}
/**
Returns true if the two arrays passed as input compare equal to each other.
@param a Input array
@param b Input array
@param equals Function to compare individual elements in the array.
@returns True if the arrays compare equal
*/
export function arrayEquals<T>(
a: T[],
b: T[],
equals: (a: T, b: T) => unknown = (a, b) => a === b,
) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!equals(a[i], b[i])) {
return false;
}
}
return true;
}
/**
Returns true if all sets are equal
@param sets set to compare

11
shared/utils/luxon.ts Normal file
View file

@ -0,0 +1,11 @@
// Wrapper around Luxon to make sure the throwOnInvalid option is set
import { Settings, DateTime, FixedOffsetZone, Zone } from "luxon";
Settings.throwOnInvalid = true;
declare module 'luxon' {
interface TSSettings {
throwOnInvalid: true;
}
}
export { DateTime, FixedOffsetZone, Zone }

View file

@ -0,0 +1,284 @@
import { ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, toIso } from "./client-schedule";
import { describe, expect, test } from "vitest";
import type { ApiSchedule } from "~/shared/types/api";
import type { Living } from "~/shared/types/common";
import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
const locale = "en-GB";
const now = DateTime.now().setLocale(locale);
const zone = now.zone;
const nowIso = now.setZone(FixedOffsetZone.utcInstance).toISO();
function fixtureClientSchedule() {
const left = new ClientScheduleLocation(1, now, false, "Left", "");
const right = new ClientScheduleLocation(2, now, false, "Right", "This is the right place");
const events = [
new ClientScheduleEvent(
1, now, false, "Up", false, "", false, "What's Up?", 0,
[new ClientScheduleEventSlot(1, now, now.plus({ hours: 1 }), [left], new Set(), 0)],
),
new ClientScheduleEvent(
2, now, false, "Down", false, "", false, "", 0,
[new ClientScheduleEventSlot(2, now, now.plus({ hours: 2 }), [right], new Set(), 0)],
),
];
const red = new ClientScheduleRole(1, now, false, "Red", "Is a color.");
const blue = new ClientScheduleRole(2, now, false, "Blue", "");
const shifts = [
new ClientScheduleShift(
1, now, false, red, "White", "",
[new ClientScheduleShiftSlot(1, now, now.plus({ hours: 1 }), new Set())],
),
new ClientScheduleShift(
2, now, false, blue, "Black", "Is dark.",
[new ClientScheduleShiftSlot(2, now, now.plus({ hours: 2 }), new Set())],
),
];
return new ClientSchedule(
111,
now,
false,
new Map([
[left.id, left],
[right.id, right],
]),
new Map(events.map(event => [event.id, event])),
new Map([
[red.id, red],
[blue.id, blue],
]),
new Map(shifts.map(shift => [shift.id, shift])),
);
}
function fixtureApiSchedule(): Living<ApiSchedule> {
return {
id: 111,
updatedAt: nowIso,
locations: [
{
id: 1,
updatedAt: nowIso,
name: "Left",
},
{
id: 2,
updatedAt: nowIso,
name: "Right",
description: "This is the right place",
},
],
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: 2,
updatedAt: nowIso,
name: "Down",
slots: [{
id: 2,
start: nowIso,
end: toIso(now.plus({ hours: 2 })),
locationIds: [2],
}],
},
],
roles: [
{
id: 1,
updatedAt: nowIso,
name: "Red",
description: "Is a color.",
},
{
id: 2,
updatedAt: nowIso,
name: "Blue",
},
],
shifts: [
{
id: 1,
updatedAt: nowIso,
name: "White",
roleId: 1,
slots: [{
id: 1,
start: nowIso,
end: toIso(now.plus({ hours: 1 })),
}],
},
{
id: 2,
updatedAt: nowIso,
name: "Black",
description: "Is dark.",
roleId: 2,
slots: [{
id: 2,
start: nowIso,
end: toIso(now.plus({ hours: 2 })),
}],
},
],
};
}
describe("class ClientSchedule", () => {
test("load from api", () => {
const schedule = ClientSchedule.fromApi(fixtureApiSchedule(), { zone, locale })
expect(schedule).toStrictEqual(fixtureClientSchedule());
});
test("save to api", () => {
const schedule = fixtureClientSchedule();
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 fixture: 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 schedule = new ClientSchedule(111, now, false, new Map(), new Map(), new Map(), new Map());
if (fixture[pattern[0]])
schedule.originalLocations.set(1, fixture[pattern[0]]);
if (fixture[pattern[1]])
schedule.locations.set(1, fixture[pattern[1]]);
const update = fixture[pattern[3]];
const expectedOriginalLocation = pattern[5] === "x" ? undefined : fixture[pattern[5]];
const expectedLocation = fixture[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));
});
}
test("create location", () => {
const schedule = fixtureClientSchedule();
const location = new ClientScheduleLocation(3, now, false, "New location", "");
schedule.setLocation(location);
expect(schedule.originalLocations.get(3)).toBe(undefined);
expect(schedule.locations.get(3)).toBe(location);
});
test("update location", () => {
const schedule = fixtureClientSchedule();
const original = schedule.locations.get(1)!;
const copy = original.clone();
copy.name = "Modified Location";
schedule.setLocation(copy);
expect(schedule.originalLocations.get(1)).toBe(original);
expect(schedule.locations.get(1)).toBe(copy);
expect(schedule.events.get(1)!.slots[0].locations[0]).toBe(copy);
});
test("delete location in use throws", () => {
const schedule = fixtureClientSchedule();
const original = schedule.locations.get(1)!;
const copy = original.clone();
copy.deleted = true;
expect(
() => { schedule.setLocation(copy); }
).toThrow(new Error('Cannot delete location, event "Up" depends on it'));
});
test("delete location", () => {
const schedule = fixtureClientSchedule();
const event = schedule.events.get(1)!.clone();
event.slots = [];
schedule.setEvent(event);
const original = schedule.locations.get(1)!;
const copy = original.clone();
copy.deleted = true;
schedule.setLocation(copy);
expect(schedule.originalLocations.get(1)).toBe(original);
expect(schedule.locations.get(1)).toBe(copy);
});
test("create role", () => {
const schedule = fixtureClientSchedule();
const role = new ClientScheduleRole(3, now, false, "New role", "");
schedule.setRole(role);
expect(schedule.originalRoles.get(3)).toBe(undefined);
expect(schedule.roles.get(3)).toBe(role);
});
test("update role", () => {
const schedule = fixtureClientSchedule();
const original = schedule.roles.get(1)!;
const copy = original.clone();
copy.name = "Modified Role";
schedule.setRole(copy);
expect(schedule.originalRoles.get(1)).toBe(original);
expect(schedule.roles.get(1)).toBe(copy);
expect(schedule.shifts.get(1)!.role).toBe(copy);
});
test("delete role in use throws", () => {
const schedule = fixtureClientSchedule();
const original = schedule.roles.get(1)!;
const copy = original.clone();
copy.deleted = true;
expect(
() => { schedule.setRole(copy); }
).toThrow(new Error('Cannot delete role, shift "White" depends on it'));
});
test("delete role", () => {
const schedule = fixtureClientSchedule();
const shift = schedule.shifts.get(1)!.clone();
shift.role = schedule.roles.get(2)!;
schedule.setShift(shift);
const original = schedule.roles.get(1)!;
const copy = original.clone();
copy.deleted = true;
schedule.setRole(copy);
expect(schedule.originalRoles.get(1)).toBe(original);
expect(schedule.roles.get(1)).toBe(copy);
});
});

683
utils/client-schedule.ts Normal file
View file

@ -0,0 +1,683 @@
import { DateTime, FixedOffsetZone, Zone } from "~/shared/utils/luxon";
import type {
ApiSchedule,
ApiScheduleEvent,
ApiScheduleEventSlot,
ApiScheduleLocation,
ApiScheduleRole,
ApiScheduleShift,
ApiScheduleShiftSlot
} from "~/shared/types/api";
import type { Entity, Id, Living, Tombstone } from "~/shared/types/common";
import { arrayEquals, setEquals } from "~/shared/utils/functions";
function filterAlive<T extends Entity>(entities?: T[]) {
return (entities ?? []).filter((entity) => !entity.deleted) as Living<T>[];
}
function filterTombstone<T extends Entity>(entities?: T[]) {
return (entities ?? []).filter((entity) => entity.deleted) as Tombstone<T>[];
}
function entityMap<T extends { id: Id }>(entities: T[]) {
return new Map(entities.map(entity => [entity.id, entity]));
}
export function toIso(timestamp: DateTime) {
return timestamp.setZone(FixedOffsetZone.utcInstance).toISO();
}
export abstract class ClientEntity {
constructor(
public id: Id,
public updatedAt: DateTime,
public deleted: boolean,
) {
}
abstract equals(other: this): boolean;
}
export class ClientScheduleLocation extends ClientEntity {
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public name: string,
public description: string,
) {
super(id, updatedAt, deleted);
}
clone() {
return new ClientScheduleLocation(
this.id,
this.updatedAt,
this.deleted,
this.name,
this.description,
);
}
equals(other: ClientScheduleLocation) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.name === other.name
&& this.description === other.description
)
}
static fromApi(api: Living<ApiScheduleLocation>, opts: { zone: Zone, locale: string }) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.name,
api.description ?? "",
);
}
toApi(): ApiScheduleLocation {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
name: this.name,
description: this.description || undefined,
}
}
}
export class ClientScheduleEvent extends ClientEntity {
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public name: string,
public crew: boolean,
public host: string,
public cancelled: boolean,
public description: string,
public interested: number,
public slots: ClientScheduleEventSlot[],
) {
super(id, updatedAt, deleted);
}
clone() {
return new ClientScheduleEvent(
this.id,
this.updatedAt,
this.deleted,
this.name,
this.crew,
this.host,
this.cancelled,
this.description,
this.interested,
this.slots.map(slot => slot.clone()),
);
}
equals(other: ClientScheduleEvent) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.name === other.name
&& this.crew === other.crew
&& this.host === other.host
&& this.cancelled === other.cancelled
&& this.description === other.description
&& this.interested === other.interested
&& arrayEquals(this.slots, other.slots, (a, b) => a.equals(b))
)
}
static fromApi(
api: Living<ApiScheduleEvent>,
locations: Map<Id, ClientScheduleLocation>,
opts: { zone: Zone, locale: string },
) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.name,
api.crew ?? false,
api.host ?? "",
api.cancelled ?? false,
api.description ?? "",
api.interested ?? 0,
api.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, locations, opts)),
);
}
toApi(): ApiScheduleEvent {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
name: this.name,
crew: this.crew || undefined,
host: this.host || undefined,
cancelled: this.cancelled || undefined,
description: this.description || undefined,
interested: this.interested || undefined,
slots: this.slots.map(slot => slot.toApi()),
}
}
}
export class ClientScheduleEventSlot {
constructor(
public id: Id,
public start: DateTime,
public end: DateTime,
public locations: ClientScheduleLocation[],
public assigned: Set<Id>,
public interested: number,
) {
}
clone() {
return new ClientScheduleEventSlot(
this.id,
this.start,
this.end,
[...this.locations],
new Set(this.assigned),
this.interested,
);
}
equals(other: ClientScheduleEventSlot) {
return (
this.id === other.id
&& this.start.toMillis() === other.start.toMillis()
&& this.end.toMillis() === other.end.toMillis()
&& arrayEquals(this.locations, other.locations)
&& setEquals(this.assigned, other.assigned)
&& this.interested === other.interested
)
}
static fromApi(
api: ApiScheduleEventSlot,
locations: Map<Id, ClientScheduleLocation>,
opts: { zone: Zone, locale: string }
) {
return new this(
api.id,
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
api.locationIds.map(id => locations.get(id)!),
new Set(api.assigned),
api.interested ?? 0,
);
}
toApi(): ApiScheduleEventSlot {
return {
id: this.id,
start: toIso(this.start),
end: toIso(this.end),
locationIds: this.locations.map(location => location.id),
assigned: this.assigned.size ? [...this.assigned] : undefined,
interested: this.interested || undefined,
}
}
}
export class ClientScheduleRole extends ClientEntity {
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public name: string,
public description: string,
) {
super(id, updatedAt, deleted);
}
clone() {
return new ClientScheduleRole(
this.id,
this.updatedAt,
this.deleted,
this.name,
this.description,
);
}
equals(other: ClientScheduleRole) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.name === other.name
&& this.description === other.description
)
}
static fromApi(api: Living<ApiScheduleRole>, opts: { zone: Zone, locale: string }) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.name,
api.description ?? "",
);
}
toApi(): ApiScheduleRole {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
name: this.name,
description: this.description || undefined,
}
}
}
export class ClientScheduleShift extends ClientEntity {
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public role: ClientScheduleRole,
public name: string,
public description: string,
public slots: ClientScheduleShiftSlot[],
) {
super(id, updatedAt, deleted);
}
clone() {
return new ClientScheduleShift(
this.id,
this.updatedAt,
this.deleted,
this.role,
this.name,
this.description,
this.slots.map(slot => slot.clone()),
)
}
equals(other: ClientScheduleShift) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.role.id === other.role.id
&& this.name === other.name
&& this.description === other.description
&& arrayEquals(this.slots, other.slots, (a, b) => a.equals(b))
)
}
static fromApi(
api: Living<ApiScheduleShift>,
roles: Map<Id, ClientScheduleRole>,
opts: { zone: Zone, locale: string },
) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
roles.get(api.roleId)!,
api.name,
api.description ?? "",
api.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, opts)),
);
}
toApi(): ApiScheduleShift {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
roleId: this.role.id,
name: this.name,
description: this.description || undefined,
slots: this.slots.map(slot => slot.toApi()),
}
}
}
export class ClientScheduleShiftSlot {
constructor(
public id: Id,
public start: DateTime,
public end: DateTime,
public assigned: Set<Id>,
) {
}
clone() {
return new ClientScheduleShiftSlot(
this.id,
this.start,
this.end,
new Set(this.assigned),
)
}
equals(other: ClientScheduleShiftSlot) {
return (
this.id === other.id
&& this.start.toMillis() === other.start.toMillis()
&& this.end.toMillis() === other.end.toMillis()
&& setEquals(this.assigned, other.assigned)
)
}
static fromApi(api: ApiScheduleShiftSlot, opts: { zone: Zone, locale: string }) {
return new this(
api.id,
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
new Set(api.assigned),
);
}
toApi(): ApiScheduleShiftSlot {
return {
id: this.id,
start: toIso(this.start),
end: toIso(this.end),
assigned: this.assigned.size ? [...this.assigned] : undefined,
}
}
}
export class ClientSchedule extends ClientEntity {
originalLocations: Map<Id, ClientScheduleLocation>;
originalEvents: Map<Id, ClientScheduleEvent>;
originalRoles: Map<Id, ClientScheduleRole>;
originalShifts: Map<Id, ClientScheduleShift>;
constructor(
id: 111,
updatedAt: DateTime,
deleted: boolean,
public locations: Map<Id, ClientScheduleLocation>,
public events: Map<Id, ClientScheduleEvent>,
public roles: Map<Id, ClientScheduleRole>,
public shifts: Map<Id, ClientScheduleShift>,
) {
super(id, updatedAt, deleted);
this.originalLocations = new Map(locations);
this.originalEvents = new Map(events);
this.originalRoles = new Map(roles);
this.originalShifts = new Map(shifts);
}
equals(other: ClientSchedule): boolean {
throw new Error("ClientSchedule.equals not implemented")
}
#fixLocationRefs(locations: Map<Id, ClientScheduleLocation>) {
for (const events of [this.events, this.originalEvents]) {
for (const event of events.values()) {
for (const slot of event.slots) {
for (let i = 0; i < slot.locations.length; i++) {
const location = locations.get(slot.locations[i].id);
if (location && slot.locations[i] !== location) {
slot.locations[i] = location;
}
}
}
}
}
}
#checkLocationRefsForDeletion(id: Id) {
for (const event of this.events.values()) {
for (const slot of event.slots) {
for (let i = 0; i < slot.locations.length; i++) {
if (slot.locations[i].id === id) {
throw new Error(`Cannot delete location, event "${event.name}" depends on it`);
}
}
}
}
}
setLocation(location: ClientScheduleLocation) {
if (location.deleted) {
this.#checkLocationRefsForDeletion(location.id);
}
this.locations.set(location.id, location);
if (!location.deleted) {
this.#fixLocationRefs(new Map([[location.id, location]]));
}
}
restoreLocation(id: Id) {
const location = this.originalLocations.get(id);
if (location) {
this.locations.set(id, location);
this.#fixLocationRefs(new Map([[location.id, location]]));
} else {
this.#checkLocationRefsForDeletion(id);
this.locations.delete(id);
}
}
setEvent(event: ClientScheduleEvent) {
this.events.set(event.id, event);
}
restoreEvent(id: Id) {
const event = this.originalEvents.get(id);
if (event) {
this.events.set(id, event);
} else {
this.events.delete(id);
}
}
#fixRoleRefs(roles: Map<Id, ClientScheduleRole>) {
for (const shifts of [this.shifts, this.originalShifts]) {
for (const shift of shifts.values()) {
const role = roles.get(shift.role.id);
if (role && shift.role !== role) {
shift.role = role;
}
}
}
}
#checkRoleRefsForDeletion(id: Id) {
for (const shift of this.shifts.values()) {
if (shift.role.id === id) {
throw new Error(`Cannot delete role, shift "${shift.name}" depends on it`);
}
}
}
setRole(role: ClientScheduleRole) {
if (role.deleted) {
this.#checkRoleRefsForDeletion(role.id);
}
this.roles.set(role.id, role);
if (!role.deleted) {
this.#fixRoleRefs(new Map([[role.id, role]]));
}
}
restoreRole(id: Id) {
const role = this.originalRoles.get(id);
if (role) {
this.roles.set(id, role);
this.#fixRoleRefs(new Map([[role.id, role]]));
} else {
this.#checkRoleRefsForDeletion(id);
this.roles.delete(id);
}
}
setShift(shift: ClientScheduleShift) {
this.shifts.set(shift.id, shift);
}
restoreShift(id: Id) {
const shift = this.originalShifts.get(id);
if (shift) {
this.shifts.set(id, shift);
} else {
this.shifts.delete(id);
}
}
static fromApi(api: Living<ApiSchedule>, opts: { zone: Zone, locale: string }) {
const locations = entityMap(filterAlive(api.locations).map(location => ClientScheduleLocation.fromApi(location, opts)));
const roles = entityMap(filterAlive(api.roles).map(role => ClientScheduleRole.fromApi(role, opts)));
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
locations,
entityMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))),
roles,
entityMap(filterAlive(api.shifts).map(shift => ClientScheduleShift.fromApi(shift, roles, opts))),
);
}
toApi(diff: boolean): ApiSchedule {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
if (!diff) {
return {
id: this.id as 111,
updatedAt: toIso(this.updatedAt),
locations: this.locations.size ? [...this.locations.values()].map(location => location.toApi()) : undefined,
events: this.events.size ? [...this.events.values()].map(event => event.toApi()) : undefined,
roles: this.roles.size ? [...this.roles.values()].map(role => role.toApi()) : undefined,
shifts: this.shifts.size ? [...this.shifts.values()].map(shift => shift.toApi()) : undefined,
}
}
const locations: ApiScheduleLocation[] = [];
for (const [id, location] of this.locations)
if (location !== this.originalLocations.get(id)) locations.push(location.toApi());
const events: ApiScheduleEvent[] = [];
for (const [id, event] of this.events)
if (event !== this.originalEvents.get(id)) events.push(event.toApi());
const roles: ApiScheduleRole[] = [];
for (const [id, role] of this.roles)
if (role !== this.originalRoles.get(id)) roles.push(role.toApi());
const shifts: ApiScheduleShift[] = [];
for (const [id, shift] of this.shifts)
if (shift !== this.originalShifts.get(id)) shifts.push(shift.toApi());
return {
id: this.id as 111,
updatedAt: toIso(this.updatedAt),
locations: locations.length ? locations : undefined,
events: events.length ? events : undefined,
roles: roles.length ? roles : undefined,
shifts: shifts.length ? shifts : undefined,
}
}
applyUpdate(update: ApiSchedule, opts: { zone: Zone, locale: string }) {
if (update.deleted)
throw new Error("ClientSchedule.applyUpdate: Unexpected deletion");
if (update.id !== this.id)
throw new Error("ClientSchedule.applyUpdate: id mismatch");
this.updatedAt = DateTime.fromISO(update.updatedAt, opts);
function applyEntityUpdates<T extends Entity, U extends ClientEntity>(
entityUpdates: T[] | undefined,
fromApi: (api: Living<T>) => U,
originalEntities: Map<Id, U>,
entities: Map<Id, U>,
) {
if (!entityUpdates)
return new Map();
const setEntites = entityMap(filterAlive(entityUpdates).map(entity => fromApi(entity)));
for (const [id, updatedLocation] of setEntites) {
const modifiedLocation = entities.get(id);
if (
originalEntities.get(id) === modifiedLocation
|| modifiedLocation?.equals(updatedLocation)
) {
entities.set(id, updatedLocation);
}
originalEntities.set(id, updatedLocation);
}
const deletedLocations = filterTombstone(entityUpdates).map(location => location.id);
for (const id of deletedLocations) {
const modifiedLocation = entities.get(id);
if (
originalEntities.get(id) === modifiedLocation
|| entities.get(id)?.deleted
) {
entities.delete(id);
}
originalEntities.delete(id);
}
return setEntites;
}
const setLocations = applyEntityUpdates(
update.locations,
api => ClientScheduleLocation.fromApi(api, opts),
this.originalLocations,
this.locations,
);
this.#fixLocationRefs(setLocations);
applyEntityUpdates(
update.events,
api => ClientScheduleEvent.fromApi(api, this.locations, opts),
this.originalEvents,
this.events,
);
applyEntityUpdates(
update.roles,
api => ClientScheduleRole.fromApi(api, opts),
this.originalRoles,
this.roles,
);
applyEntityUpdates(
update.shifts,
api => ClientScheduleShift.fromApi(api, this.roles, opts),
this.originalShifts,
this.shifts,
);
}
}