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.
This commit is contained in:
Hornwitser 2025-06-14 19:12:31 +02:00
parent 73bb12c104
commit ce9f758f84
4 changed files with 432 additions and 44 deletions

View file

@ -78,3 +78,26 @@ export function setEquals<T>(...sets: Set<T>[]) {
} }
return true; 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<K, V>(
a: Map<K, V>,
b: Map<K, V>,
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;
}

View file

@ -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 { describe, expect, test } from "vitest";
import type { ApiSchedule } from "~/shared/types/api"; 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"; import { DateTime, FixedOffsetZone } from "~/shared/utils/luxon";
const locale = "en-GB"; const locale = "en-GB";
@ -17,11 +17,11 @@ function fixtureClientSchedule() {
const events = [ const events = [
new ClientScheduleEvent( new ClientScheduleEvent(
1, now, false, "Up", false, "", false, "What's Up?", 0, 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( new ClientScheduleEvent(
2, now, false, "Down", false, "", false, "", 0, 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 = [ const shifts = [
new ClientScheduleShift( new ClientScheduleShift(
1, now, false, red, "White", "", 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( new ClientScheduleShift(
2, now, false, blue, "Black", "Is dark.", 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", "event",
() => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, []) () => new ClientScheduleEvent(3, now, false, "New location", false, "", false, "", 0, new Map())
], ],
[ [
"role", "role",
@ -219,7 +219,7 @@ describe("class ClientSchedule", () => {
], ],
[ [
"shift", "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; ] as const;
for (const [name, create] of entityTests) { 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)[`isModified${Name}`](1)).toBe(true);
expect((schedule as any)[`original${Name}s`].get(1)).toBe(original); expect((schedule as any)[`original${Name}s`].get(1)).toBe(original);
if (name === "location") { 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") { } else if (name === "role") {
expect(schedule.shifts.get(1)!.role).toBe(schedule.roles.get(1)); expect(schedule.shifts.get(1)!.role).toBe(schedule.roles.get(1));
} }
@ -289,4 +289,199 @@ describe("class ClientSchedule", () => {
}); });
}); });
} }
function validateSlotRelations(
slots: Map<Id, ClientScheduleShiftSlot> | Map<Id, ClientScheduleEventSlot>,
entites: Map<Id, ClientScheduleShift> | Map<Id, ClientScheduleEvent>,
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");
});
});
}); });

View file

