Refactor API types and sync logic
All checks were successful
/ build (push) Successful in 2m5s
/ deploy (push) Successful in 16s

Rename and refactor the types passed over the API to be based on an
entity that's either living or a tombstone.  A living entity has a
deleted property that's either undefined or false, while a tombstone
has a deleted property set to true.  All entities have a numeric id
and an updatedAt timestamp.

To sync entities, an array of replacements are passed around. Living
entities are replaced with tombstones when they're deleted. And
tombstones are replaced with living entities when restored.
This commit is contained in:
Hornwitser 2025-06-11 21:05:17 +02:00
parent 251e83f640
commit fe06d0d6bd
36 changed files with 1242 additions and 834 deletions

View file

@ -1,30 +1,41 @@
import { Account } from "~/shared/types/account";
import { Role, Schedule, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
import type { ApiAccount, ApiSchedule, ApiScheduleEventSlot, ApiScheduleShiftSlot } from "~/shared/types/api";
import { toId } from "~/shared/utils/functions";
const locations = [
{
id: 1,
name: "Stage",
description: "Inside the main building."
description: "Inside the main building.",
updatedAt: "d0 18:21",
},
{
id: 2,
name: "Clubhouse",
description: "That big red building in the middle of the park."
description: "That big red building in the middle of the park.",
updatedAt: "d0 18:25",
},
{
id: 4,
name: "Summerhouse",
description: "Next to the campfire by the lake"
description: "Next to the campfire by the lake",
updatedAt: "d0 18:22",
},
{
id: 6,
name: "Campfire",
description: "Next to the big tree by the lake."
description: "Next to the big tree by the lake.",
updatedAt: "d0 18:41",
},
{
id: 7,
name: "Outside",
description: "Takes place somewhere outside."
description: "Takes place somewhere outside.",
updatedAt: "d0 18:37",
}
]
let slotId = 1;
let eventId = 1;
const events = [
{
name: "Arcade",
@ -133,17 +144,24 @@ const events = [
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16:00 1h clubhouse"]},
{ name: "Feedback Panel", slots: ["d5 12:00 1h clubhouse"]},
];
].map(({ slots, ...rest }) => ({
...rest,
id: eventId++,
slots: slots.map(slot => `${slot} ${slotId++}`),
}));
const roles: Role[] = [
{ id: "medic", name: "Medic" },
{ id: "security", name: "Security" },
const idMedic = 1;
const idSecurity = 2;
const roles = [
{ id: idMedic, name: "Medic", updatedAt: "d1 12:34" },
{ id: idSecurity, name: "Security", updatedAt: "d1 12:39" },
]
const rota = [
const shifts = [
{
name: "Medic Early",
role: "medic",
roleId: idMedic,
slots: [
"d1 12:00 4h",
"d2 12:00 4h",
@ -154,7 +172,7 @@ const rota = [
},
{
name: "Medic Late",
role: "medic",
roleId: idMedic,
slots: [
"d1 16:00 7h",
"d2 16:00 6h",
@ -164,7 +182,7 @@ const rota = [
},
{
name: "Security Early",
role: "security",
roleId: idSecurity,
slots: [
"d1 12:00 6h",
"d2 12:00 6h",
@ -175,7 +193,7 @@ const rota = [
},
{
name: "Security Late",
role: "security",
roleId: idSecurity,
slots: [
"d1 18:00 5h",
"d2 18:00 4h",
@ -183,13 +201,17 @@ const rota = [
"d4 18:00 5h",
],
},
]
].map(({ slots, ...rest }) => ({
...rest,
id: eventId++,
slots: slots.map(slot => `${slot} ${slotId++}`),
}));
function toIso(date: Date) {
return date.toISOString().replace(":00.000Z", "Z");
}
function toDates(origin: Date, day: string, start: string, duration: string) {
function toDate(origin: Date, day: string, start: string) {
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
const dayNumber = parseInt(day.slice(1));
@ -198,6 +220,11 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
startDate.setUTCHours(startDate.getUTCHours() + startHours);
startDate.setUTCMinutes(startDate.getUTCMinutes() + startMinutes);
return startDate;
}
function toDates(origin: Date, day: string, start: string, duration: string) {
const startDate = toDate(origin, day, start);
const [_, durationHours, durationMinutes] = /(?:(\d+)h)?(?:(\d+)m)?/.exec(duration)!;
const durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
const endDate = new Date(startDate.getTime() + durationTotal * 60e3);
@ -205,33 +232,35 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
return [startDate, endDate];
}
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>, idToAssigned: Map<string, number[]>): TimeSlot {
const [day, start, duration, location] = shorthand.split(" ");
function toSlot(origin: Date, shorthand: string, counts: Map<number, number>, idToAssigned: Map<number, number[]>): ApiScheduleEventSlot {
const [day, start, duration, location, idStr] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
const id = parseInt(idStr, 10);
return {
id: `${id}-${index}`,
id,
start: toIso(startDate),
end: toIso(endDate),
locations: [location],
assigned: idToAssigned.get(`${id}-${index}`),
interested: counts.get(`${id}-${index}`),
locationIds: [locations.find(l => toId(l.name) === location)!.id],
assigned: idToAssigned.get(id),
interested: counts.get(id),
};
}
function toShift(origin: Date, id: string, shorthand: string, index: number, idToAssigned: Map<string, number[]>): ShiftSlot {
const [day, start, duration] = shorthand.split(" ");
function toShift(origin: Date, shorthand: string, idToAssigned: Map<number, number[]>): ApiScheduleShiftSlot {
const [day, start, duration, idStr] = shorthand.split(" ");
const [startDate, endDate] = toDates(origin, day, start, duration);
const id = parseInt(idStr, 10);
return {
id: `${id}-${index}`,
id,
start: toIso(startDate),
end: toIso(endDate),
assigned: idToAssigned.get(`${id}-${index}`),
assigned: idToAssigned.get(id),
};
}
export function generateDemoSchedule(): Schedule {
export function generateDemoSchedule(): ApiSchedule {
const origin = new Date();
const utcOffset = 1;
origin.setUTCDate(origin.getUTCDate() - origin.getUTCDay() + 1); // Go to Monday
@ -240,101 +269,98 @@ export function generateDemoSchedule(): Schedule {
origin.setUTCSeconds(0);
origin.setUTCMilliseconds(0);
const counts = new Map<string, number>()
const eventCounts = new Map<number, number>()
const slotCounts = new Map<number, number>()
const accounts = generateDemoAccounts();
for (const account of accounts) {
for (const id of account.interestedIds ?? []) {
counts.set(id, (counts.get(id) ?? 0) + 1);
for (const id of account.interestedEventIds ?? []) {
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
}
for (const id of account.interestedEventSlotIds ?? []) {
slotCounts.set(id, (slotCounts.get(id) ?? 0) + 1);
}
}
seed = 2;
const idToAssigned = new Map<string, number[]>();
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
const assignedIds: string[] = [];
const slotsToAdd = Math.floor(random() * 20);
while (assignedIds.length < slotsToAdd) {
const event = events[Math.floor(random() * events.length)];
const eventId = toId(event.name);
if (assignedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
continue;
}
if (event.slots.length === 1 || random() < 0.8) {
for (const index of event.slots.map((_, index) => index)) {
assignedIds.push(toId(`${toId(event.name)}-${index}`));
function assignSlots(events: { id: number, slots: string[] }[], count: number) {
const idToAssigned = new Map<number, number[]>();
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
const assignedIds = new Set<number>;
const usedEvents = new Set<number>;
const slotsToAdd = Math.floor(random() * count);
while (assignedIds.size < slotsToAdd) {
const event = events[Math.floor(random() * events.length)];
if (usedEvents.has(event.id)) {
continue;
}
} else {
for (const index of event.slots.map((_, index) => index)) {
if (random() < 0.5) {
assignedIds.push(toId(`${toId(event.name)}-${index}`));
if (event.slots.length === 1 || random() < 0.8) {
for (const slot of event.slots) {
const id = parseInt(slot.split(" ").slice(-1)[0]);
assignedIds.add(id);
usedEvents.add(event.id);
}
} else {
for (const slot of event.slots) {
if (random() < 0.5) {
const id = parseInt(slot.split(" ").slice(-1)[0]);
assignedIds.add(id);
usedEvents.add(event.id);
}
}
}
}
}
for (const id of assignedIds) {
const assigned = idToAssigned.get(id);
if (assigned) {
assigned.push(account.id);
} else {
idToAssigned.set(id, [account.id]);
for (const id of assignedIds) {
const assigned = idToAssigned.get(id);
if (assigned) {
assigned.push(account.id);
} else {
idToAssigned.set(id, [account.id]);
}
}
}
return idToAssigned;
}
seed = 2;
const eventSlotIdToAssigned = assignSlots(events, 20);
seed = 5;
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
const assignedIds: string[] = [];
const slotsToAdd = Math.floor(random() * 3);
while (assignedIds.length < slotsToAdd) {
const shift = rota[Math.floor(random() * rota.length)];
const shiftId = toId(shift.name);
if (assignedIds.some(id => id.replace(/-\d+$/, "") === shiftId)) {
continue;
}
if (shift.slots.length === 1 || random() < 0.8) {
for (const index of shift.slots.map((_, index) => index)) {
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
}
} else {
for (const index of shift.slots.map((_, index) => index)) {
if (random() < 0.5) {
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
}
}
}
}
for (const id of assignedIds) {
const assigned = idToAssigned.get(id);
if (assigned) {
assigned.push(account.id);
} else {
idToAssigned.set(id, [account.id]);
}
}
}
const shiftSlotIdToAssigned = assignSlots(shifts, 3);
return {
id: 111,
updatedAt: toIso(toDate(origin, "d2", "10:01")),
events: events.map(
({ name, crew, description, slots }) => ({
id: toId(name),
({ id, name, crew, description, slots }) => ({
id,
name,
crew,
description,
interested: counts.get(toId(name)),
slots: slots.map((shorthand, index) => toSlot(origin, toId(name), shorthand, index, counts, idToAssigned))
interested: eventCounts.get(id),
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
updatedAt: toIso(toDate(origin, "d0", "15:11")),
})
),
locations: locations.map(
({ name, description }) => ({ id: toId(name), name, description })
),
roles,
rota: rota.map(
({ name, role, slots }) => ({
id: toId(name),
({ id, name, description, updatedAt }) => ({
id,
name,
role,
slots: slots.map((shorthand, index) => toShift(origin, toId(name), shorthand, index, idToAssigned))
description,
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
})
),
roles: roles.map(
({ id, name, updatedAt }) => ({
id,
name,
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
})
),
shifts: shifts.map(
({ id, name, roleId, slots }) => ({
id,
name,
roleId,
slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)),
updatedAt: toIso(toDate(origin, "d0", "13:23")),
})
)
};
@ -364,9 +390,9 @@ function random() {
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
}
export function generateDemoAccounts(): Account[] {
export function generateDemoAccounts(): ApiAccount[] {
seed = 1;
const accounts: Account[] = [];
const accounts: ApiAccount[] = [];
for (const name of names) {
accounts.push({
@ -378,38 +404,45 @@ export function generateDemoAccounts(): Account[] {
seed = 1;
// These have a much higher probability of being in someone's interested list.
const desiredEvent = ["opening", "closing", "fursuit-games"];
const desiredEvent = ["opening", "closing", "fursuit-games"].map(
id => events.find(e => toId(e.name) === id)!.id
);
const nonCrewEvents = events.filter(event => !event.crew);
for (const account of accounts) {
const interestedIds: string[] = [];
const interestedEventIds = new Set<number>;
const interestedSlotIds = new Set<number>;
const usedEvents = new Set<number>;
for (const id of desiredEvent) {
if (random() < 0.5) {
interestedIds.push(id);
interestedEventIds.add(id);
}
}
const eventsToAdd = Math.floor(random() * 10);
while (interestedIds.length < eventsToAdd) {
while (interestedEventIds.size + interestedSlotIds.size < eventsToAdd) {
const event = nonCrewEvents[Math.floor(random() * nonCrewEvents.length)];
const eventId = toId(event.name);
if (interestedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
if (usedEvents.has(event.id)) {
continue;
}
if (event.slots.length === 1 || random() < 0.8) {
interestedIds.push(toId(event.name))
interestedEventIds.add(event.id)
} else {
for (const index of event.slots.map((_, index) => index)) {
for (const slot of event.slots) {
if (random() < 0.5) {
interestedIds.push(toId(`${toId(event.name)}-${index}`));
const id = parseInt(slot.split(" ")[4], 10);
interestedSlotIds.add(id);
}
}
}
}
if (interestedIds.length) {
account.interestedIds = interestedIds;
if (interestedEventIds.size) {
account.interestedEventIds = [...interestedEventIds];
}
if (interestedSlotIds.size) {
account.interestedEventSlotIds = [...interestedSlotIds];
}
}
return accounts;