owltide/utils/client-schedule.ts
Hornwitser 73b28834a1 Allow orphaned event and shit slots
When editing the slots of events and shifts there are certain situations
where the event or shift a slot should belong to becomes unclear or
difficult to reliably assign.  For example when adding a new slot in the
UI it may be desirable to do so before the user has input the event
or shift the slot should belong to.

In these cases, not being able to store the slot into the schedule makes
the UI logic needlessly complicated.  Allow slots to be added that do
not have its assiated relation linked up to make editing and handling
such scenarios easier.
2025-06-27 18:34:37 +02:00

997 lines
25 KiB
TypeScript

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.description;
}
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 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.description;
}
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;
serverName: string;
serverDescription: string;
serverSlotIds: Set<Id>;
constructor(
id: Id,
updatedAt: DateTime,
deleted: boolean,
public roleId: Id,
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 this.slotIds.difference(this.serverSlotIds)) {
const slot = this.schedule.shiftSlots.get(id)!;
slot.setShiftId(slot.serverShiftId);
}
}
static create(
schedule: ClientSchedule,
id: Id,
roleId: Id,
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.roleId = 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;
}
}
}
}
}
}
}
}