From 13bc4504b5a80c71311f19de829bcf1d1999689e Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Fri, 19 Jun 2026 00:47:12 -0600 Subject: [PATCH 1/2] feat(crew): add staffing calculator --- apps/crew/src/lib/crew-staffing-calculator.ts | 203 +++++++++++ apps/crew/src/routeTree.gen.ts | 21 ++ apps/crew/src/routes/__root.tsx | 11 +- apps/crew/src/routes/calculator.tsx | 336 ++++++++++++++++++ apps/crew/src/routes/index.tsx | 9 +- .../test/lib/crew-staffing-calculator.test.ts | 106 ++++++ 6 files changed, 684 insertions(+), 2 deletions(-) create mode 100644 apps/crew/src/lib/crew-staffing-calculator.ts create mode 100644 apps/crew/src/routes/calculator.tsx create mode 100644 apps/crew/test/lib/crew-staffing-calculator.test.ts diff --git a/apps/crew/src/lib/crew-staffing-calculator.ts b/apps/crew/src/lib/crew-staffing-calculator.ts new file mode 100644 index 000000000..fe709342a --- /dev/null +++ b/apps/crew/src/lib/crew-staffing-calculator.ts @@ -0,0 +1,203 @@ +export type StaffingRoleGroup = "judge" | "volunteer" + +export type StaffingRoleBasis = "event" | "floor" | "lane" | "lanePerFloor" + +export interface StaffingRoleAssumption { + id: string + label: string + group: StaffingRoleGroup + basis: StaffingRoleBasis + peoplePerUnit: number +} + +export interface StaffingCalculatorInputs { + lanes: number + floors: number + heats: number + heatDurationMinutes: number + shiftLengthHours: number + roleAssumptions: StaffingRoleAssumption[] +} + +export interface StaffingRoleEstimate { + id: string + label: string + group: StaffingRoleGroup + basis: StaffingRoleBasis + peoplePerUnit: number + concurrentPeople: number + personMinutes: number + shiftSlots: number +} + +export interface StaffingCalculatorEstimate { + eventMinutes: number + shiftLengthMinutes: number + totalConcurrentPeople: number + totalShiftSlots: number + judgeConcurrentPeople: number + judgeShiftSlots: number + volunteerConcurrentPeople: number + volunteerShiftSlots: number + roleEstimates: StaffingRoleEstimate[] +} + +export const defaultStaffingRoleAssumptions: StaffingRoleAssumption[] = [ + { + id: "lane-judges", + label: "Lane judges", + group: "judge", + basis: "lanePerFloor", + peoplePerUnit: 1, + }, + { + id: "floor-leads", + label: "Floor leads", + group: "judge", + basis: "floor", + peoplePerUnit: 1, + }, + { + id: "score-runners", + label: "Score runners", + group: "volunteer", + basis: "floor", + peoplePerUnit: 1, + }, + { + id: "equipment-reset", + label: "Equipment reset", + group: "volunteer", + basis: "lanePerFloor", + peoplePerUnit: 0.5, + }, + { + id: "athlete-control", + label: "Athlete control", + group: "volunteer", + basis: "event", + peoplePerUnit: 2, + }, + { + id: "check-in", + label: "Check-in", + group: "volunteer", + basis: "event", + peoplePerUnit: 2, + }, +] + +export const defaultStaffingCalculatorInputs: StaffingCalculatorInputs = { + lanes: 8, + floors: 1, + heats: 24, + heatDurationMinutes: 12, + shiftLengthHours: 4, + roleAssumptions: defaultStaffingRoleAssumptions, +} + +export function estimateCrewStaffing( + inputs: StaffingCalculatorInputs, +): StaffingCalculatorEstimate { + const normalizedInputs = normalizeStaffingCalculatorInputs(inputs) + const eventMinutes = + normalizedInputs.heats * normalizedInputs.heatDurationMinutes + const shiftLengthMinutes = normalizedInputs.shiftLengthHours * 60 + + const roleEstimates = normalizedInputs.roleAssumptions.map((role) => { + const concurrentPeople = Math.ceil( + role.peoplePerUnit * getRoleBasisUnits(role.basis, normalizedInputs), + ) + const personMinutes = concurrentPeople * eventMinutes + + return { + ...role, + concurrentPeople, + personMinutes, + shiftSlots: Math.ceil(personMinutes / shiftLengthMinutes), + } + }) + + return { + eventMinutes, + shiftLengthMinutes, + totalConcurrentPeople: sumRoleValue(roleEstimates, "concurrentPeople"), + totalShiftSlots: sumRoleValue(roleEstimates, "shiftSlots"), + judgeConcurrentPeople: sumRoleValue( + roleEstimates.filter((role) => role.group === "judge"), + "concurrentPeople", + ), + judgeShiftSlots: sumRoleValue( + roleEstimates.filter((role) => role.group === "judge"), + "shiftSlots", + ), + volunteerConcurrentPeople: sumRoleValue( + roleEstimates.filter((role) => role.group === "volunteer"), + "concurrentPeople", + ), + volunteerShiftSlots: sumRoleValue( + roleEstimates.filter((role) => role.group === "volunteer"), + "shiftSlots", + ), + roleEstimates, + } +} + +export function formatStaffingDuration(minutes: number) { + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + + if (hours === 0) return `${remainingMinutes}m` + if (remainingMinutes === 0) return `${hours}h` + + return `${hours}h ${remainingMinutes}m` +} + +function normalizeStaffingCalculatorInputs( + inputs: StaffingCalculatorInputs, +): StaffingCalculatorInputs { + return { + lanes: clampWholeNumber(inputs.lanes, 1), + floors: clampWholeNumber(inputs.floors, 1), + heats: clampWholeNumber(inputs.heats, 1), + heatDurationMinutes: clampWholeNumber(inputs.heatDurationMinutes, 1), + shiftLengthHours: clampDecimal(inputs.shiftLengthHours, 0.25), + roleAssumptions: inputs.roleAssumptions.map((role) => ({ + ...role, + peoplePerUnit: clampDecimal(role.peoplePerUnit, 0), + })), + } +} + +function getRoleBasisUnits( + basis: StaffingRoleBasis, + inputs: StaffingCalculatorInputs, +) { + switch (basis) { + case "event": + return 1 + case "floor": + return inputs.floors + case "lane": + return inputs.lanes + case "lanePerFloor": + return inputs.lanes * inputs.floors + } +} + +function sumRoleValue( + roles: StaffingRoleEstimate[], + key: "concurrentPeople" | "shiftSlots", +) { + return roles.reduce((total, role) => total + role[key], 0) +} + +function clampWholeNumber(value: number, minimum: number) { + if (!Number.isFinite(value)) return minimum + return Math.max(minimum, Math.round(value)) +} + +function clampDecimal(value: number, minimum: number) { + if (!Number.isFinite(value)) return minimum + return Math.max(minimum, value) +} diff --git a/apps/crew/src/routeTree.gen.ts b/apps/crew/src/routeTree.gen.ts index ea61f41db..a0f4ac90e 100644 --- a/apps/crew/src/routeTree.gen.ts +++ b/apps/crew/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as EventsRouteImport } from './routes/events' +import { Route as CalculatorRouteImport } from './routes/calculator' import { Route as IndexRouteImport } from './routes/index' import { Route as EventsNewRouteImport } from './routes/events/new' import { Route as EventsEventIdRouteImport } from './routes/events/$eventId' @@ -23,6 +24,11 @@ const EventsRoute = EventsRouteImport.update({ path: '/events', getParentRoute: () => rootRouteImport, } as any) +const CalculatorRoute = CalculatorRouteImport.update({ + id: '/calculator', + path: '/calculator', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -61,6 +67,7 @@ const EventsEventIdScheduleRoute = EventsEventIdScheduleRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/calculator': typeof CalculatorRoute '/events': typeof EventsRouteWithChildren '/events/$eventId': typeof EventsEventIdRouteWithChildren '/events/new': typeof EventsNewRoute @@ -71,6 +78,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/calculator': typeof CalculatorRoute '/events': typeof EventsRouteWithChildren '/events/new': typeof EventsNewRoute '/events/$eventId/schedule': typeof EventsEventIdScheduleRoute @@ -81,6 +89,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/calculator': typeof CalculatorRoute '/events': typeof EventsRouteWithChildren '/events/$eventId': typeof EventsEventIdRouteWithChildren '/events/new': typeof EventsNewRoute @@ -93,6 +102,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/calculator' | '/events' | '/events/$eventId' | '/events/new' @@ -103,6 +113,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/calculator' | '/events' | '/events/new' | '/events/$eventId/schedule' @@ -112,6 +123,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/calculator' | '/events' | '/events/$eventId' | '/events/new' @@ -123,6 +135,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CalculatorRoute: typeof CalculatorRoute EventsRoute: typeof EventsRouteWithChildren } @@ -135,6 +148,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EventsRouteImport parentRoute: typeof rootRouteImport } + '/calculator': { + id: '/calculator' + path: '/calculator' + fullPath: '/calculator' + preLoaderRoute: typeof CalculatorRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -220,6 +240,7 @@ const EventsRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CalculatorRoute: CalculatorRoute, EventsRoute: EventsRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/apps/crew/src/routes/__root.tsx b/apps/crew/src/routes/__root.tsx index 0e159922a..c82379e32 100644 --- a/apps/crew/src/routes/__root.tsx +++ b/apps/crew/src/routes/__root.tsx @@ -1,5 +1,4 @@ import type { ErrorComponentProps } from "@tanstack/react-router" -import type { ReactNode } from "react" import { createRootRoute, HeadContent, @@ -7,6 +6,7 @@ import { Outlet, Scripts, } from "@tanstack/react-router" +import type { ReactNode } from "react" import { Toaster } from "sonner" import appCss from "../styles.css?url" @@ -53,6 +53,15 @@ function RootComponent() { WODsmith Crew