@ -9,7 +9,7 @@ import type {
ApiScheduleShiftSlot ApiScheduleShiftSlot
} from "~/shared/types/api"; } from "~/shared/types/api";
import type { Entity, Id, Living, Tombstone } from "~/shared/types/common"; 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<T extends Entity>(entities?: T[]) { function filterAlive<T extends Entity>(entities?: T[]) {
return (entities ?? []).filter((entity) => !entity.deleted) as Living<T>[]; return (entities ?? []).filter((entity) => !entity.deleted) as Living<T>[];
@ -19,14 +19,23 @@ function filterTombstone<T extends Entity>(entities?: T[]) {
return (entities ?? []).filter((entity) => entity.deleted) as Tombstone<T>[]; return (entities ?? []).filter((entity) => entity.deleted) as Tombstone<T>[];
} }
function entityMap<T extends { id: Id }>(entities: T[]) {
return new Map(entities.map(entity => [entity.id, entity]));
}
export function toIso(timestamp: DateTime) { export function toIso(timestamp: DateTime) {
return timestamp.setZone(FixedOffsetZone.utcInstance).toISO(); 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 abstract class ClientEntity { export abstract class ClientEntity {
constructor( constructor(
public id: Id, public id: Id,
@ -106,7 +115,7 @@ export class ClientScheduleEvent extends ClientEntity {
public cancelled: boolean, public cancelled: boolean,
public description: string, public description: string,
public interested: number, public interested: number,
public slots: ClientScheduleEventSlot[], public slots: Map<Id, ClientScheduleEventSlot>,
) { ) {
super(id, updatedAt, deleted); super(id, updatedAt, deleted);
} }
@ -122,7 +131,7 @@ export class ClientScheduleEvent extends ClientEntity {
this.cancelled, this.cancelled,
this.description, this.description,
this.interested, 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.cancelled === other.cancelled
&& this.description === other.description && this.description === other.description
&& this.interested === other.interested && 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.cancelled ?? false,
api.description ?? "", api.description ?? "",
api.interested ?? 0, 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, cancelled: this.cancelled || undefined,
description: this.description || undefined, description: this.description || undefined,
interested: this.interested || 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 { export class ClientScheduleEventSlot {
constructor( constructor(
public id: Id, public id: Id,
public deleted: boolean,
public eventId: Id,
public start: DateTime, public start: DateTime,
public end: DateTime, public end: DateTime,
public locations: ClientScheduleLocation[], public locations: ClientScheduleLocation[],
@ -195,6 +206,8 @@ export class ClientScheduleEventSlot {
clone() { clone() {
return new ClientScheduleEventSlot( return new ClientScheduleEventSlot(
this.id, this.id,
this.deleted,
this.eventId,
this.start, this.start,
this.end, this.end,
[...this.locations], [...this.locations],
@ -206,6 +219,8 @@ export class ClientScheduleEventSlot {
equals(other: ClientScheduleEventSlot) { equals(other: ClientScheduleEventSlot) {
return ( return (
this.id === other.id this.id === other.id
&& this.deleted === other.deleted
&& this.eventId === other.eventId
&& this.start.toMillis() === other.start.toMillis() && this.start.toMillis() === other.start.toMillis()
&& this.end.toMillis() === other.end.toMillis() && this.end.toMillis() === other.end.toMillis()
&& arrayEquals(this.locations, other.locations) && arrayEquals(this.locations, other.locations)
@ -216,11 +231,14 @@ export class ClientScheduleEventSlot {
static fromApi( static fromApi(
api: ApiScheduleEventSlot, api: ApiScheduleEventSlot,
eventId: Id,
locations: Map<Id, ClientScheduleLocation>, locations: Map<Id, ClientScheduleLocation>,
opts: { zone: Zone, locale: string } opts: { zone: Zone, locale: string }
) { ) {
return new this( return new this(
api.id, api.id,
false,
eventId,
DateTime.fromISO(api.start, opts), DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts), DateTime.fromISO(api.end, opts),
api.locationIds.map(id => locations.get(id)!), api.locationIds.map(id => locations.get(id)!),
@ -230,6 +248,9 @@ export class ClientScheduleEventSlot {
} }
toApi(): ApiScheduleEventSlot { toApi(): ApiScheduleEventSlot {
if (this.deleted) {
throw new Error("ClientScheduleEventSlot.toApi: Unexpected deleted slot")
}
return { return {
id: this.id, id: this.id,
start: toIso(this.start), start: toIso(this.start),
@ -306,7 +327,7 @@ export class ClientScheduleShift extends ClientEntity {
public role: ClientScheduleRole, public role: ClientScheduleRole,
public name: string, public name: string,
public description: string, public description: string,
public slots: ClientScheduleShiftSlot[], public slots: Map<Id, ClientScheduleShiftSlot>,
) { ) {
super(id, updatedAt, deleted); super(id, updatedAt, deleted);
} }
@ -319,7 +340,7 @@ export class ClientScheduleShift extends ClientEntity {
this.role, this.role,
this.name, this.name,
this.description, 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.role.id === other.role.id
&& this.name === other.name && this.name === other.name
&& this.description === other.description && 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)!, roles.get(api.roleId)!,
api.name, api.name,
api.description ?? "", 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, roleId: this.role.id,
name: this.name, name: this.name,
description: this.description || undefined, 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 { export class ClientScheduleShiftSlot {
constructor( constructor(
public id: Id, public id: Id,
public deleted: boolean,
public shiftId: Id,
public start: DateTime, public start: DateTime,
public end: DateTime, public end: DateTime,
public assigned: Set<Id>, public assigned: Set<Id>,
@ -381,6 +404,8 @@ export class ClientScheduleShiftSlot {
clone() { clone() {
return new ClientScheduleShiftSlot( return new ClientScheduleShiftSlot(
this.id, this.id,
this.deleted,
this.shiftId,
this.start, this.start,
this.end, this.end,
new Set(this.assigned), new Set(this.assigned),
@ -390,15 +415,19 @@ export class ClientScheduleShiftSlot {
equals(other: ClientScheduleShiftSlot) { equals(other: ClientScheduleShiftSlot) {
return ( return (
this.id === other.id this.id === other.id
&& this.deleted === other.deleted
&& this.shiftId === other.shiftId
&& this.start.toMillis() === other.start.toMillis() && this.start.toMillis() === other.start.toMillis()
&& this.end.toMillis() === other.end.toMillis() && this.end.toMillis() === other.end.toMillis()
&& setEquals(this.assigned, other.assigned) && 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( return new this(
api.id, api.id,
false,
shiftId,
DateTime.fromISO(api.start, opts), DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts), DateTime.fromISO(api.end, opts),
new Set(api.assigned), new Set(api.assigned),
@ -406,6 +435,9 @@ export class ClientScheduleShiftSlot {
} }
toApi(): ApiScheduleShiftSlot { toApi(): ApiScheduleShiftSlot {
if (this.deleted) {
throw new Error("ClientScheduleShiftSlot.toApi: Unexpected deleted slot")
}
return { return {
id: this.id, id: this.id,
start: toIso(this.start), start: toIso(this.start),
@ -418,8 +450,12 @@ export class ClientScheduleShiftSlot {
export class ClientSchedule extends ClientEntity { export class ClientSchedule extends ClientEntity {
originalLocations: Map<Id, ClientScheduleLocation>; originalLocations: Map<Id, ClientScheduleLocation>;
originalEvents: Map<Id, ClientScheduleEvent>; originalEvents: Map<Id, ClientScheduleEvent>;
originalEventSlots: Map<Id, ClientScheduleEventSlot>;
originalRoles: Map<Id, ClientScheduleRole>; originalRoles: Map<Id, ClientScheduleRole>;
originalShifts: Map<Id, ClientScheduleShift>; originalShifts: Map<Id, ClientScheduleShift>;
originalShiftSlots: Map<Id, ClientScheduleShiftSlot>;
shiftSlots: Map<Id, ClientScheduleShiftSlot>;
eventSlots: Map<Id, ClientScheduleEventSlot>;
modified: boolean; modified: boolean;
constructor( constructor(
@ -436,6 +472,11 @@ export class ClientSchedule extends ClientEntity {
this.originalEvents = new Map(events); this.originalEvents = new Map(events);
this.originalRoles = new Map(roles); this.originalRoles = new Map(roles);
this.originalShifts = new Map(shifts); 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; this.modified = false;
} }
@ -444,17 +485,6 @@ export class ClientSchedule extends ClientEntity {
} }
private recalcModified() { private recalcModified() {
function mapEquals<K, V>(a: Map<K, V>, b: Map<K, V>) {
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 = ( this.modified = (
!mapEquals(this.locations, this.originalLocations) !mapEquals(this.locations, this.originalLocations)
|| !mapEquals(this.events, this.originalEvents) || !mapEquals(this.events, this.originalEvents)
@ -466,7 +496,7 @@ export class ClientSchedule extends ClientEntity {
private fixLocationRefs(locations: Map<Id, ClientScheduleLocation>) { private fixLocationRefs(locations: Map<Id, ClientScheduleLocation>) {
for (const events of [this.events, this.originalEvents]) { for (const events of [this.events, this.originalEvents]) {
for (const event of events.values()) { 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++) { for (let i = 0; i < slot.locations.length; i++) {
const location = locations.get(slot.locations[i].id); const location = locations.get(slot.locations[i].id);
if (location && slot.locations[i] !== location) { if (location && slot.locations[i] !== location) {
@ -482,7 +512,7 @@ export class ClientSchedule extends ClientEntity {
for (const event of this.events.values()) { for (const event of this.events.values()) {
if (event.deleted) if (event.deleted)
continue; continue;
for (const slot of event.slots) { for (const slot of event.slots.values()) {
for (let i = 0; i < slot.locations.length; i++) { for (let i = 0; i < slot.locations.length; i++) {
if (slot.locations[i].id === id) { if (slot.locations[i].id === id) {
throw new Error(`Cannot delete location, event "${event.name}" depends on it`); throw new Error(`Cannot delete location, event "${event.name}" depends on it`);
@ -539,6 +569,17 @@ export class ClientSchedule extends ClientEntity {
} }
setEvent(event: ClientScheduleEvent) { 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.events.set(event.id, event);
this.modified = true; this.modified = true;
} }
@ -548,13 +589,17 @@ export class ClientSchedule extends ClientEntity {
edits: { edits: {
deleted?: boolean, deleted?: boolean,
name?: string, name?: string,
description?: string crew?: boolean,
description?: string,
slots?: Map<Id, ClientScheduleEventSlot>,
}, },
) { ) {
const copy = event.clone(); const copy = event.clone();
if (edits.deleted !== undefined) copy.deleted = edits.deleted; if (edits.deleted !== undefined) copy.deleted = edits.deleted;
if (edits.name !== undefined) copy.name = edits.name; 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.description !== undefined) copy.description = edits.description;
if (edits.slots !== undefined) copy.slots = edits.slots;
this.setEvent(copy); this.setEvent(copy);
} }
@ -568,6 +613,60 @@ export class ClientSchedule extends ClientEntity {
this.recalcModified(); 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<Id>,
},
) {
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<Id, ClientScheduleRole>) { private fixRoleRefs(roles: Map<Id, ClientScheduleRole>) {
for (const shifts of [this.shifts, this.originalShifts]) { for (const shifts of [this.shifts, this.originalShifts]) {
for (const shift of shifts.values()) { for (const shift of shifts.values()) {
@ -637,6 +736,17 @@ export class ClientSchedule extends ClientEntity {
} }
setShift(shift: ClientScheduleShift) { 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.shifts.set(shift.id, shift);
this.modified = true; this.modified = true;
} }
@ -645,14 +755,18 @@ export class ClientSchedule extends ClientEntity {
shift: ClientScheduleShift, shift: ClientScheduleShift,
edits: { edits: {
deleted?: boolean, deleted?: boolean,
role?: ClientScheduleRole,
name?: string, name?: string,
description?: string description?: string
slots?: Map<Id, ClientScheduleShiftSlot>,
}, },
) { ) {
const copy = shift.clone(); const copy = shift.clone();
if (edits.deleted !== undefined) copy.deleted = edits.deleted; 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.name !== undefined) copy.name = edits.name;
if (edits.description !== undefined) copy.description = edits.description; if (edits.description !== undefined) copy.description = edits.description;
if (edits.slots !== undefined) copy.slots = edits.slots;
this.setShift(copy); this.setShift(copy);
} }
@ -666,17 +780,68 @@ export class ClientSchedule extends ClientEntity {
this.recalcModified(); 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<Id>,
},
) {
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<ApiSchedule>, opts: { zone: Zone, locale: string }) { static fromApi(api: Living<ApiSchedule>, opts: { zone: Zone, locale: string }) {
const locations = entityMap(filterAlive(api.locations).map(location => ClientScheduleLocation.fromApi(location, opts))); const locations = idMap(filterAlive(api.locations).map(location => ClientScheduleLocation.fromApi(location, opts)));
const roles = entityMap(filterAlive(api.roles).map(role => ClientScheduleRole.fromApi(role, opts))); const roles = idMap(filterAlive(api.roles).map(role => ClientScheduleRole.fromApi(role, opts)));
return new this( return new this(
api.id, api.id,
DateTime.fromISO(api.updatedAt, opts), DateTime.fromISO(api.updatedAt, opts),
api.deleted ?? false, api.deleted ?? false,
locations, locations,
entityMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))), idMap(filterAlive(api.events).map(event => ClientScheduleEvent.fromApi(event, locations, opts))),
roles, 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) if (!entityUpdates)
return new Map(); 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) { for (const [id, updatedLocation] of setEntites) {
const modifiedLocation = entities.get(id); const modifiedLocation = entities.get(id);
if ( if (

View file

@ -1,4 +1,5 @@
import type { LocationQueryValue } from 'vue-router'; import type { LocationQueryValue } from 'vue-router';
import type { Id } from '~/shared/types/common';
export function queryToString(item?: null | LocationQueryValue | LocationQueryValue[]) { export function queryToString(item?: null | LocationQueryValue | LocationQueryValue[]) {
if (item === null) if (item === null)
@ -15,3 +16,7 @@ export function queryToNumber(item?: null | LocationQueryValue | LocationQueryVa
return queryToNumber(item[0]) return queryToNumber(item[0])
return Number.parseInt(item, 10); return Number.parseInt(item, 10);
} }
export function idMap<T extends { id: Id }>(entities: T[]) {
return new Map(entities.map(entity => [entity.id, entity]));
}