I firmly believe in free software. The application I'm making here have capabilities that I've not seen in any system. It presents itself as an opportunity to collaborate on a tool that serves the people rather than corporations. Whose incentives are to help people rather, not make the most money. And whose terms ensure that these freedoms and incentives cannot be taken back or subverted. I license this software under the AGPL.
459 lines
12 KiB
TypeScript
459 lines
12 KiB
TypeScript
/*
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
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.",
|
|
updatedAt: "d-1 18:21",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Clubhouse",
|
|
description: "That big red building in the middle of the park.",
|
|
updatedAt: "d-1 18:25",
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "Summerhouse",
|
|
description: "Next to the campfire by the lake",
|
|
updatedAt: "d-1 18:22",
|
|
},
|
|
{
|
|
id: 6,
|
|
name: "Campfire",
|
|
description: "Next to the big tree by the lake.",
|
|
updatedAt: "d-1 18:41",
|
|
},
|
|
{
|
|
id: 7,
|
|
name: "Outside",
|
|
description: "Takes place somewhere outside.",
|
|
updatedAt: "d-1 18:37",
|
|
}
|
|
]
|
|
|
|
let slotId = 1;
|
|
let eventId = 1;
|
|
const events = [
|
|
{
|
|
name: "Arcade",
|
|
description: "Play retro games!",
|
|
slots: [
|
|
"d1 12:00 4h clubhouse",
|
|
"d2 12:00 4h clubhouse",
|
|
"d3 12:00 4h clubhouse",
|
|
],
|
|
},
|
|
{ name: "Arcade Setup", crew: true, slots: ["d1 11:00 1h clubhouse"], },
|
|
{
|
|
name: "Bonfire Stories",
|
|
description: "Share your stories as we sit cosily around the campfire.",
|
|
slots: ["d2 18:00 2h campfire"],
|
|
},
|
|
{ name: "Stage Rigging", crew: true, slots: ["d1 11:00 7h30m stage"]},
|
|
{
|
|
name: "Reconfigure for DJ",
|
|
crew: true,
|
|
slots: [
|
|
"d2 19:00 1h stage",
|
|
"d3 18:45 1h15m stage",
|
|
],
|
|
},
|
|
{ name: "DJ Alpha", slots: ["d2 20:00 2h stage"] },
|
|
{ name: "DJ Bravo", slots: ["d3 20:00 2h stage"] },
|
|
{ name: "DJ Charlie", slots: ["d3 22:00 2h stage"] },
|
|
{ name: "Prepare Fursuit Games", crew: true, slots: ["d4 17:00 1h clubhouse"] },
|
|
{
|
|
name: "Fursuit Games",
|
|
description: "Playful time for the suiters.",
|
|
slots: ["d4 18:00 2h clubhouse"],
|
|
},
|
|
{ name: "Fishing Trip", slots: ["d3 12:00 3h30m outside"]},
|
|
{ name: "Opening", slots: ["d1 18:30 1h30m stage"]},
|
|
{ name: "Closing", slots: ["d5 10:00 1h stage"]},
|
|
{ name: "Stage Teardown", crew: true, slots: ["d5 11:00 4h stage"]},
|
|
{ name: "Setup Board Games", crew: true, slots: ["d1 11:30 30m summerhouse"]},
|
|
{
|
|
name: "Board Games",
|
|
slots: [
|
|
"d1 12:00 4h summerhouse",
|
|
"d2 12:00 4h summerhouse",
|
|
"d3 12:00 4h summerhouse",
|
|
"d4 12:00 4h summerhouse",
|
|
],
|
|
},
|
|
{ name: "Teardown Board Games", crew: true, slots: ["d4 16:00 30m summerhouse"]},
|
|
{ name: "📷meet", slots: ["d3 19:00 1h10m summerhouse"]},
|
|
{
|
|
name: "Prepare Karaoke",
|
|
crew: true,
|
|
slots: [
|
|
"d3 20:00 1h clubhouse",
|
|
"d4 20:00 1h clubhouse",
|
|
],
|
|
},
|
|
{
|
|
name: "Karaoke",
|
|
slots: [
|
|
"d3 21:00 2h clubhouse",
|
|
"d4 21:00 2h clubhouse",
|
|
],
|
|
},
|
|
{ name: "Dance", slots: ["d1 20:00 3h stage"]},
|
|
{ name: "Prepare Charity Auction", crew: true, slots: ["d4 10:00 3h stage"]},
|
|
{ name: "Charity Auction", slots: ["d4 13:00 2h stage"]},
|
|
{ name: "Tournament Preparation", crew: true, slots: ["d2 15:00 2h stage"]},
|
|
{ name: "Tournament", slots: ["d2 17:00 2h stage"]},
|
|
{ name: "Canoe Trip", slots: ["d4 11:00 4h30m outside"]},
|
|
{
|
|
name: "Dinner Preparations",
|
|
crew: true,
|
|
slots: [
|
|
"d1 14:00 2h campfire",
|
|
"d2 14:00 2h campfire",
|
|
"d3 14:00 2h campfire",
|
|
"d4 14:00 2h campfire",
|
|
],
|
|
},
|
|
{
|
|
name: "Dinner",
|
|
slots: [
|
|
"d1 16:00 1h campfire",
|
|
"d2 16:00 1h campfire",
|
|
"d3 16:00 1h campfire",
|
|
"d4 16:00 1h campfire",
|
|
],
|
|
},
|
|
{
|
|
name: "Dinner Cleanup",
|
|
crew: true,
|
|
slots: [
|
|
"d1 17:00 1h campfire",
|
|
"d2 17:00 1h campfire",
|
|
"d3 17:00 1h campfire",
|
|
"d4 17:00 1h campfire",
|
|
],
|
|
},
|
|
{ name: "Prepare Film Night", crew: true, slots: ["d4 19:00 2h stage"]},
|
|
{ name: "Film Night", slots: ["d4 21:00 2h stage"]},
|
|
{ name: "Prepare Group Photo", crew: true, slots: ["d3 17:00 1h stage"]},
|
|
{ name: "Group Photo", slots: ["d3 18:00 45m stage"]},
|
|
{ name: "Setup Artist Alley", crew: true, slots: ["d4 10:00 2h clubhouse"]},
|
|
{ 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 idMedic = 1;
|
|
const idSecurity = 2;
|
|
const roles = [
|
|
{ id: idMedic, name: "Medic", updatedAt: "d-2 12:34" },
|
|
{ id: idSecurity, name: "Security", updatedAt: "d-2 12:39" },
|
|
]
|
|
|
|
const shifts = [
|
|
{
|
|
name: "Medic Early",
|
|
roleId: idMedic,
|
|
slots: [
|
|
"d1 12:00 4h",
|
|
"d2 12:00 4h",
|
|
"d3 12:00 4h",
|
|
"d4 11:00 5h",
|
|
"d5 10:00 3h",
|
|
],
|
|
},
|
|
{
|
|
name: "Medic Late",
|
|
roleId: idMedic,
|
|
slots: [
|
|
"d1 16:00 7h",
|
|
"d2 16:00 6h",
|
|
"d3 16:00 8h",
|
|
"d4 16:00 7h",
|
|
],
|
|
},
|
|
{
|
|
name: "Security Early",
|
|
roleId: idSecurity,
|
|
slots: [
|
|
"d1 12:00 6h",
|
|
"d2 12:00 6h",
|
|
"d3 12:00 6h",
|
|
"d4 11:00 7h",
|
|
"d5 10:00 3h",
|
|
],
|
|
},
|
|
{
|
|
name: "Security Late",
|
|
roleId: idSecurity,
|
|
slots: [
|
|
"d1 18:00 5h",
|
|
"d2 18:00 4h",
|
|
"d3 18:00 6h",
|
|
"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 toDate(origin: Date, day: string, start: string) {
|
|
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
|
|
const dayNumber = parseInt(day.slice(1));
|
|
|
|
const startDate = new Date(origin);
|
|
startDate.setUTCDate(startDate.getUTCDate() + dayNumber);
|
|
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);
|
|
|
|
return [startDate, endDate];
|
|
}
|
|
|
|
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,
|
|
start: toIso(startDate),
|
|
end: toIso(endDate),
|
|
locationIds: [locations.find(l => toId(l.name) === location)!.id],
|
|
assigned: idToAssigned.get(id),
|
|
interested: counts.get(id),
|
|
};
|
|
}
|
|
|
|
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,
|
|
start: toIso(startDate),
|
|
end: toIso(endDate),
|
|
assigned: idToAssigned.get(id),
|
|
};
|
|
}
|
|
|
|
function getDemoOrigin() {
|
|
const origin = new Date();
|
|
const utcOffset = 1;
|
|
origin.setUTCDate(origin.getUTCDate() - 1); // Day 0 is yesterday
|
|
origin.setUTCHours(-utcOffset);
|
|
origin.setUTCMinutes(0);
|
|
origin.setUTCSeconds(0);
|
|
origin.setUTCMilliseconds(0);
|
|
return origin;
|
|
}
|
|
|
|
export function generateDemoSchedule(): ApiSchedule {
|
|
const origin = getDemoOrigin();
|
|
|
|
const eventCounts = new Map<number, number>()
|
|
const slotCounts = new Map<number, number>()
|
|
const accounts = generateDemoAccounts(origin);
|
|
for (const account of accounts) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
return idToAssigned;
|
|
}
|
|
seed = 2;
|
|
const eventSlotIdToAssigned = assignSlots(events, 20);
|
|
|
|
seed = 5;
|
|
const shiftSlotIdToAssigned = assignSlots(shifts, 3);
|
|
|
|
return {
|
|
id: 111,
|
|
updatedAt: toIso(toDate(origin, "d-2", "10:01")),
|
|
events: events.map(
|
|
({ id, name, crew, description, slots }) => ({
|
|
id,
|
|
name,
|
|
crew,
|
|
description,
|
|
interested: eventCounts.get(id),
|
|
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
|
|
updatedAt: toIso(toDate(origin, "d-1", "15:11")),
|
|
})
|
|
),
|
|
locations: locations.map(
|
|
({ id, name, description, updatedAt }) => ({
|
|
id,
|
|
name,
|
|
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, "d-1", "13:23")),
|
|
})
|
|
)
|
|
};
|
|
}
|
|
|
|
const names = [
|
|
"Leo", "Lisa",
|
|
"Jack", "Emily",
|
|
"Roy", "Sofia",
|
|
"Adam", "Eve",
|
|
"Max", "Rose",
|
|
"Hugo", "Maria",
|
|
"David", "Zoe",
|
|
"Hunter", "Ria",
|
|
"Sonny", "Amy",
|
|
"Kai", "Megan",
|
|
"Toby", "Katie",
|
|
"Bob", "Lucy",
|
|
];
|
|
|
|
// MINSTD random implementation for reproducible random numbers.
|
|
let seed = 1;
|
|
function random() {
|
|
const a = 48271;
|
|
const c = 0;
|
|
const m = 2 ** 31 -1;
|
|
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
|
|
}
|
|
|
|
export function generateDemoAccounts(origin = getDemoOrigin()): ApiAccount[] {
|
|
seed = 1;
|
|
const accounts: ApiAccount[] = [];
|
|
|
|
for (const name of names) {
|
|
accounts.push({
|
|
id: accounts.length,
|
|
updatedAt: toIso(toDate(origin, "d-1", "10:41")),
|
|
name,
|
|
type: (["regular", "crew", "crew", "crew", "admin"] as const)[Math.floor(random() ** 3 * 5)],
|
|
});
|
|
}
|
|
|
|
seed = 1;
|
|
// These have a much higher probability of being in someone's interested list.
|
|
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 interestedEventIds = new Set<number>;
|
|
const interestedSlotIds = new Set<number>;
|
|
const usedEvents = new Set<number>;
|
|
for (const id of desiredEvent) {
|
|
if (random() < 0.5) {
|
|
interestedEventIds.add(id);
|
|
}
|
|
}
|
|
|
|
const eventsToAdd = Math.floor(random() * 10);
|
|
while (interestedEventIds.size + interestedSlotIds.size < eventsToAdd) {
|
|
const event = nonCrewEvents[Math.floor(random() * nonCrewEvents.length)];
|
|
if (usedEvents.has(event.id)) {
|
|
continue;
|
|
}
|
|
|
|
if (event.slots.length === 1 || random() < 0.8) {
|
|
interestedEventIds.add(event.id)
|
|
} else {
|
|
for (const slot of event.slots) {
|
|
if (random() < 0.5) {
|
|
const id = parseInt(slot.split(" ")[4], 10);
|
|
interestedSlotIds.add(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (interestedEventIds.size) {
|
|
account.interestedEventIds = [...interestedEventIds];
|
|
}
|
|
if (interestedSlotIds.size) {
|
|
account.interestedEventSlotIds = [...interestedSlotIds];
|
|
}
|
|
}
|
|
return accounts;
|
|
}
|