owltide/utils/client-schedule.ts
Hornwitser e52972853d License under AGPL version 3 or later
I firmly believe in free software.

The application I'm making here have capabilities that I've not seen in
any system.  It presents itself as an opportunity to collaborate on a
tool that serves the people rather than corporations.  Whose incentives
are to help people rather, not make the most money.  And whose terms
ensure that these freedoms and incentives cannot be taken back or
subverted.

I license this software under the AGPL.
2025-06-30 18:58:24 +02:00

1001 lines
25 KiB
TypeScript

/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { DateTime, FixedOffsetZone, Zone } from "~/shared/utils/luxon";
import type {
ApiEntity,
ApiSchedule,
ApiScheduleEvent,
ApiScheduleEventSlot,
ApiScheduleLocation,
ApiScheduleRole,
ApiScheduleShift,
ApiScheduleShiftSlot,
ApiTombstone
} from "~/shared/types/api";
import type { Id } from "~/shared/types/common";
import { setEquals } from "~/shared/utils/functions";
import { ClientEntity } from "~/utils/client-entity";
function filterEntity<T extends ApiEntity>(entities?: (T | ApiTombstone)[]) {
return (entities ?? []).filter((entity) => !entity.deleted) as T[];
}
function filterTombstone<T extends ApiEntity>(entities?: (T | ApiTombstone)[]) {
return (entities ?? []).filter((entity) => entity.deleted) as ApiTombstone[];
}
export function toIso(timestamp: DateTime) {
return timestamp.setZone(FixedOffsetZone.utcInstance).toISO();
}
function mapWith<K, V>(map: Map<K, V>, key: K, value: V) {
const copy = new Map(map);
copy.set(key, value);
return copy;
}
function mapWithout<K, V>(map: Map<K, V>, key: K) {
const copy = new Map(map);
copy.delete(key);
return copy;
}
export class ClientScheduleLocation extends ClientEntity<ApiScheduleLocation> {
serverName: string;
serverDescription: string;
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public name: string,
public description: string,
) {
super(id, updatedAt, deleted);
this.serverName = name;
this.serverDescription = description;
}
override isModified() {
return (
super.isModified()
|| this.name !== this.serverName
|| this.description !== this.serverDescription
);
}
override discard() {
if (this.isNew()) {
throw new Error("ClientScheduleLocation.discard: Cannot discard new entity.")
}
this.updatedAt = this.serverUpdatedAt;
this.deleted = this.serverDeleted;
this.name = this.serverName;
this.description = this.serverDescription;
}
static create(
id: Id,
name: string,
description: string,
opts: { zone: Zone, locale: string },
) {
return new ClientScheduleLocation(
id,
DateTime.fromMillis(ClientEntity.newEntityMillis, opts),
false,
name,
description,
);
}
static fromApi(api: ApiScheduleLocation, opts: { zone: Zone, locale: string }) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.name,
api.description ?? "",
);
}
override apiUpdate(api: ApiScheduleLocation, opts: { zone: Zone, locale: string }) {
const wasModified = this.isModified();
this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts);
this.serverDeleted = false;
this.serverName = api.name;
this.serverDescription = api.description || "";
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleLocation | ApiTombstone {
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<ApiScheduleEvent> {
schedule!: ClientSchedule;
serverName: string;
serverCrew: boolean;
serverHost: string;
serverCancelled: boolean;
serverDescription: string;
serverInterested: number;
serverSlotIds: Set<Id>;
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 slotIds: Set<Id>,
) {
super(id, updatedAt, deleted);
this.serverName = name;
this.serverCrew = crew;
this.serverHost = host;
this.serverCancelled = cancelled;
this.serverDescription = description;
this.serverInterested = interested;
this.serverSlotIds = new Set(slotIds);
}
get slots(): ReadonlyMap<Id, ClientScheduleEventSlot> {
return new Map([...this.slotIds].map(id => [id, this.schedule.eventSlots.get(id)!]));
}
override isModified() {
return (
super.isModified()
|| this.name !== this.serverName
|| this.crew !== this.serverCrew
|| this.host !== this.serverHost
|| this.cancelled !== this.serverCancelled
|| this.description !== this.serverDescription
|| this.interested !== this.serverInterested
|| !setEquals(this.slotIds, this.serverSlotIds)
|| [...this.slotIds].some(id => this.schedule.eventSlots.get(id)!.isModified())
);
}
override discard() {
if (this.isNew()) {
throw new Error("ClientScheduleEvent.discard: Cannot discard new entity.")
}
this.updatedAt = this.serverUpdatedAt;;
this.deleted = this.serverDeleted;;
this.name = this.serverName;
this.crew = this.serverCrew;
this.host = this.serverHost;
this.cancelled = this.serverCancelled;
this.description = this.serverDescription;
this.interested = this.serverInterested;
for (const id of this.serverSlotIds) {
this.schedule.eventSlots.get(id)!.discard();
}
for (const id of toRaw(this.slotIds).difference(this.serverSlotIds)) {
const slot = this.schedule.eventSlots.get(id)!;
slot.setEventId(slot.serverEventId);
}
}
static create(
schedule: ClientSchedule,
id: Id,
name: string,
crew: boolean,
host: string,
cancelled: boolean,
description: string,
interested: number,
slotIds: Set<Id>,
opts: { zone: Zone, locale: string },
) {
const event = new ClientScheduleEvent(
id,
DateTime.fromMillis(ClientEntity.newEntityMillis, opts),
false,
name,
crew,
host,
cancelled,
description,
interested,
slotIds,
);
event.schedule = schedule;
return event;
}
static fromApi(
api: ApiScheduleEvent,
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,
new Set(api.slots.map(slot => slot.id)),
);
}
override apiUpdate(
api: ApiScheduleEvent,
opts: { zone: Zone, locale: string },
) {
const wasModified = this.isModified();
this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts);
this.serverDeleted = false;
this.serverName = api.name;
this.serverCrew = api.crew ?? false;
this.serverHost = api.host ?? "";
this.serverCancelled = api.cancelled ?? false;
this.serverDescription = api.description ?? "";
this.serverInterested = api.interested ?? 0;
this.serverSlotIds = new Set(api.slots.map(slot => slot.id));
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleEvent | ApiTombstone {
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.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()),
}
}
}
export class ClientScheduleEventSlot {
schedule!: ClientSchedule;
serverDeleted: boolean;
serverEventId: Id | undefined;
serverStart: DateTime;
serverEnd: DateTime;
serverLocationIds: Set<Id>;
serverAssigned: Set<Id>;
serverInterested: number;
constructor(
public id: Id,
public isNewEntity: boolean,
public deleted: boolean,
public eventId: Id | undefined,
public start: DateTime,
public end: DateTime,
public locationIds: Set<Id>,
public assigned: Set<Id>,
public interested: number,
) {
this.serverDeleted = deleted;
this.serverEventId = eventId;
this.serverStart = start;
this.serverEnd = end;
this.serverLocationIds = new Set(locationIds);
this.serverAssigned = new Set(assigned);
this.serverInterested = interested;
}
isModified() {
return (
this.isNewEntity
|| this.deleted
|| this.serverDeleted
|| this.eventId !== this.serverEventId
|| this.start.toMillis() !== this.serverStart.toMillis()
|| this.end.toMillis() !== this.serverEnd.toMillis()
|| !setEquals(this.locationIds, this.serverLocationIds)
|| !setEquals(this.assigned, this.serverAssigned)
|| this.interested !== this.serverInterested
);
}
setEventId(newEventId: Id | undefined) {
if (this.eventId === newEventId)
return;
this.schedule.events.get(newEventId!)?.slotIds.add(this.id);
this.schedule.events.get(this.eventId!)?.slotIds.delete(this.id);
this.eventId = newEventId;
}
discard() {
if (this.isNewEntity) {
throw new Error("ClientScheduleEventSlot.discard: Cannot discard new slot");
}
this.deleted = this.serverDeleted;
this.setEventId(this.serverEventId);
this.start = this.serverStart;
this.end = this.serverEnd;
this.locationIds = new Set(this.serverLocationIds);
this.assigned = new Set(this.serverAssigned);
this.interested = this.serverInterested;
}
static create(
schedule: ClientSchedule,
id: Id,
eventId: Id,
start: DateTime,
end: DateTime,
locationIds: Set<Id>,
assigned: Set<Id>,
interested: number,
) {
const slot = new ClientScheduleEventSlot(
id,
true,
false,
eventId,
start,
end,
locationIds,
assigned,
interested,
);
slot.schedule = schedule;
return slot;
}
static fromApi(
api: ApiScheduleEventSlot,
eventId: Id,
opts: { zone: Zone, locale: string },
) {
return new this(
api.id,
false,
false,
eventId,
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
new Set(api.locationIds),
new Set(api.assigned),
api.interested ?? 0,
);
}
apiUpdate(
api: ApiScheduleEventSlot,
eventId: Id,
opts: { zone: Zone, locale: string }
) {
const wasModified = this.isModified();
this.isNewEntity = false;
this.serverDeleted = false;
this.serverEventId = eventId;
this.serverStart = DateTime.fromISO(api.start, opts);
this.serverEnd = DateTime.fromISO(api.end, opts);
this.serverLocationIds = new Set(api.locationIds);
this.serverAssigned = new Set(api.assigned);
this.serverInterested = api.interested ?? 0;
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleEventSlot {
if (this.deleted) {
throw new Error("ClientScheduleEventSlot.toApi: Unexpected deleted slot")
}
return {
id: this.id,
start: toIso(this.start),
end: toIso(this.end),
locationIds: [...this.locationIds],
assigned: this.assigned.size ? [...this.assigned] : undefined,
interested: this.interested || undefined,
}
}
}
export class ClientScheduleRole extends ClientEntity<ApiScheduleRole> {
serverName: string;
serverDescription: string;
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public name: string,
public description: string,
) {
super(id, updatedAt, deleted);
this.serverName = name;
this.serverDescription = description;
}
override isModified() {
return (
super.isModified()
|| this.name !== this.serverName
|| this.description !== this.serverDescription
);
}
override discard() {
if (this.isNew()) {
throw new Error("ClientScheduleRole.discard: Cannot discard new entity.")
}
this.updatedAt = this.serverUpdatedAt;
this.deleted = this.serverDeleted;
this.name = this.serverName;
this.description = this.serverDescription;
}
static create(
id: Id,
name: string,
description: string,
opts: { zone: Zone, locale: string },
) {
return new ClientScheduleRole(
id,
DateTime.fromMillis(ClientEntity.newEntityMillis, opts),
false,
name,
description,
);
}
static fromApi(api: ApiScheduleRole, opts: { zone: Zone, locale: string }) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.name,
api.description ?? "",
);
}
override apiUpdate(api: ApiScheduleRole, opts: { zone: Zone, locale: string }) {
const wasModified = this.isModified();
this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts);
this.serverDeleted = false;
this.serverName = api.name;
this.serverDescription = api.description || "";
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleRole | ApiTombstone {
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<ApiScheduleShift> {
schedule!: ClientSchedule;
serverRoleId: Id | undefined;
serverName: string;
serverDescription: string;
serverSlotIds: Set<Id>;
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public roleId: Id | undefined,
public name: string,
public description: string,
public slotIds: Set<Id>,
) {
super(id, updatedAt, deleted);
this.serverRoleId = roleId;
this.serverName = name;
this.serverDescription = description;
this.serverSlotIds = new Set(slotIds);
}
get slots(): ReadonlyMap<Id, ClientScheduleShiftSlot> {
return new Map([...this.slotIds].map(id => [id, this.schedule.shiftSlots.get(id)!]));
}
override isModified() {
return (
super.isModified()
|| this.roleId !== this.serverRoleId
|| this.name !== this.serverName
|| this.description !== this.serverDescription
|| !setEquals(this.slotIds, this.serverSlotIds)
|| [...this.slotIds].some(id => this.schedule.shiftSlots.get(id)!.isModified())
);
}
override discard() {
if (this.isNew()) {
throw new Error("ClientScheduleShift.discard: Cannot discard new entity.")
}
this.updatedAt = this.serverUpdatedAt;;
this.deleted = this.serverDeleted;;
this.roleId = this.serverRoleId;
this.name = this.serverName;
this.description = this.serverDescription;
for (const id of this.serverSlotIds) {
this.schedule.shiftSlots.get(id)!.discard();
}
for (const id of toRaw(this.slotIds).difference(this.serverSlotIds)) {
const slot = this.schedule.shiftSlots.get(id)!;
slot.setShiftId(slot.serverShiftId);
}
}
static create(
schedule: ClientSchedule,
id: Id,
roleId: Id | undefined,
name: string,
description: string,
slotIds: Set<Id>,
opts: { zone: Zone, locale: string },
) {
const shift = new ClientScheduleShift(
id,
DateTime.fromMillis(ClientEntity.newEntityMillis, opts),
false,
roleId,
name,
description,
slotIds,
);
shift.schedule = schedule;
return shift;
}
static fromApi(
api: ApiScheduleShift,
opts: { zone: Zone, locale: string },
) {
return new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
api.roleId,
api.name,
api.description ?? "",
new Set(api.slots.map(slot => slot.id)),
);
}
override apiUpdate(
api: ApiScheduleShift,
opts: { zone: Zone, locale: string },
) {
const wasModified = this.isModified();
this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts);
this.serverDeleted = false;
this.serverRoleId = api.roleId;
this.serverName = api.name;
this.serverDescription = api.description ?? "";
this.serverSlotIds = new Set(api.slots.map(slot => slot.id));
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleShift | ApiTombstone {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
roleId: this.roleId,
name: this.name,
description: this.description || undefined,
slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()),
}
}
}
export class ClientScheduleShiftSlot {
schedule!: ClientSchedule;
serverDeleted: boolean;
serverShiftId: Id | undefined;
serverStart: DateTime;
serverEnd: DateTime;
serverAssigned: Set<Id>;
constructor(
public id: Id,
public isNewEntity: boolean,
public deleted: boolean,
public shiftId: Id | undefined,
public start: DateTime,
public end: DateTime,
public assigned: Set<Id>,
) {
this.serverDeleted = deleted;
this.serverShiftId = shiftId;
this.serverStart = start;
this.serverEnd = end;
this.serverAssigned = new Set(assigned);
}
isModified() {
return (
this.isNewEntity
|| this.deleted
|| this.serverDeleted
|| this.shiftId !== this.serverShiftId
|| this.start.toMillis() !== this.serverStart.toMillis()
|| this.end.toMillis() !== this.serverEnd.toMillis()
|| !setEquals(this.assigned, this.serverAssigned)
);
}
setShiftId(newShiftId: Id | undefined) {
if (this.shiftId === newShiftId)
return;
this.schedule.shifts.get(newShiftId!)?.slotIds.add(this.id);
this.schedule.shifts.get(this.shiftId!)?.slotIds.delete(this.id);
this.shiftId = newShiftId;
}
discard() {
if (this.isNewEntity) {
throw new Error("ClientScheduleShiftSlot.discard: Cannot discard new slot");
}
this.deleted = this.serverDeleted;
this.setShiftId(this.serverShiftId);
this.start = this.serverStart;
this.end = this.serverEnd;
this.assigned = new Set(this.serverAssigned);
}
static create(
schedule: ClientSchedule,
id: Id,
shiftId: Id,
start: DateTime,
end: DateTime,
assigned: Set<Id>,
) {
const slot = new ClientScheduleShiftSlot(
id,
true,
false,
shiftId,
start,
end,
assigned,
);
slot.schedule = schedule;
return slot;
}
static fromApi(
api: ApiScheduleShiftSlot,
shiftId: Id,
opts: { zone: Zone, locale: string },
) {
return new this(
api.id,
false,
false,
shiftId,
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
new Set(api.assigned),
);
}
apiUpdate(
api: ApiScheduleShiftSlot,
shiftId: Id,
opts: { zone: Zone, locale: string },
) {
const wasModified = this.isModified();
this.isNewEntity = false;
this.serverDeleted = false;
this.serverShiftId = shiftId;
this.serverStart = DateTime.fromISO(api.start, opts);
this.serverEnd = DateTime.fromISO(api.end, opts);
this.serverAssigned = new Set(api.assigned);
if (!wasModified || !this.isModified()) {
this.discard();
}
}
toApi(): ApiScheduleShiftSlot {
if (this.deleted) {
throw new Error("ClientScheduleShiftSlot.toApi: Unexpected deleted slot")
}
return {
id: this.id,
start: toIso(this.start),
end: toIso(this.end),
assigned: this.assigned.size ? [...this.assigned] : undefined,
}
}
}
export class ClientSchedule extends ClientEntity<ApiSchedule> {
nextClientId = -1;
constructor(
id: 111,
updatedAt: DateTime,
deleted: boolean,
public locations: ClientMap<ClientScheduleLocation>,
public events: ClientMap<ClientScheduleEvent>,
public eventSlots: Map<Id, ClientScheduleEventSlot>,
public roles: ClientMap<ClientScheduleRole>,
public shifts: ClientMap<ClientScheduleShift>,
public shiftSlots: Map<Id, ClientScheduleShiftSlot>,
) {
super(id, updatedAt, deleted);
// XXX For now the prototype server is assigning client ids instead of remapping them.
this.nextClientId = Math.min(
0,
...locations.keys(),
...events.keys(),
...eventSlots.keys(),
...roles.keys(),
...shifts.keys(),
...shiftSlots.keys(),
) - 1;
}
discard() {
this.locations.discard();
this.events.discard();
this.roles.discard();
this.shifts.discard();
}
discardEventSlot(id: Id) {
const eventSlot = this.eventSlots.get(id);
if (!eventSlot) {
throw new Error("ClientSchedule.discardEventSlot: slot does not exist");
}
if (eventSlot.isNewEntity) {
this.eventSlots.delete(id);
if (eventSlot.eventId !== undefined)
this.events.get(eventSlot.eventId)?.slotIds.delete(id);
if (eventSlot.serverEventId !== undefined && eventSlot.serverEventId !== eventSlot.eventId)
this.events.get(eventSlot.serverEventId)?.schedule.eventSlots.delete(id);
} else {
eventSlot.discard();
}
}
discardShiftSlot(id: Id) {
const shiftSlot = this.shiftSlots.get(id);
if (!shiftSlot) {
throw new Error("ClientSchedule.discardShiftSlot: slot does not exist");
}
if (shiftSlot.isNewEntity) {
this.shiftSlots.delete(id);
if (shiftSlot.shiftId !== undefined)
this.shifts.get(shiftSlot.shiftId)?.slotIds.delete(id);
if (shiftSlot.serverShiftId !== undefined && shiftSlot.serverShiftId !== shiftSlot.shiftId)
this.shifts.get(shiftSlot.serverShiftId)?.schedule.shiftSlots.delete(id);
} else {
shiftSlot.discard();
}
}
override isModified() {
return (
super.isModified()
|| [...this.locations.values()].some(location => location.isModified())
|| [...this.events.values()].some(event => event.isModified())
|| [...this.roles.values()].some(role => role.isModified())
|| [...this.shifts.values()].some(shift => shift.isModified())
);
}
private checkLocationRefsForDeletion(id: Id) {
for (const event of this.events.values()) {
if (event.deleted)
continue;
for (const slot of event.slots.values()) {
if (slot.locationIds.has(id)) {
throw new Error(`Cannot delete location, event "${event.name}" depends on it`);
}
}
}
}
private checkRoleRefsForDeletion(id: Id) {
for (const shift of this.shifts.values()) {
if (shift.deleted) {
continue;
}
if (shift.roleId === id) {
throw new Error(`Cannot delete role, shift "${shift.name}" depends on it`);
}
}
}
static fromApi(api: ApiSchedule, opts: { zone: Zone, locale: string }) {
const eventSlots = idMap((api.events ?? [])
.filter(event => !event.deleted)
.flatMap(event => event.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, event.id, opts)))
)
const shiftSlots = idMap((api.shifts ?? [])
.filter(shift => !shift.deleted)
.flatMap(shift => shift.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, shift.id, opts)))
)
const schedule = new this(
api.id,
DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false,
ClientMap.fromApi(ClientScheduleLocation, api.locations ?? [], opts),
ClientMap.fromApi(ClientScheduleEvent, api.events ?? [], opts),
eventSlots,
ClientMap.fromApi(ClientScheduleRole, api.roles ?? [], opts),
ClientMap.fromApi(ClientScheduleShift, api.shifts ?? [], opts),
shiftSlots,
);
for (const event of schedule.events.values()) {
event.schedule = schedule;
}
for (const eventSlot of schedule.eventSlots.values()) {
eventSlot.schedule = schedule;
}
for (const shift of schedule.shifts.values()) {
shift.schedule = schedule;
}
for (const shiftSlot of schedule.shiftSlots.values()) {
shiftSlot.schedule = schedule;
}
return schedule;
}
toApi(diff = false): ApiSchedule | ApiTombstone {
if (this.deleted) {
return {
id: this.id,
updatedAt: toIso(this.updatedAt),
deleted: true,
}
}
return {
id: this.id as 111,
updatedAt: toIso(this.updatedAt),
locations: this.locations.size ? this.locations.toApi(diff) : undefined,
events: this.events.size ? this.events.toApi(diff) : undefined,
roles: this.roles.size ? this.roles.toApi(diff) : undefined,
shifts: this.shifts.size ? this.shifts.toApi(diff) : undefined,
}
}
override apiUpdate(update: ApiSchedule, opts: { zone: Zone, locale: string }) {
if (update.deleted)
throw new Error("ClientSchedule.apiUpdate: Unexpected deletion");
if (update.id !== this.id)
throw new Error("ClientSchedule.apiUpdate: id mismatch");
this.updatedAt = DateTime.fromISO(update.updatedAt, opts);
if (update.locations)
this.locations.apiUpdate(update.locations, opts);
if (update.events) {
for (const apiEvent of update.events) {
if (!apiEvent.deleted) {
for (const apiSlot of apiEvent.slots) {
let slot = this.eventSlots.get(apiSlot.id);
if (slot) {
slot.apiUpdate(apiSlot, apiEvent.id, opts);
} else {
slot = ClientScheduleEventSlot.fromApi(apiSlot, apiEvent.id, opts);
slot.schedule = this;
this.eventSlots.set(slot.id, slot);
}
}
}
}
this.events.apiUpdate(update.events, opts);
const serverSlotIds = new Set([...this.events.values()].flatMap(event => [...event.serverSlotIds]));
for (const event of this.events.values()) {
for (const slotId of event.slotIds) {
if (!serverSlotIds.has(slotId)) {
const slot = this.eventSlots.get(slotId)!;
if (!slot.isNewEntity) {
if (slot.deleted) {
event.slotIds.delete(slotId);
this.eventSlots.delete(slotId);
} else {
slot.serverDeleted = true;
}
}
}
}
}
}
if (update.roles)
this.roles.apiUpdate(update.roles, opts);
if (update.shifts) {
for (const apiShift of update.shifts) {
if (!apiShift.deleted) {
for (const apiSlot of apiShift.slots) {
let slot = this.shiftSlots.get(apiSlot.id);
if (slot) {
slot.apiUpdate(apiSlot, apiShift.id, opts);
} else {
slot = ClientScheduleShiftSlot.fromApi(apiSlot, apiShift.id, opts);
slot.schedule = this;
this.shiftSlots.set(slot.id, slot);
}
}
}
}
this.shifts.apiUpdate(update.shifts, opts);
const serverSlotIds = new Set([...this.shifts.values()].flatMap(shift => [...shift.serverSlotIds]));
for (const shift of this.shifts.values()) {
for (const slotId of shift.slotIds) {
if (!serverSlotIds.has(slotId)) {
const slot = this.shiftSlots.get(slotId)!;
if (!slot.isNewEntity) {
if (slot.deleted) {
shift.slotIds.delete(slotId);
this.shiftSlots.delete(slotId);
} else {
slot.serverDeleted = true;
}
}
}
}
}
}
}
}