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, mapEquals, 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[]; } 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 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.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: Map, ) { 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, new Map(this.slots), ); } equals(other: ClientScheduleEvent) { return ( this.id === other.id && 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 && mapEquals(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, idMap(api.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, api.id, 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.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()), } } } export class ClientScheduleEventSlot { constructor( public id: Id, public deleted: boolean, public eventId: Id, public start: DateTime, public end: DateTime, public locations: ClientScheduleLocation[], public assigned: Set, public interested: number, ) { } clone() { return new ClientScheduleEventSlot( this.id, this.deleted, this.eventId, this.start, this.end, [...this.locations], new Set(this.assigned), this.interested, ); } equals(other: ClientScheduleEventSlot) { return ( this.id === other.id && this.deleted === other.deleted && this.eventId === other.eventId && 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, eventId: Id, locations: Map, opts: { zone: Zone, locale: string } ) { return new this( api.id, false, eventId, 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 { 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.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.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: Map, ) { super(id, updatedAt, deleted); } clone() { return new ClientScheduleShift( this.id, this.updatedAt, this.deleted, this.role, this.name, this.description, new Map(this.slots), ) } equals(other: ClientScheduleShift) { return ( this.id === other.id && this.deleted === other.deleted && this.role.id === other.role.id && this.name === other.name && this.description === other.description && mapEquals(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 ?? "", idMap(api.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, api.id, 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.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()), } } } export class ClientScheduleShiftSlot { constructor( public id: Id, public deleted: boolean, public shiftId: Id, public start: DateTime, public end: DateTime, public assigned: Set, ) { } clone() { return new ClientScheduleShiftSlot( this.id, this.deleted, this.shiftId, this.start, this.end, new Set(this.assigned), ) } equals(other: ClientScheduleShiftSlot) { return ( this.id === other.id && this.deleted === other.deleted && this.shiftId === other.shiftId && this.start.toMillis() === other.start.toMillis() && this.end.toMillis() === other.end.toMillis() && setEquals(this.assigned, other.assigned) ) } static fromApi(api: ApiScheduleShiftSlot, shiftId: Id, opts: { zone: Zone, locale: string }) { return new this( api.id, false, shiftId, DateTime.fromISO(api.start, opts), DateTime.fromISO(api.end, opts), new Set(api.assigned), ); } 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 { originalLocations: Map; originalEvents: Map; originalEventSlots: Map; originalRoles: Map; originalShifts: Map; originalShiftSlots: Map; shiftSlots: Map; eventSlots: Map; modified: boolean; nextClientId = -1; 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); // Slot tracking this.eventSlots = idMap([...events.values()].flatMap(event => [...event.slots.values()])); this.originalEventSlots = new Map(this.eventSlots); this.shiftSlots = idMap([...shifts.values()].flatMap(shift => [...shift.slots.values()])); this.originalShiftSlots = new Map(this.shiftSlots); this.modified = false; // XXX For now the prototype server is assigning client ids instead of remapping them. this.nextClientId = Math.min( 0, ...locations.keys(), ...events.keys(), ...roles.keys(), ...shifts.keys(), ) - 1; } equals(other: ClientSchedule): boolean { throw new Error("ClientSchedule.equals not implemented") } private recalcModified() { this.modified = ( !mapEquals(this.locations, this.originalLocations) || !mapEquals(this.events, this.originalEvents) || !mapEquals(this.roles, this.originalRoles) || !mapEquals(this.shifts, this.originalShifts) ); } private fixLocationRefs(locations: Map) { for (const events of [this.events, this.originalEvents]) { for (const event of events.values()) { for (const slot of event.slots.values()) { 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; } } } } } } private checkLocationRefsForDeletion(id: Id) { for (const event of this.events.values()) { if (event.deleted) continue; for (const slot of event.slots.values()) { 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`); } } } } } isModifiedLocation(id: Id) { return this.originalLocations.get(id) !== this.locations.get(id); } 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]])); } this.modified = true; } editLocation( location: ClientScheduleLocation, edits: { deleted?: boolean, name?: string, description?: string }, ) { const copy = location.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.name !== undefined) copy.name = edits.name; if (edits.description !== undefined) copy.description = edits.description; this.setLocation(copy); } 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); } this.recalcModified(); } isModifiedEvent(id: Id) { return this.originalEvents.get(id) !== this.events.get(id); } setEvent(event: ClientScheduleEvent) { const previous = this.events.get(event.id); if (previous) { for (const id of previous.slots.keys()) { if (!event.slots.has(id)) { this.eventSlots.delete(id); } } for (const [id, slot] of event.slots) { this.eventSlots.set(id, slot); } } this.events.set(event.id, event); this.modified = true; } editEvent( event: ClientScheduleEvent, edits: { deleted?: boolean, name?: string, crew?: boolean, description?: string, slots?: Map, }, ) { const copy = event.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.name !== undefined) copy.name = edits.name; if (edits.crew !== undefined) copy.crew = edits.crew; if (edits.description !== undefined) copy.description = edits.description; if (edits.slots !== undefined) copy.slots = edits.slots; this.setEvent(copy); } restoreEvent(id: Id) { const event = this.originalEvents.get(id); if (event) { this.events.set(id, event); } else { this.events.delete(id); } this.recalcModified(); } isModifiedEventSlot(id: Id) { return this.originalEventSlots.get(id) !== this.eventSlots.get(id); } setEventSlot(eventSlot: ClientScheduleEventSlot) { const currentEvent = this.events.get(eventSlot.eventId); if (!currentEvent) { throw new Error(`Event with id ${eventSlot.eventId} does not exist`); } const previous = this.eventSlots.get(eventSlot.id); if (previous && previous.eventId !== eventSlot.eventId) { const previousEvent = this.events.get(previous.eventId)!; this.editEvent(previousEvent, { slots: mapWithout(previousEvent.slots, eventSlot.id), }); } this.editEvent(currentEvent, { slots: mapWith(currentEvent.slots, eventSlot.id, eventSlot), }); this.modified = true; } editEventSlot( event: ClientScheduleEventSlot, edits: { deleted?: boolean, eventId?: Id, start?: DateTime, end?: DateTime, locations?: ClientScheduleLocation[], assigned?: Set, }, ) { const copy = event.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.eventId !== undefined) copy.eventId = edits.eventId; if (edits.start !== undefined) copy.start = edits.start; if (edits.end !== undefined) copy.end = edits.end; if (edits.locations !== undefined) copy.locations = edits.locations; if (edits.assigned !== undefined) copy.assigned = edits.assigned; this.setEventSlot(copy); } restoreEventSlot(id: Id) { const originalSlot = this.originalEventSlots.get(id); if (!originalSlot) { this.eventSlots.delete(id); } else { this.setEventSlot(originalSlot); } this.recalcModified(); } private 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; } } } } private checkRoleRefsForDeletion(id: Id) { for (const shift of this.shifts.values()) { if (shift.deleted) { continue; } if (shift.role.id === id) { throw new Error(`Cannot delete role, shift "${shift.name}" depends on it`); } } } isModifiedRole(id: Id) { return this.originalRoles.get(id) !== this.roles.get(id); } 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]])); } this.modified = true; } editRole( role: ClientScheduleRole, edits: { deleted?: boolean, name?: string, description?: string }, ) { const copy = role.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.name !== undefined) copy.name = edits.name; if (edits.description !== undefined) copy.description = edits.description; this.setRole(copy); } 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); } this.recalcModified(); } isModifiedShift(id: Id) { return this.originalShifts.get(id) !== this.shifts.get(id); } setShift(shift: ClientScheduleShift) { const previous = this.shifts.get(shift.id); if (previous) { for (const id of previous.slots.keys()) { if (!shift.slots.has(id)) { this.shiftSlots.delete(id); } } for (const [id, slot] of shift.slots) { this.shiftSlots.set(id, slot); } } this.shifts.set(shift.id, shift); this.modified = true; } editShift( shift: ClientScheduleShift, edits: { deleted?: boolean, role?: ClientScheduleRole, name?: string, description?: string slots?: Map, }, ) { const copy = shift.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.role !== undefined) copy.role = edits.role; if (edits.name !== undefined) copy.name = edits.name; if (edits.description !== undefined) copy.description = edits.description; if (edits.slots !== undefined) copy.slots = edits.slots; this.setShift(copy); } restoreShift(id: Id) { const shift = this.originalShifts.get(id); if (shift) { this.shifts.set(id, shift); } else { this.shifts.delete(id); } this.recalcModified(); } isModifiedShiftSlot(id: Id) { return this.originalShiftSlots.get(id) !== this.shiftSlots.get(id); } setShiftSlot(shiftSlot: ClientScheduleShiftSlot) { const currentShift = this.shifts.get(shiftSlot.shiftId); if (!currentShift) { throw new Error(`Shift with id ${shiftSlot.shiftId} does not exist`); } const previous = this.shiftSlots.get(shiftSlot.id); if (previous && previous.shiftId !== shiftSlot.shiftId) { const previousShift = this.shifts.get(previous.shiftId)!; this.editShift(previousShift, { slots: mapWithout(previousShift.slots, shiftSlot.id), }); } this.editShift(currentShift, { slots: mapWith(currentShift.slots, shiftSlot.id, shiftSlot), }); this.modified = true; } editShiftSlot( shift: ClientScheduleShiftSlot, edits: { deleted?: boolean, shiftId?: Id, start?: DateTime, end?: DateTime, assigned?: Set, }, ) { const copy = shift.clone(); if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.shiftId !== undefined) copy.shiftId = edits.shiftId; if (edits.start !== undefined) copy.start = edits.start; if (edits.end !== undefined) copy.end = edits.end; if (edits.assigned !== undefined) copy.assigned = edits.assigned; this.setShiftSlot(copy); } restoreShiftSlot(id: Id) { const originalSlot = this.originalShiftSlots.get(id); if (!originalSlot) { this.shiftSlots.delete(id); } else { this.setShiftSlot(originalSlot); } this.recalcModified(); } static fromApi(api: Living, opts: { zone: Zone, locale: string }) { const locations = idMap(filterAlive(api.locations).map(location => ClientScheduleLocation.fromApi(location, opts))); const roles = idMap(filterAlive(api.roles).map(role => ClientScheduleRole.fromApi(role, opts))); return new this( api.id, DateTime.fromISO(api.updatedAt, opts), api.deleted ?? false, locations, idMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))), roles, idMap(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 = idMap(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, ); this.recalcModified(); } }