From 4d285b36a62fc74480db7d0a3cd7321f46ec7d62 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 04:57:41 +0000 Subject: [PATCH] feat(invites): per-division allocation summary + formula breakdown on source-detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `DivisionAllocationSummary` card to the organizer invites route. Mounted above the tabs so the per-division spot allocation is visible regardless of which tab is active. Each row collapses to show the per-source breakdown so organizers can see exactly which sources contribute spots to that division. - Fix the source-detail page's misleading "Default is 5" copy. For series sources the resolved default is `directSpotsPerComp × seriesCompCount + globalSpots` — the new copy renders the formula inline (e.g. `5 = 2 direct × 2 comps + 1 global`) so the math is never a black box. `seriesCompCount` is loaded server-side via `getInviteSourceByIdFn` so the formula always matches the resolver's math. - Source-isolation behavior is unchanged. Each source's quota is still enforced independently at claim time + Stripe re-check; the new card just surfaces the existing scoping so organizers can trust what they see. --- .../invites/division-allocation-summary.tsx | 265 ++++++++++++++++++ .../$competitionId/invites/index.tsx | 10 +- .../invites/sources/$sourceId.tsx | 87 ++++-- .../src/server-fns/competition-invite-fns.ts | 15 +- .../division-allocation-summary.test.tsx | 188 +++++++++++++ lat.md/competition-invites.md | 10 + 6 files changed, 543 insertions(+), 32 deletions(-) create mode 100644 apps/wodsmith-start/src/components/organizer/invites/division-allocation-summary.tsx create mode 100644 apps/wodsmith-start/test/components/division-allocation-summary.test.tsx diff --git a/apps/wodsmith-start/src/components/organizer/invites/division-allocation-summary.tsx b/apps/wodsmith-start/src/components/organizer/invites/division-allocation-summary.tsx new file mode 100644 index 000000000..ef93164cf --- /dev/null +++ b/apps/wodsmith-start/src/components/organizer/invites/division-allocation-summary.tsx @@ -0,0 +1,265 @@ +"use client" + +/** + * Division Allocation Summary — top-of-page card on the organizer invites + * route. Renders one collapsible row per championship division with the + * total spots allocated across every qualification source. Expanding a + * row shows the per-source breakdown so the organizer can see exactly + * which sources contribute spots to that division. + * + * Reads the same `allocationsBySourceByDivision` map the loader feeds to + * the Sources / Sent tabs — single source of truth for resolved spots + * per (source, championship-division). Each source's quota is enforced + * independently at claim time + Stripe re-check (see + * [[apps/wodsmith-start/src/server/competition-invites/claim.ts#getAcceptedPaidCountForBucket]]), + * so this view's per-source breakdown matches the runtime enforcement + * scoping the organizer cares about. + */ +// @lat: [[competition-invites#Division allocation summary]] + +import { ChevronRight, Layers, Trophy } from "lucide-react" +import { useState } from "react" +import { Badge } from "@/components/ui/badge" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + COMPETITION_INVITE_SOURCE_KIND, + type CompetitionInviteSource, +} from "@/db/schemas/competition-invites" +import { cn } from "@/utils/cn" + +interface DivisionAllocationSummaryProps { + divisions: ReadonlyArray<{ id: string; label: string }> + sources: ReadonlyArray + /** Resolved per-(source, championship-division) allocation map from + * `listInviteSourceAllocationsFn`. Drives both the per-division total + * and the per-source breakdown. */ + allocationsBySourceByDivision: Record> + competitionNamesById?: Record + seriesNamesById?: Record +} + +function sourceLabel( + source: CompetitionInviteSource, + competitionNamesById: Record | undefined, + seriesNamesById: Record | undefined, +): string { + if (source.kind === COMPETITION_INVITE_SOURCE_KIND.SERIES) { + return source.sourceGroupId + ? (seriesNamesById?.[source.sourceGroupId] ?? "Unknown series") + : "Unknown series" + } + return source.sourceCompetitionId + ? (competitionNamesById?.[source.sourceCompetitionId] ?? + "Unknown competition") + : "Unknown competition" +} + +export function DivisionAllocationSummary({ + divisions, + sources, + allocationsBySourceByDivision, + competitionNamesById, + seriesNamesById, +}: DivisionAllocationSummaryProps) { + const [openIds, setOpenIds] = useState>(() => new Set()) + + const setOpen = (divisionId: string, next: boolean) => { + setOpenIds((prev) => { + const out = new Set(prev) + if (next) out.add(divisionId) + else out.delete(divisionId) + return out + }) + } + + if (divisions.length === 0) { + return ( + + + Allocation by division + + This championship has no divisions yet — add divisions to see spot + allocations. + + + + ) + } + + // Per-division breakdown: list of {source, spots} pairs where spots > 0. + // Computed inline rather than memoized — divisions count is bounded + // and the parent re-renders on filter / nav events anyway. + const divisionBreakdowns = divisions.map((division) => { + const breakdown = sources + .map((source) => ({ + source, + spots: allocationsBySourceByDivision[source.id]?.[division.id] ?? 0, + })) + .filter((entry) => entry.spots > 0) + const total = breakdown.reduce((acc, entry) => acc + entry.spots, 0) + return { division, breakdown, total } + }) + + const championshipTotal = divisionBreakdowns.reduce( + (acc, d) => acc + d.total, + 0, + ) + + return ( + + + Allocation by division + + How qualifying spots are distributed across championship divisions. + Each source's quota is enforced independently — accepted invites from + one source never consume another source's spots. Click a division to + see the per-source breakdown. + + + +
+ + Total qualifying spots across {divisions.length} division + {divisions.length === 1 ? "" : "s"} + + + {championshipTotal} + +
+
+ {divisionBreakdowns.map(({ division, breakdown, total }) => { + const isOpen = openIds.has(division.id) + const hasBreakdown = breakdown.length > 0 + return ( + setOpen(division.id, next)} + > + +
+
+
+ + {total} + + + {total === 1 ? "spot" : "spots"} + {hasBreakdown ? ( + <> + {" · "} + {breakdown.length}{" "} + {breakdown.length === 1 ? "source" : "sources"} + + ) : null} + +
+
+ +
+ {hasBreakdown ? ( + + + + + Source + + + Spots + + + + + {breakdown.map(({ source, spots }) => { + const isSeries = + source.kind === + COMPETITION_INVITE_SOURCE_KIND.SERIES + const Icon = isSeries ? Layers : Trophy + const label = sourceLabel( + source, + competitionNamesById, + seriesNamesById, + ) + return ( + + +
+
+
+ + {label} + + + {isSeries ? "Series" : "Competition"} + +
+
+ + {spots} + +
+ ) + })} +
+
+ ) : null} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx index 333262051..3ceec7c71 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx @@ -38,6 +38,7 @@ import { rosterRowKey, } from "@/components/organizer/invites/championship-roster-table" import { DeleteInviteSourceDialog } from "@/components/organizer/invites/delete-invite-source-dialog" +import { DivisionAllocationSummary } from "@/components/organizer/invites/division-allocation-summary" import { EditInviteSourceDialog } from "@/components/organizer/invites/edit-invite-source-dialog" import { InviteSourcesList } from "@/components/organizer/invites/invite-sources-list" import { @@ -660,6 +661,14 @@ function InvitesPage() { + + Candidates @@ -1021,7 +1030,6 @@ function InvitesPage() { allocationsBySourceByDivision={allocationsBySourceByDivision} /> - {championshipDivisions[0] ? ( diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/sources/$sourceId.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/sources/$sourceId.tsx index 7dbdf38c6..b5f80f0a4 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/sources/$sourceId.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/sources/$sourceId.tsx @@ -137,6 +137,7 @@ export const Route = createFileRoute( return { source: sourceResult.source, + seriesCompCount: sourceResult.seriesCompCount, championshipDivisions, allocationsBySourceByDivision: allocationsResult.allocationsBySourceByDivision, @@ -156,8 +157,8 @@ interface OverrideState { function InviteSourceDetailsPage() { const { source, + seriesCompCount, championshipDivisions, - allocationsBySourceByDivision, rawAllocationsForSource, competitionOptions, seriesOptions, @@ -175,23 +176,16 @@ function InviteSourceDetailsPage() { // Source default applied per-division when no override row exists. // Mirrors `sourceDefaultPerDivision` in the server-side allocations - // helper — kept simple here because the details page only needs the - // displayable number, not the full resolution algorithm. - const sourceDefaultPerDivision = useMemo(() => { - if (source.kind === "series") { - // Series default is `directSpotsPerComp * compCount + globalSpots` - // applied per-division. We only have the resolved per-division - // total in `allocationsBySourceByDivision[source.id]` — pick any - // entry where there's no override to derive the default. If we - // can't (every entry has an override), fall back to the raw - // globalSpots so the toggle copy still has a number to show. - const map = allocationsBySourceByDivision[source.id] ?? {} - const firstDefault = Object.values(map)[0] - if (typeof firstDefault === "number") return firstDefault - return source.globalSpots ?? 0 - } - return source.globalSpots ?? 0 - }, [source, allocationsBySourceByDivision]) + // helper. Derived directly from the source row + seriesCompCount so the + // formula breakdown shown to the organizer always matches what the + // resolver computes — no inference from the resolved allocation map. + const directSpotsPerComp = source.directSpotsPerComp ?? 0 + const globalSpots = source.globalSpots ?? 0 + const compCount = seriesCompCount ?? 0 + const sourceDefaultPerDivision = + source.kind === "series" + ? directSpotsPerComp * compCount + globalSpots + : globalSpots // Seed the per-division override map from the raw allocation rows. // Presence of a row in `rawAllocationsForSource` means "override is @@ -245,8 +239,7 @@ function InviteSourceDetailsPage() { kind: values.kind, sourceCompetitionId: values.kind === "competition" ? values.sourceCompetitionId : null, - sourceGroupId: - values.kind === "series" ? values.sourceGroupId : null, + sourceGroupId: values.kind === "series" ? values.sourceGroupId : null, directSpotsPerComp: values.kind === "series" ? (values.directSpotsPerComp ?? null) @@ -287,7 +280,11 @@ function InviteSourceDetailsPage() { return } const parsed = Number(trimmed) - if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { + if ( + !Number.isFinite(parsed) || + parsed < 0 || + !Number.isInteger(parsed) + ) { setAllocationError("Spots must be 0 or greater.") return } @@ -394,11 +391,44 @@ function InviteSourceDetailsPage() { Per-division allocation - - Override how many spots this source contributes per championship - division. Default is{" "} - {sourceDefaultPerDivision}{" "} - per division. Toggle a row off to set an explicit value. + +
+ Override how many spots this source contributes per championship + division. Toggle a row off to set an explicit value. +
+
+ + Default per division: + {" "} + + {sourceDefaultPerDivision} + {" "} + {source.kind === "series" ? ( + + ={" "} + + {directSpotsPerComp} + {" "} + direct ×{" "} + + {compCount} + {" "} + {compCount === 1 ? "comp" : "comps"} +{" "} + + {globalSpots} + {" "} + global + + ) : ( + + = top{" "} + + {globalSpots} + {" "} + qualifies, applied to every division + + )} +
@@ -499,9 +529,7 @@ function InviteSourceDetailsPage() { @@ -511,4 +539,3 @@ function InviteSourceDetailsPage() { ) } - diff --git a/apps/wodsmith-start/src/server-fns/competition-invite-fns.ts b/apps/wodsmith-start/src/server-fns/competition-invite-fns.ts index 594b56c17..1a9045555 100644 --- a/apps/wodsmith-start/src/server-fns/competition-invite-fns.ts +++ b/apps/wodsmith-start/src/server-fns/competition-invite-fns.ts @@ -614,7 +614,20 @@ export const getInviteSourceByIdFn = createServerFn({ method: "GET" }) TEAM_PERMISSIONS.MANAGE_COMPETITIONS, ) - return { source } + // For series sources, count the comps in the group so the details + // page can render the default-spots formula (`directSpotsPerComp × + // seriesCompCount + globalSpots`). `null` for single-comp sources. + let seriesCompCount: number | null = null + if (source.sourceGroupId) { + const db = getDb() + const rows = await db + .select({ count: sql`count(*)` }) + .from(competitionsTable) + .where(eq(competitionsTable.groupId, source.sourceGroupId)) + seriesCompCount = Number(rows[0]?.count ?? 0) + } + + return { source, seriesCompCount } }, ) }) diff --git a/apps/wodsmith-start/test/components/division-allocation-summary.test.tsx b/apps/wodsmith-start/test/components/division-allocation-summary.test.tsx new file mode 100644 index 000000000..3a91077d6 --- /dev/null +++ b/apps/wodsmith-start/test/components/division-allocation-summary.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { DivisionAllocationSummary } from "@/components/organizer/invites/division-allocation-summary" +import type { CompetitionInviteSource } from "@/db/schemas/competition-invites" + +function source( + overrides: Partial & { id: string }, +): CompetitionInviteSource { + return { + championshipCompetitionId: "champ_1", + kind: "competition", + sourceCompetitionId: null, + sourceGroupId: null, + directSpotsPerComp: null, + globalSpots: null, + divisionMappings: null, + sortOrder: 0, + notes: null, + createdAt: new Date("2026-04-01T00:00:00Z"), + updatedAt: new Date("2026-04-01T00:00:00Z"), + updateCounter: 0, + ...overrides, + } +} + +const divisions = [ + { id: "div_rxm", label: "Men's RX" }, + { id: "div_rxw", label: "Women's RX" }, +] as const + +describe("DivisionAllocationSummary", () => { + it("renders the championship total across all divisions", () => { + const sources = [ + source({ + id: "src_series", + kind: "series", + sourceGroupId: "grp_throwdown", + }), + source({ + id: "src_comp", + kind: "competition", + sourceCompetitionId: "comp_global", + }), + ] + render( + , + ) + // 5 + 5 + 3 + 3 = 16 + expect(screen.getByText("16")).toBeInTheDocument() + }) + + it("isolates each source's per-division spots in the breakdown", () => { + const sources = [ + source({ + id: "src_series", + kind: "series", + sourceGroupId: "grp_throwdown", + }), + source({ + id: "src_comp", + kind: "competition", + sourceCompetitionId: "comp_global", + }), + ] + render( + , + ) + + // Click the Men's RX trigger to expand its breakdown. + const trigger = screen.getByRole("button", { + name: /Men's RX:\s*12 spots/i, + }) + fireEvent.click(trigger) + + // Both sources appear with their independent spot counts (5 and 7). + expect(screen.getByText("2025 Throwdown Series")).toBeInTheDocument() + expect(screen.getByText("Global Leaderboard")).toBeInTheDocument() + }) + + it("hides sources that contribute zero spots from the breakdown", () => { + const sources = [ + source({ + id: "src_series", + kind: "series", + sourceGroupId: "grp_throwdown", + }), + source({ + id: "src_zero", + kind: "competition", + sourceCompetitionId: "comp_zero", + }), + ] + render( + , + ) + + fireEvent.click( + screen.getByRole("button", { name: /Men's RX:\s*5 spots/i }), + ) + expect(screen.getByText("2025 Throwdown Series")).toBeInTheDocument() + expect(screen.queryByText("Excluded Comp")).not.toBeInTheDocument() + }) + + it("disables the toggle when a division has no contributing sources", () => { + render( + , + ) + + const trigger = screen.getByRole("button", { + name: /Team RX: 0 spots/i, + }) + expect(trigger).toBeDisabled() + }) + + it("renders the empty-state copy when no divisions are provided", () => { + render( + , + ) + expect( + screen.getByText(/no divisions yet/i), + ).toBeInTheDocument() + }) + + it("shows the per-division resolved total in the trigger row", () => { + render( + , + ) + const trigger = screen.getByRole("button", { + name: /Men's RX:\s*5 spots/i, + }) + expect(within(trigger).getByText("5")).toBeInTheDocument() + }) +}) diff --git a/lat.md/competition-invites.md b/lat.md/competition-invites.md index 1598470eb..20f79c506 100644 --- a/lat.md/competition-invites.md +++ b/lat.md/competition-invites.md @@ -54,6 +54,8 @@ Two write surfaces. Source meta (kind, source comp/series, default spots, notes) Loader gates on `MANAGE_COMPETITIONS` via [[apps/wodsmith-start/src/server-fns/competition-invite-fns.ts#getInviteSourceByIdFn]] — a new single-source read that resolves the championship organizing team from the source row — and runs `Promise.all` for the source row, championship divisions, the championship-wide allocation map, and the source pickers. Discard restores the loader's seed values; Save invalidates the route on success. +The per-division allocation card surfaces the **default-spots formula** inline so the resolved number is never a black box: for series sources it renders `defaultPerDivision = directSpotsPerComp × seriesCompCount + globalSpots` (e.g. `5 = 2 direct × 2 comps + 1 global`), and for single-comp sources it renders `defaultPerDivision = top globalSpots qualifies, applied to every division`. The series comp count is loaded server-side by `getInviteSourceByIdFn` (one extra count query when `source.sourceGroupId` is set) and threaded through the loader so the formula always matches the resolver's math — no client-side inference from the resolved allocation map. + ## Roster computation `getChampionshipRoster({ championshipId, divisionId })` in [[apps/wodsmith-start/src/server/competition-invites/roster.ts]] returns an ordered `RosterRow[]` for a championship + division. Invite-state columns are `null` in Phase 1. @@ -74,6 +76,14 @@ The loader loads sources, divisions, the first division's roster, the active-inv The original "Roster" tab was renamed to **Candidates** because the surface is a candidate-picking view (athletes pulled from sources plus bespoke drafts), not a registered roster. Internal helpers (`getChampionshipRoster`, `RosterRow`, `championship-roster-table.tsx`) keep their names — they describe the server-side join across source-competition leaderboards, which is still correct. +## Division allocation summary + +Top-of-page card on the organizer invites route — [[apps/wodsmith-start/src/components/organizer/invites/division-allocation-summary.tsx#DivisionAllocationSummary]] — answering "how many spots are allocated per division, and where do they come from?" with a click-to-expand per-source breakdown. + +Renders one collapsible row per championship division; expanding shows one row per source contributing > 0 spots, scoped to that division. Reads `allocationsBySourceByDivision` from [[apps/wodsmith-start/src/server-fns/competition-invite-fns.ts#listInviteSourceAllocationsFn]] — the same resolved map the Sources / Sent tabs consume, so no parallel math. The card is mounted above the tabs in [[apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx]] so it's visible regardless of which tab is active. Divisions with zero contributing sources still render but the trigger button is disabled (no breakdown to expand) — the empty row is intentional so the organizer can see the unallocated division at a glance and decide whether that's expected. + +Per-source rows scope match runtime enforcement: each source's quota is enforced independently in [[apps/wodsmith-start/src/server/competition-invites/claim.ts#getAcceptedPaidCountForBucket]] (claim-time guardrail) and the Stripe re-check in [[apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts]], so an organizer who reads "Series: 5, Competition: 3" in the breakdown can trust that one source's accepted invites never consume the other's spots. + ## Sent invites tab The Sent tab is an audit view that groups every issued invite (active OR terminal) by championship division. Reads the `AuditInviteSummary` projection from `listAllInvitesFn`; no mutations live here.