diff --git a/shared/types/common.ts b/shared/types/common.ts index 061e713..07af6e8 100644 --- a/shared/types/common.ts +++ b/shared/types/common.ts @@ -8,7 +8,7 @@ export const entityLivingSchema = z.object({ updatedAt: z.string(), deleted: z.optional(z.literal(false)), }); -export type EnityLiving = z.infer; +export type EntityLiving = z.infer; export const entityToombstoneSchema = z.object({ id: idSchema, @@ -20,6 +20,9 @@ export type EntityToombstone = z.infer; export const entitySchema = z.discriminatedUnion("deleted", [entityLivingSchema, entityToombstoneSchema]); export type Entity = z.infer; +export type Living = Extract; +export type Tombstone = Extract; + export function defineEntity(fields: T) { return z.discriminatedUnion("deleted", [z.extend(entityLivingSchema, fields), entityToombstoneSchema]); } diff --git a/shared/utils/functions.ts b/shared/utils/functions.ts index 22cf208..db15d92 100644 --- a/shared/utils/functions.ts +++ b/shared/utils/functions.ts @@ -32,6 +32,29 @@ export function* pairs(iterable: Iterable) { } } +/** + 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( + 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 diff --git a/shared/utils/luxon.ts b/shared/utils/luxon.ts new file mode 100644 index 0000000..9575df4 --- /dev/null +++ b/shared/utils/luxon.ts @@ -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 } diff --git a/utils/client-schedule.nuxt.test.ts b/utils/client-schedule.nuxt.test.ts new file mode 100644 index 0000000..f58c51d --- /dev/null +++ b/utils/client-schedule.nuxt.test.ts @@ -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 { + 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 = { + 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); + }); +}); diff --git a/utils/client-schedule.ts b/utils/client-schedule.ts new file mode 100644 index 0000000..347aa9f --- /dev/null +++ b/utils/client-schedule.ts @@ -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(entities?: T[]) { + return (entities ?? []).filter((entity) => !entity.deleted) as Living[]; +} + +function filterTombstone(entities?: T[]) { + return (entities ?? []).filter((entity) => entity.deleted) as Tombstone[]; +} + +function entityMap(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, 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, + locations: Map, + 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, + 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, + 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, 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, + roles: Map, + 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, + ) { + } + + 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; + originalEvents: Map; + originalRoles: Map; + originalShifts: Map; + + constructor( + id: 111, + updatedAt: DateTime, + deleted: boolean, + public locations: Map, + public events: Map, + public roles: Map, + public shifts: Map, + ) { + 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) { + 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) { + 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, 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( + entityUpdates: T[] | undefined, + fromApi: (api: Living) => U, + originalEntities: Map, + entities: Map, + ) { + 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, + ); + } +}