+ Estimate staffing
+
+
View events
@@ -42,6 +48,7 @@ function HomePage() {
Crew shell routes
{[
+ ["/calculator", "Staffing calculator"],
["/events", "Crew event list"],
["/events/new", "Event setup placeholder"],
["/events/$eventId", "Event operations overview"],
diff --git a/apps/crew/test/lib/crew-staffing-calculator.test.ts b/apps/crew/test/lib/crew-staffing-calculator.test.ts
new file mode 100644
index 000000000..816015fd8
--- /dev/null
+++ b/apps/crew/test/lib/crew-staffing-calculator.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest"
+import {
+ estimateCrewStaffing,
+ formatStaffingDuration,
+ type StaffingCalculatorInputs,
+} from "@/lib/crew-staffing-calculator"
+
+function baseInputs(
+ overrides: Partial = {},
+): StaffingCalculatorInputs {
+ return {
+ lanes: 8,
+ floors: 1,
+ heats: 20,
+ heatDurationMinutes: 12,
+ shiftLengthHours: 4,
+ roleAssumptions: [
+ {
+ 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: "athlete-control",
+ label: "Athlete control",
+ group: "volunteer",
+ basis: "event",
+ peoplePerUnit: 2,
+ },
+ ],
+ ...overrides,
+ }
+}
+
+describe("estimateCrewStaffing", () => {
+ // @lat: [[crew#Staffing Calculator#Role Basis]]
+ it("scales lane judge assumptions by floors and lanes", () => {
+ const estimate = estimateCrewStaffing(
+ baseInputs({ lanes: 10, floors: 2 }),
+ )
+
+ const laneJudges = estimate.roleEstimates.find(
+ (role) => role.id === "lane-judges",
+ )
+
+ expect(laneJudges?.concurrentPeople).toBe(20)
+ expect(estimate.judgeConcurrentPeople).toBe(22)
+ })
+
+ // @lat: [[crew#Staffing Calculator#Shift Coverage]]
+ it("converts heat time into shift slots for coverage", () => {
+ const estimate = estimateCrewStaffing(
+ baseInputs({ heats: 40, heatDurationMinutes: 12, shiftLengthHours: 4 }),
+ )
+
+ const laneJudges = estimate.roleEstimates.find(
+ (role) => role.id === "lane-judges",
+ )
+
+ expect(estimate.eventMinutes).toBe(480)
+ expect(laneJudges?.personMinutes).toBe(3840)
+ expect(laneJudges?.shiftSlots).toBe(16)
+ })
+
+ // @lat: [[crew#Staffing Calculator#Staffing Groups]]
+ it("keeps judge and volunteer estimates separated", () => {
+ const estimate = estimateCrewStaffing(baseInputs())
+
+ expect(estimate.judgeConcurrentPeople).toBe(9)
+ expect(estimate.volunteerConcurrentPeople).toBe(2)
+ expect(estimate.totalConcurrentPeople).toBe(11)
+ expect(estimate.totalShiftSlots).toBe(
+ estimate.judgeShiftSlots + estimate.volunteerShiftSlots,
+ )
+ })
+
+ // @lat: [[crew#Staffing Calculator#Input Normalization]]
+ it("normalizes invalid minimums without producing zero coverage", () => {
+ const estimate = estimateCrewStaffing(
+ baseInputs({
+ lanes: 0,
+ floors: Number.NaN,
+ heats: -4,
+ heatDurationMinutes: 0,
+ shiftLengthHours: 0,
+ }),
+ )
+
+ expect(estimate.eventMinutes).toBe(1)
+ expect(estimate.shiftLengthMinutes).toBe(15)
+ expect(estimate.totalConcurrentPeople).toBeGreaterThan(0)
+ })
+})
+
+describe("formatStaffingDuration", () => {
+ // @lat: [[crew#Staffing Calculator#Duration Display]]
+ it("formats full hours and mixed durations", () => {
+ expect(formatStaffingDuration(45)).toBe("45m")
+ expect(formatStaffingDuration(120)).toBe("2h")
+ expect(formatStaffingDuration(135)).toBe("2h 15m")
+ })
+})
diff --git a/lat.md/crew.md b/lat.md/crew.md
index 563f7756a..40b234ac8 100644
--- a/lat.md/crew.md
+++ b/lat.md/crew.md
@@ -8,6 +8,42 @@ Crew event setup pages let an operator create and review a normal competition wi
Additional setup dashboard state is stored in the existing `crew_event_settings.settings` JSON text field until a later slice proves that dedicated typed columns or tables are needed.
+## Staffing Calculator
+
+The public Crew calculator route estimates event-day staffing needs from event dimensions and editable role assumptions.
+
+It is deterministic planning math only; it does not create rosters, shifts, assignments, invitations, imports, or persisted setup state.
+
+### Inputs and Role Assumptions
+
+Calculator inputs model the minimum event dimensions operators need before scheduling.
+
+Those dimensions are lanes per floor, floor count, heat count, heat duration, shift length, and role assumptions. Role assumptions are grouped as judges or volunteers and use one of four bases: whole event, floor, lane, or lane per floor.
+
+### Default Assumptions
+
+Default assumptions seed the UI with lane judges, floor leads, score runners, equipment reset, athlete control, and check-in coverage. They are UI defaults only and must not be treated as saved event configuration.
+
+### Role Basis
+
+Lane-per-floor roles scale by `lanes * floors`; floor roles scale by floor count; lane roles scale by lane count; event roles scale once for the whole workout block.
+
+### Shift Coverage
+
+Shift coverage is estimated from `concurrent people * event minutes`, then rounded up by shift length to produce shift slots. The result is a coverage estimate, not a staffed shift board.
+
+### Staffing Groups
+
+Judge and volunteer totals stay separated in the estimate while also rolling up to a total concurrent headcount and total shift-slot count.
+
+### Input Normalization
+
+Calculator inputs are normalized before rendering and calculation: counts are whole numbers with a minimum of one, shift length has a quarter-hour minimum, and role multipliers cannot go below zero.
+
+### Duration Display
+
+Operator-facing durations render in compact hour/minute labels so workout block and shift length summaries stay scannable.
+
## Add Thin Crew Tables
Crew-owned database tables live in `@repo/wodsmith-db` so Start and Crew consume one shared schema source. App DB files remain forwarding shims and do not own `mysqlTable` definitions.