Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions apps/crew/src/lib/crew-staffing-calculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// @lat: [[crew#Staffing Calculator#Inputs and Role Assumptions]]
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[]
}

// @lat: [[crew#Staffing Calculator#Default Assumptions]]
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,
}

// @lat: [[crew#Staffing Calculator#Shift Coverage]]
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,
}
}

// @lat: [[crew#Staffing Calculator#Duration Display]]
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`
}

// @lat: [[crew#Staffing Calculator#Input Normalization]]
export 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)
}
21 changes: 21 additions & 0 deletions apps/crew/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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: '/',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -93,6 +102,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/calculator'
| '/events'
| '/events/$eventId'
| '/events/new'
Expand All @@ -103,6 +113,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/calculator'
| '/events'
| '/events/new'
| '/events/$eventId/schedule'
Expand All @@ -112,6 +123,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/calculator'
| '/events'
| '/events/$eventId'
| '/events/new'
Expand All @@ -123,6 +135,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CalculatorRoute: typeof CalculatorRoute
EventsRoute: typeof EventsRouteWithChildren
}

Expand All @@ -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: '/'
Expand Down Expand Up @@ -220,6 +240,7 @@ const EventsRouteWithChildren =

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CalculatorRoute: CalculatorRoute,
EventsRoute: EventsRouteWithChildren,
}
export const routeTree = rootRouteImport
Expand Down
11 changes: 10 additions & 1 deletion apps/crew/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { ErrorComponentProps } from "@tanstack/react-router"
import type { ReactNode } from "react"
import {
createRootRoute,
HeadContent,
Link,
Outlet,
Scripts,
} from "@tanstack/react-router"
import type { ReactNode } from "react"
import { Toaster } from "sonner"

import appCss from "../styles.css?url"
Expand Down Expand Up @@ -53,6 +53,15 @@ function RootComponent() {
<span>WODsmith Crew</span>
</Link>
<nav className="flex items-center gap-1 text-sm">
<Link
to="/calculator"
activeProps={{
className: "bg-muted text-foreground",
}}
className="rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground"
>
Calculator
</Link>
<Link
to="/events"
activeProps={{
Expand Down
Loading
Loading