owltide/utils/client-schedule.ts

684 lines
17 KiB
TypeScript
Raw Normal View History

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, setEquals } from "~/shared/utils/functions";
function filterAlive<T extends Entity>(entities?: T[]) {
return (entities ?? []).filter((entity) => !entity.deleted) as Living<T>[];
}
function filterTombstone<T extends Entity>(entities?: 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) {
return timestamp.setZone(FixedOffsetZone.utcInstance).toISO();
}
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.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.name === other.name
&& this.description === other.description
)
}
static fromApi(api: Living<ApiScheduleLocation>, 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: ClientScheduleEventSlot[],
) {
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,
this.slots.map(slot => slot.clone()),
);
}
equals(other: ClientScheduleEvent) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& 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
&& arrayEquals(this.slots, other.slots, (a, b) => a.equals(b))
)
}
static fromApi(
api: Living<ApiScheduleEvent>,
locations: Map<Id, ClientScheduleLocation>,
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,
api.slots.map(slot => ClientScheduleEventSlot.fromApi(slot, 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.map(slot => slot.toApi()),
}
}
}
export class ClientScheduleEventSlot {
constructor(
public id: Id,
public start: DateTime,
public end: DateTime,
public locations: ClientScheduleLocation[],
public assigned: Set<Id>,
public interested: number,
) {
}
clone() {
return new ClientScheduleEventSlot(
this.id,
this.start,
this.end,
[...this.locations],
new Set(this.assigned),
this.interested,
);
}
equals(other: ClientScheduleEventSlot) {
return (
this.id === other.id
&& 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,
locations: Map<Id, ClientScheduleLocation>,
opts: { zone: Zone, locale: string }
) {
return new this(
api.id,
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 {
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.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.name === other.name
&& this.description === other.description
)
}
static fromApi(api: Living<ApiScheduleRole>, 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: ClientScheduleShiftSlot[],
) {
super(id, updatedAt, deleted);
}
clone() {
return new ClientScheduleShift(
this.id,
this.updatedAt,
this.deleted,
this.role,
this.name,
this.description,
this.slots.map(slot => slot.clone()),
)
}
equals(other: ClientScheduleShift) {
return (
this.id === other.id
&& this.updatedAt.toMillis() === other.updatedAt.toMillis()
&& this.deleted === other.deleted
&& this.role.id === other.role.id
&& this.name === other.name
&& this.description === other.description
&& arrayEquals(this.slots, other.slots, (a, b) => a.equals(b))
)
}
static fromApi(
api: Living<ApiScheduleShift>,
roles: Map<Id, ClientScheduleRole>,
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 ?? "",
api.slots.map(slot => ClientScheduleShiftSlot.fromApi(slot, 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.map(slot => slot.toApi()),
}
}
}
export class ClientScheduleShiftSlot {
constructor(
public id: Id,
public start: DateTime,
public end: DateTime,
public assigned: Set<Id>,
) {
}
clone() {
return new ClientScheduleShiftSlot(
this.id,
this.start,
this.end,
new Set(this.assigned),
)
}
equals(other: ClientScheduleShiftSlot) {
return (
this.id === other.id
&& 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 }) {
return new this(
api.id,
DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts),
new Set(api.assigned),
);
}
toApi(): ApiScheduleShiftSlot {
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<Id, ClientScheduleLocation>;
originalEvents: Map<Id, ClientScheduleEvent>;
originalRoles: Map<Id, ClientScheduleRole>;
originalShifts: Map<Id, ClientScheduleShift>;
constructor(
id: 111,
updatedAt: DateTime,
deleted: boolean,
public locations: Map<Id, ClientScheduleLocation>,
public events: Map<Id, ClientScheduleEvent>,
public roles: Map<Id, ClientScheduleRole>,
public shifts: Map<Id, ClientScheduleShift>,
) {
super(id, updatedAt, deleted);
this.originalLocations = new Map(locations);
this.originalEvents = new Map(events);
this.originalRoles = new Map(roles);
this.originalShifts = new Map(shifts);
}
equals(other: ClientSchedule): boolean {
throw new Error("ClientSchedule.equals not implemented")
}
#fixLocationRefs(locations: Map<Id, ClientScheduleLocation>) {
for (const events of [this.events, this.originalEvents]) {
for (const event of events.values()) {
for (const slot of event.slots) {
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;
}
}
}
}
}
}
#checkLocationRefsForDeletion(id: Id) {
for (const event of this.events.values()) {
for (const slot of event.slots) {
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`);
}
}
}
}
}
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]]));
}
}
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);
}
}
setEvent(event: ClientScheduleEvent) {
this.events.set(event.id, event);
}
restoreEvent(id: Id) {
const event = this.originalEvents.get(id);
if (event) {
this.events.set(id, event);
} else {
this.events.delete(id);
}
}
#fixRoleRefs(roles: Map<Id, ClientScheduleRole>) {
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;
}
}
}
}
#checkRoleRefsForDeletion(id: Id) {
for (const shift of this.shifts.values()) {
if (shift.role.id === id) {
throw new Error(`Cannot delete role, shift "${shift.name}" depends on it`);
}
}
}
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]]));
}
}
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);
}
}
setShift(shift: ClientScheduleShift) {
this.shifts.set(shift.id, shift);
}
restoreShift(id: Id) {
const shift = this.originalShifts.get(id);
if (shift) {
this.shifts.set(id, shift);
} else {
this.shifts.delete(id);
}
}
static fromApi(api: Living<ApiSchedule>, 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)));
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))),
roles,
entityMap(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<T extends Entity, U extends ClientEntity>(
entityUpdates: T[] | undefined,
fromApi: (api: Living<T>) => U,
originalEntities: Map<Id, U>,
entities: Map<Id, U>,
) {
if (!entityUpdates)
return new Map();
const setEntites = entityMap(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,
);
}
}