From ce9f758f843de2556d9c4d6bf833259a7223cc1b Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 14 Jun 2025 19:12:31 +0200 Subject: [PATCH] Implement editing of slots in ClientSchedule Implement tracking of time slots along with editing and restoration of singularly edited time slots. This provides a simpler interface to work with when rendering tables of time slots that can be edited than directly manipulating events and shifts containing an array of slots. --- shared/utils/functions.ts | 23 +++ utils/client-schedule.nuxt.test.ts | 213 ++++++++++++++++++++++++-- utils/client-schedule.ts | 235 ++++++++++++++++++++++++----- utils/functions.ts | 5 + 4 files changed, 432 insertions(+), 44 deletions(-) diff --git a/shared/utils/functions.ts b/shared/utils/functions.ts index db15d92..6aa726b 100644 --- a/shared/utils/functions.ts +++ b/shared/utils/functions.ts @@ -78,3 +78,26 @@ export function setEquals(...sets: Set[]) { } return true; } + +/** + Returns true if the two maps passed as input compare equal to each other. + @param a Input map + @param b Input map + @param equals Function to compare individual values in the map. + @returns True if the maps compare equal +*/ +export function mapEquals( + a: Map, + b: Map, + equals: (a: V, b: V) => unknown = (a, b) => a === b, +) { + if (a.size !== b.size) { + return false; + } + for (const [key, value] of a) { + if (!b.has(key) || value !== b.get(key)) { + return false; + } + } + return true; +} diff --git a/utils/client-schedule.nuxt.test.ts b/utils/client-schedule.nuxt.test.ts index 5559b4b..971382c 100644 --- a/utils/client-schedule.nuxt.test.ts +++ b/utils/client-schedule.nuxt.test.ts @@ -1,7 +1,7 @@ -import { ClientEntity, ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, toIso } from "./client-schedule"; +import { ClientEntity, ClientSchedule, ClientScheduleEventSlot, ClientScheduleLocation, ClientScheduleShiftSlot, toIso } from "./client-schedule"; import { describe, expect, test } from "vitest"; import type { ApiSchedule } from "~/shared/types/api"; -import type { Living } from "~/shared/types/common"; +import type { Id, Living } from "~/shared/types/common"; import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon"; const locale = "en-GB"; @@ -17,11 +17,11 @@ function fixtureClientSchedule() { const events = [ new ClientScheduleEvent( 1, now, false, "Up", false, "", false, "What's Up?", 0, - [new ClientScheduleEventSlot(1, now, now.plus({ hours: 1 }), [left], new Set(), 0)], + idMap([new ClientScheduleEventSlot(1, false, 1, now, now.plus({ hours: 1 }), [left], new Set(), 0)]), ), new ClientScheduleEvent( 2, now, false, "Down", false, "", false, "", 0, - [new ClientScheduleEventSlot(2, now, now.plus({ hours: 2 }), [right], new Set(), 0)], + idMap([new ClientScheduleEventSlot(2, false, 2, now, now.plus({ hours: 2 }), [right], new Set(), 0)]), ), ]; @@ -30,11 +30,11 @@ function fixtureClientSchedule() { const shifts = [ new ClientScheduleShift( 1, now, false, red, "White", "", - [new ClientScheduleShiftSlot(1, now, now.plus({ hours: 1 }), new Set())], + idMap([new ClientScheduleShiftSlot(1, false, 1, now, now.plus({ hours: 1 }), new Set())]), ), new ClientScheduleShift( 2, now, false, blue, "Black", "Is dark.", - [new ClientScheduleShiftSlot(2, now, now.plus({ hours: 2 }), new Set())], + idMap([new ClientScheduleShiftSlot(2, false, 2, now, now.plus({ hours: 2 }), new Set())]), ), ]; @@ -211,7 +211,7 @@ describe("class ClientSchedule", () => { ], [ "event", - () => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, []) + () => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, new Map()) ], [ "role", @@ -219,7 +219,7 @@ describe("class ClientSchedule", () => { ], [ "shift", - (schedule) => new ClientScheduleShift(3, now, false, schedule.roles.get(1)!, "New location", "", []) + (schedule) => new ClientScheduleShift(3, now, false, schedule.roles.get(1)!, "New location", "", new Map()) ], ] as const; for (const [name, create] of entityTests) { @@ -249,7 +249,7 @@ describe("class ClientSchedule", () => { expect((schedule as any)[`isModified${Name}`](1)).toBe(true); expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); if (name === "location") { - expect(schedule.events.get(1)!.slots[0].locations[0]).toBe(schedule.locations.get(1)); + expect(schedule.events.get(1)!.slots.get(1)!.locations[0]).toBe(schedule.locations.get(1)); } else if (name === "role") { expect(schedule.shifts.get(1)!.role).toBe(schedule.roles.get(1)); } @@ -289,4 +289,199 @@ describe("class ClientSchedule", () => { }); }); } + + function validateSlotRelations( + slots: Map | Map, + entites: Map | Map, + prefix: string, + ) { + const remainingIds = new Set(slots.keys()) + for (const shift of entites.values()) { + for (const slot of shift.slots.values()) { + if (!slots.has(slot.id)) { + throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that is not in shiftSlots`); + } + if (slots.get(slot.id) !== slot) { + throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} which does not match the corresponding slot in shiftSlots.`); + } + if (!remainingIds.has(slot.id)) { + throw Error(`${prefix}: shift ${shift.name}:${shift.id} has slot ${slot.id} that has been seen twice.`); + } + remainingIds.delete(slot.id); + } + } + if (remainingIds.size) { + throw Error(`${prefix}: shiftSlots ${[...remainingIds].join(", ")} does not have a corresponding shift`); + } + } + + describe("event slot", () => { + test("edit", () => { + const schedule = fixtureClientSchedule(); + expect(schedule.modified).toBe(false); + expect(schedule.isModifiedEvent(1)).toBe(false); + expect(schedule.isModifiedEvent(2)).toBe(false); + expect(schedule.isModifiedEventSlot(1)).toBe(false); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + // Modify + schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedEvent(1)).toBe(true); + expect(schedule.isModifiedEvent(2)).toBe(false); + expect(schedule.isModifiedEventSlot(1)).toBe(true); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + expect(schedule.events.get(1)!.slots.size).toBe(1); + expect(schedule.events.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.eventSlots, schedule.events, "current"); + validateSlotRelations(schedule.eventSlots, schedule.events, "original"); + }); + test("move to another event", () => { + const schedule = fixtureClientSchedule(); + expect(schedule.modified).toBe(false); + expect(schedule.isModifiedEvent(1)).toBe(false); + expect(schedule.isModifiedEvent(2)).toBe(false); + expect(schedule.isModifiedEventSlot(1)).toBe(false); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + // Modify + schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedEvent(1)).toBe(true); + expect(schedule.isModifiedEvent(2)).toBe(true); + expect(schedule.isModifiedEventSlot(1)).toBe(true); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + expect(schedule.events.get(1)!.slots.size).toBe(0); + expect(schedule.events.get(2)!.slots.size).toBe(2); + validateSlotRelations(schedule.eventSlots, schedule.events, "current"); + validateSlotRelations(schedule.eventSlots, schedule.events, "original"); + // Move back + schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 1 }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedEvent(1)).toBe(true); + expect(schedule.isModifiedEvent(2)).toBe(true); + expect(schedule.isModifiedEventSlot(1)).toBe(true); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + expect(schedule.events.get(1)!.slots.size).toBe(1); + expect(schedule.events.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.eventSlots, schedule.events, "current"); + validateSlotRelations(schedule.eventSlots, schedule.events, "original"); + }); + test("restore", () => { + const schedule = fixtureClientSchedule(); + schedule.editEventSlot(schedule.eventSlots.get(1)!, { start: later }); + schedule.restoreEventSlot(1); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedEvent(1)).toBe(true); + expect(schedule.isModifiedEvent(2)).toBe(false); + expect(schedule.isModifiedEventSlot(1)).toBe(false); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + expect(schedule.events.get(1)!.slots.size).toBe(1); + expect(schedule.events.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.eventSlots, schedule.events, "current"); + validateSlotRelations(schedule.eventSlots, schedule.events, "original"); + }); + test("restore from another event", () => { + const schedule = fixtureClientSchedule(); + schedule.editEventSlot(schedule.eventSlots.get(1)!, { eventId: 2 }); + schedule.restoreEventSlot(1); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedEvent(1)).toBe(true); + expect(schedule.isModifiedEvent(2)).toBe(true); + expect(schedule.isModifiedEventSlot(1)).toBe(false); + expect(schedule.isModifiedEventSlot(2)).toBe(false); + expect(schedule.events.get(1)!.slots.size).toBe(1); + expect(schedule.events.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.eventSlots, schedule.events, "current"); + validateSlotRelations(schedule.eventSlots, schedule.events, "original"); + }); + }); + + describe("shift slot", () => { + test("edit", () => { + const schedule = fixtureClientSchedule(); + expect(schedule.modified).toBe(false); + expect(schedule.isModifiedShift(1)).toBe(false); + expect(schedule.isModifiedShift(2)).toBe(false); + expect(schedule.isModifiedShiftSlot(1)).toBe(false); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + // Modify + schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedShift(1)).toBe(true); + expect(schedule.isModifiedShift(2)).toBe(false); + expect(schedule.isModifiedShiftSlot(1)).toBe(true); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + expect(schedule.shifts.get(1)!.slots.size).toBe(1); + expect(schedule.shifts.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); + }); + test("move to another shift", () => { + const schedule = fixtureClientSchedule(); + expect(schedule.modified).toBe(false); + expect(schedule.isModifiedShift(1)).toBe(false); + expect(schedule.isModifiedShift(2)).toBe(false); + expect(schedule.isModifiedShiftSlot(1)).toBe(false); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + // Modify + schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedShift(1)).toBe(true); + expect(schedule.isModifiedShift(2)).toBe(true); + expect(schedule.isModifiedShiftSlot(1)).toBe(true); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + expect(schedule.shifts.get(1)!.slots.size).toBe(0); + expect(schedule.shifts.get(2)!.slots.size).toBe(2); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); + // Move back + schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 1 }); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedShift(1)).toBe(true); + expect(schedule.isModifiedShift(2)).toBe(true); + expect(schedule.isModifiedShiftSlot(1)).toBe(true); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + expect(schedule.shifts.get(1)!.slots.size).toBe(1); + expect(schedule.shifts.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); + }); + test("restore", () => { + const schedule = fixtureClientSchedule(); + schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { start: later }); + schedule.restoreShiftSlot(1); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedShift(1)).toBe(true); + expect(schedule.isModifiedShift(2)).toBe(false); + expect(schedule.isModifiedShiftSlot(1)).toBe(false); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + expect(schedule.shifts.get(1)!.slots.size).toBe(1); + expect(schedule.shifts.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); + }); + test("restore from another shift", () => { + const schedule = fixtureClientSchedule(); + schedule.editShiftSlot(schedule.shiftSlots.get(1)!, { shiftId: 2 }); + schedule.restoreShiftSlot(1); + // Check + expect(schedule.modified).toBe(true); + expect(schedule.isModifiedShift(1)).toBe(true); + expect(schedule.isModifiedShift(2)).toBe(true); + expect(schedule.isModifiedShiftSlot(1)).toBe(false); + expect(schedule.isModifiedShiftSlot(2)).toBe(false); + expect(schedule.shifts.get(1)!.slots.size).toBe(1); + expect(schedule.shifts.get(2)!.slots.size).toBe(1); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "current"); + validateSlotRelations(schedule.shiftSlots, schedule.shifts, "original"); + }); + }); }); diff --git a/utils/client-schedule.ts b/utils/client-schedule.ts index a75dfe7..5dac816 100644 --- a/utils/client-schedule.ts +++ b/utils/client-schedule.ts @@ -9,7 +9,7 @@ import type { ApiScheduleShiftSlot } from "~/shared/types/api"; import type { Entity, Id, Living, Tombstone } from "~/shared/types/common"; -import { arrayEquals, setEquals } from "~/shared/utils/functions"; +import { arrayEquals, mapEquals, setEquals } from "~/shared/utils/functions"; function filterAlive(entities?: T[]) { return (entities ?? []).filter((entity) => !entity.deleted) as Living[]; @@ -19,14 +19,23 @@ function filterTombstone(entities?: T[]) { return (entities ?? []).filter((entity) => entity.deleted) as Tombstone[]; } -function entityMap(entities: T[]) { - return new Map(entities.map(entity => [entity.id, entity])); -} - export function toIso(timestamp: DateTime) { return timestamp.setZone(FixedOffsetZone.utcInstance).toISO(); } +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, @@ -106,7 +115,7 @@ export class ClientScheduleEvent extends ClientEntity { public cancelled: boolean, public description: string, public interested: number, - public slots: ClientScheduleEventSlot[], + public slots: Map, ) { super(id, updatedAt, deleted); } @@ -122,7 +131,7 @@ export class ClientScheduleEvent extends ClientEntity { this.cancelled, this.description, this.interested, - this.slots.map(slot => slot.clone()), + new Map(this.slots), ); } @@ -136,7 +145,7 @@ export class ClientScheduleEvent extends ClientEntity { && this.cancelled === other.cancelled && this.description === other.description && this.interested === other.interested - && arrayEquals(this.slots, other.slots, (a, b) => a.equals(b)) + && mapEquals(this.slots, other.slots, (a, b) => a.equals(b)) ) } @@ -155,7 +164,7 @@ export class ClientScheduleEvent extends ClientEntity { api.cancelled ?? false, api.description ?? "", api.interested ?? 0, - api.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, locations, opts)), + idMap(api.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, api.id, locations, opts))), ); } @@ -176,7 +185,7 @@ export class ClientScheduleEvent extends ClientEntity { cancelled: this.cancelled || undefined, description: this.description || undefined, interested: this.interested || undefined, - slots: this.slots.map(slot => slot.toApi()), + slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()), } } } @@ -184,6 +193,8 @@ export class ClientScheduleEvent extends ClientEntity { export class ClientScheduleEventSlot { constructor( public id: Id, + public deleted: boolean, + public eventId: Id, public start: DateTime, public end: DateTime, public locations: ClientScheduleLocation[], @@ -195,6 +206,8 @@ export class ClientScheduleEventSlot { clone() { return new ClientScheduleEventSlot( this.id, + this.deleted, + this.eventId, this.start, this.end, [...this.locations], @@ -206,6 +219,8 @@ export class ClientScheduleEventSlot { 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) @@ -216,11 +231,14 @@ export class ClientScheduleEventSlot { 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)!), @@ -230,6 +248,9 @@ export class ClientScheduleEventSlot { } toApi(): ApiScheduleEventSlot { + if (this.deleted) { + throw new Error("ClientScheduleEventSlot.toApi: Unexpected deleted slot") + } return { id: this.id, start: toIso(this.start), @@ -306,7 +327,7 @@ export class ClientScheduleShift extends ClientEntity { public role: ClientScheduleRole, public name: string, public description: string, - public slots: ClientScheduleShiftSlot[], + public slots: Map, ) { super(id, updatedAt, deleted); } @@ -319,7 +340,7 @@ export class ClientScheduleShift extends ClientEntity { this.role, this.name, this.description, - this.slots.map(slot => slot.clone()), + new Map(this.slots), ) } @@ -330,7 +351,7 @@ export class ClientScheduleShift extends ClientEntity { && this.role.id === other.role.id && this.name === other.name && this.description === other.description - && arrayEquals(this.slots, other.slots, (a, b) => a.equals(b)) + && mapEquals(this.slots, other.slots, (a, b) => a.equals(b)) ) } @@ -346,7 +367,7 @@ export class ClientScheduleShift extends ClientEntity { roles.get(api.roleId)!, api.name, api.description ?? "", - api.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, opts)), + idMap(api.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, api.id, opts))), ); } @@ -364,7 +385,7 @@ export class ClientScheduleShift extends ClientEntity { roleId: this.role.id, name: this.name, description: this.description || undefined, - slots: this.slots.map(slot => slot.toApi()), + slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()), } } } @@ -372,6 +393,8 @@ export class ClientScheduleShift extends ClientEntity { export class ClientScheduleShiftSlot { constructor( public id: Id, + public deleted: boolean, + public shiftId: Id, public start: DateTime, public end: DateTime, public assigned: Set, @@ -381,6 +404,8 @@ export class ClientScheduleShiftSlot { clone() { return new ClientScheduleShiftSlot( this.id, + this.deleted, + this.shiftId, this.start, this.end, new Set(this.assigned), @@ -390,15 +415,19 @@ export class ClientScheduleShiftSlot { 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, opts: { zone: Zone, locale: string }) { + 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), @@ -406,6 +435,9 @@ export class ClientScheduleShiftSlot { } toApi(): ApiScheduleShiftSlot { + if (this.deleted) { + throw new Error("ClientScheduleShiftSlot.toApi: Unexpected deleted slot") + } return { id: this.id, start: toIso(this.start), @@ -418,8 +450,12 @@ export class ClientScheduleShiftSlot { export class ClientSchedule extends ClientEntity { originalLocations: Map; originalEvents: Map; + originalEventSlots: Map; originalRoles: Map; originalShifts: Map; + originalShiftSlots: Map; + shiftSlots: Map; + eventSlots: Map; modified: boolean; constructor( @@ -436,6 +472,11 @@ export class ClientSchedule extends ClientEntity { 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; } @@ -444,17 +485,6 @@ export class ClientSchedule extends ClientEntity { } private recalcModified() { - function mapEquals(a: Map, b: Map) { - if (a.size !== b.size) { - return false; - } - for (const [key, value] of a) { - if (!b.has(key) || b.get(key) !== value) { - return false; - } - } - return true; - } this.modified = ( !mapEquals(this.locations, this.originalLocations) || !mapEquals(this.events, this.originalEvents) @@ -466,7 +496,7 @@ export class ClientSchedule extends ClientEntity { private fixLocationRefs(locations: Map) { for (const events of [this.events, this.originalEvents]) { for (const event of events.values()) { - for (const slot of event.slots) { + 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) { @@ -482,7 +512,7 @@ export class ClientSchedule extends ClientEntity { for (const event of this.events.values()) { if (event.deleted) continue; - for (const slot of event.slots) { + 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`); @@ -539,6 +569,17 @@ export class ClientSchedule extends ClientEntity { } 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; } @@ -548,13 +589,17 @@ export class ClientSchedule extends ClientEntity { edits: { deleted?: boolean, name?: string, - description?: 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); } @@ -568,6 +613,60 @@ export class ClientSchedule extends ClientEntity { 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()) { @@ -637,6 +736,17 @@ export class ClientSchedule extends ClientEntity { } 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; } @@ -645,14 +755,18 @@ export class ClientSchedule extends ClientEntity { 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); } @@ -666,17 +780,68 @@ export class ClientSchedule extends ClientEntity { 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 = entityMap(filterAlive(api.locations).map(location => ClientScheduleLocation.fromApi(location, opts))); - const roles = entityMap(filterAlive(api.roles).map(role => ClientScheduleRole.fromApi(role, opts))); + 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, - entityMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))), + idMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))), roles, - entityMap(filterAlive(api.shifts).map(shift => ClientScheduleShift.fromApi(shift, roles, opts))), + idMap(filterAlive(api.shifts).map(shift => ClientScheduleShift.fromApi(shift, roles, opts))), ); } @@ -736,7 +901,7 @@ export class ClientSchedule extends ClientEntity { ) { if (!entityUpdates) return new Map(); - const setEntites = entityMap(filterAlive(entityUpdates).map(entity => fromApi(entity))); + const setEntites = idMap(filterAlive(entityUpdates).map(entity => fromApi(entity))); for (const [id, updatedLocation] of setEntites) { const modifiedLocation = entities.get(id); if ( diff --git a/utils/functions.ts b/utils/functions.ts index 2fdafd8..97e488d 100644 --- a/utils/functions.ts +++ b/utils/functions.ts @@ -1,4 +1,5 @@ import type { LocationQueryValue } from 'vue-router'; +import type { Id } from '~/shared/types/common'; export function queryToString(item?: null | LocationQueryValue | LocationQueryValue[]) { if (item === null) @@ -15,3 +16,7 @@ export function queryToNumber(item?: null | LocationQueryValue | LocationQueryVa return queryToNumber(item[0]) return Number.parseInt(item, 10); } + +export function idMap(entities: T[]) { + return new Map(entities.map(entity => [entity.id, entity])); +}