/* SPDX-FileCopyrightText: © 2025 Hornwitser 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(entities?: (T | ApiTombstone)[]) { return (entities ?? []).filter((entity) => !entity.deleted) as T[]; } function filterTombstone(entities?: (T | ApiTombstone)[]) { return (entities ?? []).filter((entity) => entity.deleted) as ApiTombstone[]; } export function toIso(timestamp: DateTime) { return timestamp.setZone(FixedOffsetZone.utcInstance).toISO(); } function mapWith(map: Map, key: K, value: V) { const copy = new Map(map); copy.set(key, value); return copy; } function mapWithout(map: Map, key: K) { const copy = new Map(map); copy.delete(key); return copy; } export class ClientScheduleLocation extends ClientEntity { 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 { schedule!: ClientSchedule; serverName: string; serverCrew: boolean; serverHost: string; serverCancelled: boolean; serverDescription: string; serverInterested: number; serverSlotIds: Set; 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, ) { 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 { 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, 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; serverAssigned: Set; 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, public assigned: Set, 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, assigned: Set, 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 { 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 { schedule!: ClientSchedule; serverRoleId: Id | undefined; serverName: string; serverDescription: string; serverSlotIds: Set; constructor( id: Id, updatedAt: DateTime, deleted: boolean, public roleId: Id | undefined, public name: string, public description: string, public slotIds: Set, ) { super(id, updatedAt, deleted); this.serverRoleId = roleId; this.serverName = name; this.serverDescription = description; this.serverSlotIds = new Set(slotIds); } get slots(): ReadonlyMap { 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, 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; constructor( public id: Id, public isNewEntity: boolean, public deleted: boolean, public shiftId: Id | undefined, public start: DateTime, public end: DateTime, public assigned: Set, ) { 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, ) { 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 { nextClientId = -1; constructor( id: 111, updatedAt: DateTime, deleted: boolean, public locations: ClientMap, public events: ClientMap, public eventSlots: Map, public roles: ClientMap, public shifts: ClientMap, public shiftSlots: Map, ) { 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; } } } } } } } }