diff --git a/ai/research/hillerfit-benchmark-leaderboard-guide.md b/ai/research/hillerfit-benchmark-leaderboard-guide.md index cd68e81f9..524983d3f 100644 --- a/ai/research/hillerfit-benchmark-leaderboard-guide.md +++ b/ai/research/hillerfit-benchmark-leaderboard-guide.md @@ -1,9 +1,10 @@ -# HillerFit Benchmark Leaderboard — Implementation Guide +# Generic Benchmark Leaderboard — HillerFit Training PDF Seed -> **Status:** Design proposal for review (**revision 2** — a second adversarial critique was run against the codebase and incorporated). No code has been written. This is the document Zac reviews *before* implementation begins. -> **Scope:** A generic, reusable "benchmark battery" feature, with HillerFit as the first seeded battery, plus a degenerate "post your bench" single-test case. -> **Phasing (revised):** **v1 starts with an M0a competition-type capability registry** (chokepoint refactor only; the ~100-site M0b cleanup is deferred post-demo, §10) (so `"online"` stays a clean standalone product), then ships HillerFit-shaped — the absolute-tier engine + a public **best-to-date** Overall/100 board + per-athlete stat line on a **distinct `competitionType:"benchmark"`**. **The generic authoring UI, retest-history windowing, and the hybrid Open-test class are explicitly v2** (additive, no rewrites — the data model is generic from day one). See §10. -> **Grounding:** Every architectural claim below is tied to a real file:line in `apps/wodsmith-start/src/`, re-verified against the codebase in this revision. Key decisions called out inline: the **capability-registry refactor of the ~129 `competitionType` sites (M0a chokepoints first, M0b cleanup deferred)** (owner steer — distinct `"benchmark"` type, NOT reuse `"online"`), the `EventScoreInput.variant`+`secondaryValue` additions, the `scoreType`-driven direction with no `direction` column, `int`-not-`bigint` thresholds, the DB-`dq`→engine-`dnf` status mapping, and the leaner phasing. +> **Status:** Implementation contract maintained during the phased benchmark build (**revision 4** — updated after M0a/M1/M2/M3/M4 landed on `feat/hillerfit-plan`). This is the document Zac reviews before changing product scope or reopening fixed decisions. +> **Scope:** A generic, reusable "benchmark battery" feature, with the local `HillerFit_Training_Guide.pdf` as the first benchmark data source, plus a degenerate "post your bench" single-test case. **The PDF is source data only. We are not building any HillerFit-branded page, route, navigation item, marketing surface, logo, theme treatment, or customer-facing product area.** The user-facing product remains WODsmith's generic benchmark experience. +> **Phasing (revised):** **v1 starts with an M0a competition-type capability registry** (chokepoint refactor only; the ~100-site M0b cleanup is deferred post-demo) (so `"online"` stays a clean standalone product), then ships a training-guide-derived benchmark — the absolute-tier engine + a public **best-to-date** Overall/100 board + per-athlete stat line on a **distinct `competitionType:"benchmark"`**. **The generic authoring UI, retest-history windowing, and the hybrid Open-test class are explicitly v2** (additive, no rewrites — the data model is generic from day one). See §10. +> **Grounding:** Every architectural claim below is tied to a real file:line in `apps/wodsmith-start/src/`, re-verified against the codebase in this revision. Key decisions called out inline: the **capability-registry refactor of the ~129 `competitionType` sites (M0a chokepoints first, M0b cleanup deferred)** (owner steer — distinct `"benchmark"` type, NOT reuse `"online"`), the v1 `EventScoreInput.variant` addition plus v2 `secondaryValue` plumbing, the `scoreType`-driven direction with no `direction` column, `int`-not-`bigint` thresholds, the DB-`dq`→engine-`dnf` status mapping, and the leaner phasing. +> **Implementation packet:** the build-ready requirements, technical design, task list with acceptance criteria, test strategy, and reviewer alignment live in `ai/research/hillerfit-benchmark-leaderboard/`. Treat this guide as the narrative source and the packet as the implementation contract. Current checkout note: M4 has landed on the feature base; `"benchmark"` is registered with `videoSubmissions` and `perpetual`, the PDF-derived seed exists, absolute-tier scoring and best-to-date submission are implemented, and the generic leaderboard/stat-line demo is in place. > > **Revision 2 — what the second adversarial critique (verified against the codebase) changed.** Each was confirmed against real `file:line` before editing: > 1. **Overall/100 was double-scaled** (would have produced 700/1000, not 0–100). Fixed in §6.4; renamed the ambiguous `categoryScale`/`overallMax` to `maxTier`/`scoreMax`. @@ -11,27 +12,27 @@ > 3. **Sex variant was conflated with the division key.** `scalingLevelId` is dual-used as `divisionId` across grouping/dedupe/publish/upsert — encoding M/F there would drop or split the board. Variant now snapshots onto a new **`scores.benchmarkVariant`** column; `scalingLevelId` stays the single "Open" division (§5.1, §8.1, §13). > 4. **The `?? "male"` fallback** (silent mis-tiering) is removed — missing variant now **fails closed** (§6.2). > 5. **Live-gender re-tiering** fixed: variant is read from the snapshot, not live `user.gender`, so a profile change never re-tiers prior scores (§8.1, §13). -> 6. **The `absolute_tier` dispatch seam** is now concrete: `calculateEventPoints` gains a preloaded `ctx:{scoreType, tableByEventId}`; thresholds + a `genderByUserId` map are loaded **once** (no N+1) (§6.2, §3.2). +> 6. **The `absolute_tier` dispatch seam** is now concrete: `calculateEventPoints` gains a preloaded `ctx:{scoreType, tableByEventId}`; thresholds + a score-variant lookup from `scores.benchmarkVariant` are loaded **once** (no N+1) (§6.2, §3.2). > 7. **Countback was field-relative** (wrong for absolute tiers). Replaced with a **tier-histogram tiebreaker** (§6.5). > 8. **Benchmark results would have been invisible** (the `optInResultPublishing` capability hides every event until published). Benchmark now **auto-publishes** valid scores — capability dropped (§5.2, §7.4). -> 9. **HillerFit denominator pinned**: v1 is **"HillerFit Lite"** with the deferred tests *excluded from the denominator* via an `includedInScoring` flag; the denominator is **derived from active tests**, not a hand-maintained JSON count (§2.1, §6.4, §13). +> 9. **Training-guide denominator pinned**: v1 is a 55-test Lite benchmark with the deferred tests *excluded from the denominator* via an `includedInScoring` flag; the denominator is **derived from active tests**, not a hand-maintained JSON count (§2.1, §6.4, §13). > 10. Plus: `ScoringConfig.superRefine` (§5.2), `UNIQUE(ownerKey, slug)` null-safe key (§5.3), score-time `BenchmarkConfigError` for missing thresholds (§6.1, §13.2), `isOpenJoin` abuse guards (§8.1), per-battery `videoPolicy` + verification badge (§8.1/§8.3), M0 split into **M0a (chokepoints) / M0b (deferred cleanup)** with characterization tests (§10), Weighted C2B **recommended deferred** to v2 (§2.5), per-encoding-family fixtures (§11), and the §2.7 `ASC/DESC` seed-poison labels removed. --- ## 0. Decision sheet — confirm before M1 -**Four open forks** gate the build (D-E is now **resolved** — see below). One line each; full A-vs-B with rationale in §12 (number in parentheses). **Nothing below M1 should start until these are signed off.** +**Implementation decisions are split into fixed assumptions and source-data checks.** D-A, D-B, D-C, D-D, and D-E are treated as accepted for implementation because the guide already depends on them throughout. D-D means the first seed is derived from the supplied training PDF, not from branded page design. "HillerFit" is provenance for the local source artifact, not a WODsmith customer-facing brand or page family. | # | Decision | Recommended | One-line why | |---|----------|-------------|--------------| -| **D-A** (§12.2) | Competition type | **Distinct `competitionType:"benchmark"` behind a capability registry** (not reuse `"online"`), **split into M0a chokepoints + M0b deferred cleanup** | Keeps the standalone online product clean; a mechanical, behavior-preserving refactor of the **~129** `=== "online"`/`"in-person"` checks (verified: `competitionType` 60 / `isOnline` 55 / `isInPerson` 14 across **61 files**) → capability lookups. v1 refactors **only the chokepoints benchmark needs (M0a)**; the rest is deferred (M0b). *(Reversed per owner steer; scoped down per critique #6.)* | -| **D-B** (§12.1) | Sex axis | **Per-athlete `variant` → one unified Overall/100 board** (not Men/Women divisions) | The single unified scale *is* the product — but it's the biggest engine change (`EventScoreInput.variant`, gender threading), not a free win. | -| **D-C** (§12.5) | Windowing + retest history | **Defer windowing + per-attempt history to v2; v1 is _best-to-date_ only** (live `scores` row, kept monotonic via **keep-best-on-write**, §8.1) | `score_attempts`/`promoteBest` is the riskiest piece (sortKey needs round-level data). v1 is honestly "best-to-date," **not** "all-time": a keep-best guard makes the single live row monotonic, but without history an `invalid` action can't restore a prior best (that's v2). | -| **D-D** (§12.12) | Product gate | **Confirm HillerFit partnership + 1,160 thresholds are licensed** — else ship "post your bench" first | Whole schema is sized around 58×2×10; if the data isn't locked, M1's shape is speculative. | +| **D-A** (§12.2) ✅ fixed | Competition type | **Distinct `competitionType:"benchmark"` behind a capability registry** (not reuse `"online"`), **split into M0a chokepoints + M0b deferred cleanup** | Keeps the standalone online product clean; a mechanical, behavior-preserving refactor of the **~129** `=== "online"`/`"in-person"` checks (verified: `competitionType` 60 / `isOnline` 55 / `isInPerson` 14 across **61 files**) → capability lookups. v1 refactors **only the chokepoints benchmark needs (M0a)**; the rest is deferred (M0b). *(Reversed per owner steer; scoped down per critique #6.)* | +| **D-B** (§12.1) ✅ fixed | Sex axis | **Per-athlete `variant` → one unified Overall/100 board** (not Men/Women divisions) | The single unified scale *is* the product — but it's the biggest engine change (`EventScoreInput.variant`, gender threading), not a free win. | +| **D-C** (§12.5) ✅ fixed | Windowing + retest history | **Defer windowing + per-attempt history to v2; v1 is _best-to-date_ only** (live `scores` row, kept monotonic via **keep-best-on-write**, §8.1) | `score_attempts`/`promoteBest` is the riskiest piece (sortKey needs round-level data). v1 is honestly "best-to-date," **not** "all-time": a keep-best guard makes the single live row monotonic, but without history an `invalid` action can't restore a prior best (that's v2). | +| **D-D** (§12.12) ✅ fixed | Source-data-only branding boundary | **Use `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` as the benchmark data source only; do not build any HillerFit-branded page or product surface** | The full chart is sized around 58×2×10, while v1 scores 55 included tests. The build extracts benchmark tests/thresholds from the PDF into generic WODsmith benchmark data; any branded page, route, nav entry, logo, CTA, marketing copy, or theme is out of scope. | | **D-E** ✅ **resolved** | Gender source | **`user.gender` (strict Male/Female) from the athlete profile; required for benchmark participation** | Platform is Male/Female only (`GENDER_ENUM`, `users.ts:23`) — already an enum + profile-capture flow (`athlete-profile-fns.ts:148`). Variant = profile gender; no submit-time picker, no non-binary case. | -**If the four open forks land as recommended → v1 critical path is M0a capability registry + chokepoint refactor → M1 schema+seed → M2 `absolute_tier` algo → M3 submission → M4 leaderboard + `/stats` (the demo), then validate with a real gym before any v2 (and the M0b cleanup).** (§10.) +**v1 critical path:** M0a capability registry + chokepoint refactor → M1 schema + training-PDF-derived seed → M2 `absolute_tier` algo → M3 submission → M4 generic benchmark leaderboard + `/stats` (the demo), then validate with a real gym before any v2 (and the M0b cleanup). (§10.) --- @@ -45,17 +46,17 @@ ## 1. Executive summary -We are building a **generic, reusable "benchmark battery" product** — a self-paced, always-open fitness-rating system where an athlete tests themselves across a set of benchmarks, each test yields an absolute **tier (0 / 0.5 / 1–10)** from a fixed per-sex lookup table, and the tiers collapse to per-category attribute scores and an **Overall out of 100** ("a video-game stat line for your fitness"). **HillerFit** (4 categories, 58 tests) is the first seeded battery; a single-test "post your bench" board is the same machinery with one category and one test. +We are building a **generic, reusable "benchmark battery" product** — a self-paced, always-open fitness-rating system where an athlete tests themselves across a set of benchmarks, each test yields an absolute **tier (0 / 0.5 / 1–10)** from a fixed per-sex lookup table, and the tiers collapse to per-category attribute scores and an **Overall out of 100** ("a video-game stat line for your fitness"). The local HillerFit training guide PDF supplies the first benchmark data set (4 categories, 58 designed tests), but the shipped UI is WODsmith's generic benchmark board and stat-line experience. No HillerFit-branded page, route, nav item, logo, CTA, marketing copy, or visual theme ships in v1. A single-test "post your bench" board is the same machinery with one category and one test. **Headline decisions (decided, not open):** -1. **Reuse the online-competition runtime via a capability registry — don't fork it, don't overload `"online"`.** A battery *is* a competition (one `competitions` row, `trackWorkouts` as the 58 events, `scalingLevels` as the per-sex variant columns, `scores` + video submissions verbatim). We reuse the entire submission, verification, and live-read leaderboard stack — but through a new **competition-type capability registry** (§5.2) that benchmark *opts into*, leaving the standalone online product untouched. +1. **Reuse the online-competition runtime via a capability registry — don't fork it, don't overload `"online"`.** A battery *is* a competition (one `competitions` row, `trackWorkouts` as the tests, one Open division, `scores.benchmarkVariant` as the sex/variant snapshot, and video submissions reused verbatim). We reuse the submission, verification, and live-read leaderboard stack through a new **competition-type capability registry** (§5.2) that benchmark *opts into*, leaving the standalone online product untouched. 2. **Add an `absolute_tier` scoring algorithm**, not a parallel computor. It slots into the existing `calculateEventPoints` dispatch switch (`src/lib/scoring/algorithms/index.ts:159`). It is the *first* algorithm whose points are computed per-athlete-independently (a table lookup) rather than from field-relative ranking. 3. **Tier tables live in real DB tables, not JSON.** 58 tests × 2 sexes × 10 thresholds = 1,160 numbers that are joined and read on every leaderboard render and edited per-cell by organizers. They belong in queryable, indexable, editable tables (`benchmark_*`), not in a `settings` JSON blob. 4. **Sex is a *variant* sourced from `user.gender`**, threaded into the scoring engine as a new optional `EventScoreInput.variant` field — *not* modeled as a difficulty division. Men and women rank on one unified Overall/100 board. (This is the most consequential open decision; see §12.1. **Note:** this is *not* a free win — it is the single largest source of new engine surface; see §12.1 for the companion costs.) 5. **Perpetual via no-window; windowing is a deferred (post-v1) layer.** Submission windows are simply never seeded (the window check already allows-all when no window rows exist). The live `scores` row carries `recordedAt` (`scores.ts:87`, `NOT NULL`, indexed `idx_scores_user`), which records the athlete's **current value** for free; a **keep-best-on-write** guard (§8.1, M3) keeps that single live row monotonic so v1 is honestly **best-to-date** — **not** "all-time" (the live row holds the latest write unless the guard suppresses a worse retest). The `score_attempts` history table + `promoteBest` + 12/24-month windows + **invalidation-restore** are a **v2 layer** pulled OFF the v1 critical path (see §10) — they exist only to serve windowed/retest/audit views, which v1 does not need to validate the concept. 6. **The migration boundary:** `competitionType` and `scoringAlgorithm` widenings are **migration-free** (both are TS-only `varchar`s with no DB enum). The new `benchmark_*` tables and the two `trackWorkouts` columns **do require a real MySQL migration** (`pnpm db:generate`). `score_attempts` is a real table too but is deferred with windowing. Don't conflate the two. -7. **Benchmark is a distinct `competitionType: "benchmark"` behind a capability registry.** Today `checkSubmissionWindow` (`video-submission-fns.ts:114`) hard-rejects any `competitionType !== "online"` at `:139`, and **~129 comparison sites** across **61 files** branch on `competitionType`/`isOnline`/`isInPerson` (verified counts: 60 / 55 / 14). Rather than overload `"online"`, **M0a** introduces `competitionCan(type, capability)` (§5.2) and refactors the **chokepoint** sites benchmark v1 actually needs to capability lookups — **behavior-preserving for online/in-person** — deferring the remaining cleanup (**M0b**, §10). Benchmark then declares `{videoSubmissions, perpetual}` (**NOT** `optInResultPublishing` — that capability hides every event until the organizer publishes it, which would render a 56-event board empty; §5.2/§7.4) and works at the chokepoints without touching those sites again. See §12.2. +7. **Benchmark is a distinct `competitionType: "benchmark"` behind a capability registry.** Today `checkSubmissionWindow` (`video-submission-fns.ts:114`) hard-rejects any `competitionType !== "online"` at `:139`, and **~129 comparison sites** across **61 files** branch on `competitionType`/`isOnline`/`isInPerson` (verified counts: 60 / 55 / 14). Rather than overload `"online"`, **M0a** introduces `competitionCan(type, capability)` (§5.2) and refactors the **chokepoint** sites benchmark v1 actually needs to capability lookups — **behavior-preserving for online/in-person** — deferring the remaining cleanup (**M0b**, §10). Benchmark then declares `{videoSubmissions, perpetual}` (**NOT** `optInResultPublishing` — that capability hides every event until the organizer publishes it, which would render a 55-test board empty; §5.2/§7.4) and works at the chokepoints without touching those sites again. See §12.2. **Conflict 1 — sex axis (decided here):** Pillar 4 (experience) initially proposed modeling sex as **divisions** (zero engine change); Pillars 1 & 2 proposed modeling sex as a **variant** threaded into the engine. **We adopt the variant model** (one unified Overall/100 board, the entire HillerFit mental model). The division-based approach is recorded as the rejected alternative in §12.1. This is the costliest engine change, not a free win — it forces `EventScoreInput.variant`, `user.gender` threading, the nullable-`scalingLevelId` tier-0 trap (§13.5), and requiring gender (M/F) on the athlete profile, all as **mandatory companions** (§12.1). @@ -80,7 +81,7 @@ An **absolute** rating system. Numbers come from the past 12–24 months; the ch > ¹ Weighted C2B Pull Up recommended **deferred** to v2 (the representative-bodyweight test, §2.5/#14). If shipped in v1 with the baked constant, GYM = 14 / total = 56. ² Open 16.2 + Open 18.4 (hybrid reps-or-time) deferred to v2 (§2.5, §6.3). The `includedInScoring` flag (§5.3) drives the real count either way. - **Overall (out of `scoreMax` = 100)** = the **(weighted) mean of the category scores**. HillerFit weights are all equal, so this is `(STR + GYM + ENG + WORKOUT) ÷ 4`. With `maxTier = 10` and `scoreMax = 100`, each category score `Σtier ÷ count ÷ maxTier × scoreMax` lands on 0..100, and Overall is their mean on 0..100 — **there is no second rescale** (the earlier draft's `÷ categoryScale × overallMax` double-scaled to 1000; fixed in §6.4). -- The denominator `count` is the **count of active `includedInScoring` tests in the category**, **derived from the seeded `benchmark_tests` rows** (not a hand-maintained JSON number — §6.4/§9), and **not** the count of *submitted* tests. Untested-but-included tests contribute tier `0` to the numerator, dragging Overall down. **v1 ("HillerFit Lite") excludes the deferred tests from the denominator** so athletes are never penalized for tests they cannot take; the full-58 chart is **v1.1** once the hybrids (and Weighted C2B) ship. **`scoreMax` requires Tier 10 on every included test** — essentially unachievable. +- The denominator `count` is the **count of active `includedInScoring` tests in the category**, **derived from the seeded `benchmark_tests` rows** (not a hand-maintained JSON number — §6.4/§9), and **not** the count of *submitted* tests. Untested-but-included tests contribute tier `0` to the numerator, dragging Overall down. **v1 Lite excludes the deferred tests from the denominator** so athletes are never penalized for tests they cannot take; the full-58 chart is **v1.1** once the hybrids (and Weighted C2B) ship. **`scoreMax` requires Tier 10 on every included test** — essentially unachievable. ### 2.2 Rating bands @@ -118,10 +119,10 @@ An **absolute** rating system. Numbers come from the past 12–24 months; the ch ### 2.5 Special score shapes -- **Weighted C2B Pull Up (lb) — v1 encoding is now CONCRETE (not deferred).** T1 = `"BW"` (bodyweight), T2 = `"+5"`, then absolute added lb (25, 45, …). v1 bakes a **representative bodyweight constant per variant** at seed time, so every threshold is a plain encoded load and the test stays inside the pre-encode-everything model: +- **Weighted C2B Pull Up (lb) — DEFERRED from v1 by default.** The complete encoding design is preserved for v1.1/v2 if the owner chooses to ship it later: T1 = `"BW"` (bodyweight), T2 = `"+5"`, then absolute added lb (25, 45, …). The simple option bakes a **representative bodyweight constant per variant** at seed time, so every threshold is a plain encoded load and the test stays inside the pre-encode-everything model: - **Representative bodyweight constants:** `M = 185 lb`, `W = 145 lb` (sourced as round CrossFit-population medians; recorded as a tunable constant in `hillerfit-battery.ts`, revisit if accuracy complaints arise). - **Threshold encoding:** `"BW"` → `bodyweight_lb × 453.592 g`; `"+N"` → `(bodyweight_lb + N) × 453.592 g`; bare `"25"` → `(bodyweight_lb + 25) × 453.592 g` (HillerFit's bare numbers are *added* weight, consistent with `"+5"`). - - **Submit contract (the athlete INPUT):** the athlete enters **added weight in lb** (e.g. `+25`, or `0`/`BW` for bodyweight). The submit path encodes `(representativeBodyweight_lb + addedWeight_lb) × 453.592` and writes it as a normal `load` score. No new `parseScore`/`encodeScore` rule is needed at the engine level — the representative-bodyweight add happens in the benchmark submit wrapper before calling `encodeScore("", "load")`. The unit hint on this test is a dedicated `inputUnit: "lbs_added"` so the form knows to add the constant. + - **Submit contract if enabled later (the athlete INPUT):** the athlete enters **added weight in lb** (e.g. `+25`, or `0`/`BW` for bodyweight). The submit path encodes `(representativeBodyweight_lb + addedWeight_lb) × 453.592` and writes it as a normal `load` score. No new `parseScore`/`encodeScore` rule is needed at the engine level — the representative-bodyweight add happens in the benchmark submit wrapper before calling `encodeScore("", "load")`. The unit hint on this test is a dedicated `inputUnit: "lbs_added"` so the form knows to add the constant. - **Accepted inaccuracy (state it explicitly):** a 220 lb athlete doing strict BW C2B is **under-credited** (scored as if 185 lb), and a 150 lb athlete is over-credited. This is the one test that breaks pre-encode purity; true per-athlete bodyweight (capturing weight at submit) is the v2 upgrade (§12.8 Option B). - **⭐ Recommendation (revised) — DEFER Weighted C2B to v2 rather than ship the baked-constant version on a public board.** This is the single GYM test (1 of 14) whose scored value is *knowingly wrong by design*; capturing a visibly-unfair number on a public benchmark leaderboard is a trust cost the inaccuracy note above admits, and deferring one test (GYM → 13 included, §2.1) is as cheap as deferring the 2 hybrids. **If the owner chooses to ship it in v1**, the submit wrapper must still **capture `bodyweightLbAtAttempt` + `addedWeightLb` on the score row** even while scoring off the representative constant, so the v2 per-athlete recompute is backfillable without re-collecting data. See §12.8 (Option C) and the §13 trust risk. - **Hybrid reps-or-time (CrossFit-Open style) — DEFERRED out of v1 (see §10).** "Open 16.2 (time/reps)" and "Open 18.4 (reps/time)". Lower tiers are a **rep count** (didn't finish under cap); top tiers flip to a **finish time** (finished). Example — **Open 18.4 M:** T1..T6 = `62, 82, 103, 123, 144, 164` reps; T7..T10 = `8:30, 7:55, 6:50, 6:00` times. This is the single trickiest test class (2 of 58 tests) and is **omitted from the v1 seeded HillerFit battery** (marked "coming soon"); the schema columns that support it (`scoreModel`, `hybridFlipTier`, `hybridScale`) stay nullable so it can be added later without a migration. The full design is preserved in §6.3 for the v2 milestone, including the cap/finish status derivation and the non-monotonic best-attempt caveat. @@ -222,7 +223,7 @@ A concise map of the machinery a battery rides on. All paths under `apps/wodsmit | Add event at runtime | `server-fns/competition-workouts-fns.ts:1229` | `createWorkoutAndAddToCompetitionFn` lazily creates the track + workout + trackWorkout. | | Divisions | `db/schemas/scaling.ts:22/44` | `scalingLevelsTable` rows on a `scalingGroupsTable`; linked to the comp only via `settings.divisions.scalingGroupId`. `position 0 = hardest`, `teamSize 1 = individual`. | | Seed divisions | `server-fns/competition-divisions-fns.ts:891` | `initializeCompetitionDivisionsFn` (default: "Open"/"Scaled"). | -| Full seed example | `server-fns/demo-competition-fns.ts:343` | End-to-end: createCompetition → scaling group/levels → competitionDivisions → settings → programming track → loop workouts + trackWorkouts + scaling descriptions. **This is the template for the HillerFit seeder.** | +| Full seed example | `server-fns/demo-competition-fns.ts:343` | End-to-end: createCompetition → scaling group/levels → competitionDivisions → settings → programming track → loop workouts + trackWorkouts + scaling descriptions. **This is the template for the training-PDF-derived benchmark seeder.** | | Workout vocab | `db/schemas/workouts.ts:36/52/63` | `WORKOUT_SCHEME_VALUES` (11 schemes), `SCORE_TYPE_VALUES` (min/max/sum/average/first/last), `TIEBREAK_SCHEME_VALUES` (time/reps). | | Public routes | `routes/compete/$slug/*` | `leaderboard`, `workouts/$eventId`, `scores`, `register`, etc. `/compete/` redirects to `/`. | @@ -246,7 +247,7 @@ A concise map of the machinery a battery rides on. All paths under `apps/wodsmit | **Retest history** (current vs all-time best) | ❌ Submit overwrites in place; no athlete-value history | New `score_attempts` table; snapshot at every write seam; `promoteBest` step (must reproduce the leaderboard's `:1093` sortKey recompute, which needs round-level data — see §7.1) | **v2** | | **0.5 sub-tier** survives multiplier | ⚠️ `pointsMultiplier` `Math.round`s, can swallow 0.5 | Force multiplier = 1 for `absolute_tier` | **v1** | | **Hybrid reps-or-time** tests | ⚠️ `time-with-cap` + `secondaryValue` exist on `scoresTable`, but not on `EventScoreInput` | Split threshold block (rep tiers + time tiers); branch on `status === 'cap'`; needs the `secondaryValue` engine addition above | **v2** (deferred — 2 of 58 tests; §6.3, §10) | -| **Generic** (any gym authors a battery) | ✅ Battery = competition; tables are just rows | **v1 ships HillerFit as code-seeded data** (`hillerfit-battery.ts`); the generic tier-table **authoring UI** comes later (v2). Data model stays generic from day one | **v2** (UI); **v1** (data model) | +| **Generic** (any gym authors a battery) | ✅ Battery = competition; tables are just rows | **v1 ships a training-guide-derived benchmark as code-seeded data**; the generic tier-table **authoring UI** comes later (v2). Data model stays generic from day one | **v2** (UI); **v1** (data model) | | **Single-test** "post your bench" | ✅ Single test = 1-category 1-test battery | `isOpenJoin` auto-register on first submit (lazy-register resolving variant `scalingLevelId` from `user.gender`; §8.1) | **v1** (data model permits it; ships once submit path lands) | --- @@ -256,7 +257,7 @@ A concise map of the machinery a battery rides on. All paths under `apps/wodsmit ### 5.1 The generic model ``` -benchmark_battery (the named product — "HillerFit v1"; one row) +benchmark_battery (the source-derived generic benchmark battery; one row) ├── benchmark_category (STR / GYM / ENG / WORKOUT; weight, position) │ └── benchmark_test (one of the 58; scheme, scoreType, direction, unit) │ └── benchmark_tier_threshold (one row per (test, variant, tier) = 10 per sex) @@ -313,7 +314,7 @@ export const COMPETITION_TYPE_REGISTRY: Record = { "online": { id: "online", label: "Online", leaderboardVariant: "online", selectableOnCreate: true, capabilities: new Set(["videoSubmissions","submissionWindows","optInResultPublishing"]) }, "benchmark": { id: "benchmark", label: "Benchmark", leaderboardVariant: "online", selectableOnCreate: false, - capabilities: new Set(["videoSubmissions","perpetual"]) }, // NO optInResultPublishing — that hides every (event,division) until published (leaderboard:440-443/1082-1085); a 56-event perpetual board must be public-on-valid-submission. Also NO submissionWindows / physical / heats / check-in + capabilities: new Set(["videoSubmissions","perpetual"]) }, // NO optInResultPublishing — that hides every (event,division) until published (leaderboard:440-443/1082-1085); a 55-test v1 perpetual board must be public-on-valid-submission. Also NO submissionWindows / physical / heats / check-in } export function competitionCan(type: string, cap: CompetitionCapability): boolean { @@ -397,14 +398,14 @@ All new tables go in a net-new `src/db/schemas/benchmarks.ts`, registered via `e > **v1 migration scope:** the **3** definition tables (`benchmark_batteries`, `benchmark_tests`, `benchmark_tier_thresholds`) + the **2** `trackWorkouts` columns + **1** new column on the shared `scores` table (`benchmarkVariant`, the variant snapshot — §5.1/#3a/#24). `score_attempts` is a **v2** table (shown last, deferred with windowing). ```sql --- THE NAMED PRODUCT (HillerFit = one row; "post your bench" = one row) +-- THE NAMED BENCHMARK DATA SET (training-guide-derived benchmark = one row; "post your bench" = one row) benchmark_batteries ( id varchar PK, -- bbat_ ...commonColumns, teamId varchar NULL, -- owning gym (NULL = system/global, like sgrp_global_default) ownerKey varchar NOT NULL, -- COALESCE(teamId,'__global__') — NON-NULL so the unique key works (see note) slug varchar NOT NULL, -- public URL segment - name varchar NOT NULL, -- "HillerFit Benchmark Guide" + name varchar NOT NULL, -- e.g. "Benchmark Training Guide" description text, version int NOT NULL DEFAULT 1, -- categories + weights + per-category test counts. SCORING-CRITICAL (drives the denominator + weights, @@ -553,7 +554,7 @@ Heavy threshold numbers live in tables; `competitions.settings` carries only the | **Schema** | `competitionsTable`, `trackWorkoutsTable`, `workouts`, `programmingTracksTable`, `scoresTable`, `scalingGroups/Levels`, `video_submissions` | `benchmark_batteries`, `benchmark_tests`, `benchmark_tier_thresholds` (3 tables), 2 columns on `trackWorkouts` | `score_attempts` table | | **Config** | `settings` JSON parse/stringify, `getEffectiveScoringConfig` | `absolute_tier` enum value + `absoluteTier` config block; `benchmark.schema.ts` validation (**no** new `competitionType`) | — | | **Routes/UI** | online leaderboard table, `VideoSubmissionForm`, review pages, organizer create/divisions/events flow | `/compete/$slug/stats`, ``, Stats tab | tier-table authoring editor; window/mode toggles | -| **Seed** | `demo-competition-fns.ts:343` flow as template | `src/lib/seed/hillerfit-battery.ts` (data) + idempotent seeder fn | generic battery-publish flow | +| **Seed** | `demo-competition-fns.ts:343` flow as template | training-PDF-derived seed module (data) + idempotent seeder fn | generic battery-publish flow | --- @@ -729,7 +730,7 @@ Inserted as a new pass over `leaderboardMap.values()` **immediately before `cons const battery = await loadBattery(scoringConfig.absoluteTier.batteryId) // throws if config/categories invalid (§5.1) // DENOMINATOR is DERIVED from active included tests, NOT the JSON testCount (a validated cache only, // §9/#9): loadBattery returns activeTestCountByCategory = count(benchmark_tests WHERE includedInScoring -// AND categoryKey = c.key). This is what makes "HillerFit Lite" (deferred tests excluded) honest (§2.1/#7). +// AND categoryKey = c.key). This is what makes the v1 Lite seed (deferred tests excluded) honest (§2.1/#7). for (const entry of leaderboardMap.values()) { const sums: Record = {} for (const er of entry.eventResults) { @@ -838,7 +839,7 @@ The selected attempt set becomes the **source** of `EventScoreInput[]` — the a A perpetual board is `competitionType: "benchmark"`, which **declares the `videoSubmissions` capability but NOT `submissionWindows`** (§5.2). After the M0a refactor, `checkSubmissionWindow`'s gate is `competitionCan(type, "videoSubmissions")` → passes; and because benchmark lacks `submissionWindows`, no window rows are ever seeded, so the no-window allow-all (`:161–:169`) returns allowed unconditionally. The `perpetual` capability (and `boardMode: 'perpetual'` in settings) disables any organizer UI that would create windows. -> **Result visibility — benchmark results are PUBLIC on valid submission, NOT publish-gated (#15).** The publish-gate default at `competition-leaderboard.ts:440–443` yields `divisionResults = {}` (hide-until-published) **only for `competitionType === "online"`**. Benchmark deliberately **does not declare `optInResultPublishing`** (§5.2), so its default is `undefined` → the `:1082–:1085` per-(event,division) publish filter is skipped → every non-`invalid` score is visible immediately. This is essential: a perpetual 56-event self-serve board must never require the organizer to publish 56 events before anything shows. Organizer moderation still works via the existing `invalid` verification action (excluded at `:248`), not via the publish gate. +> **Result visibility — benchmark results are PUBLIC on valid submission, NOT publish-gated (#15).** The publish-gate default at `competition-leaderboard.ts:440–443` yields `divisionResults = {}` (hide-until-published) **only for `competitionType === "online"`**. Benchmark deliberately **does not declare `optInResultPublishing`** (§5.2), so its default is `undefined` → the `:1082–:1085` per-(event,division) publish filter is skipped → every non-`invalid` score is visible immediately. This is essential: a perpetual 55-test v1 self-serve board must never require the organizer to publish every test before anything shows. Organizer moderation still works via the existing `invalid` verification action (excluded at `:248`), not via the publish gate. ### 7.5 Do NOT reuse Series @@ -882,10 +883,10 @@ Series (`series-leaderboard.ts`) aggregates the **same workout across different ### 8.5 Generic gym authoring + single-test flow -> **v1 ships HillerFit (and "post your bench") as code-seeded data modules — the generic authoring UI / tier-table editor is v2.** The data model is fully generic from day one (batteries/tests/thresholds are just rows), so the editor is additive-later, not a rewrite. Building a per-cell editor for 1,160 cells before the concept is validated is the time-sink the product critique flagged. +> **v1 ships the training-PDF-derived benchmark (and "post your bench") as code-seeded data modules — the generic authoring UI / tier-table editor is v2.** The data model is fully generic from day one (batteries/tests/thresholds are just rows), so the editor is additive-later, not a rewrite. Building a per-cell editor for 1,160 cells before the concept is validated is the time-sink the product critique flagged. - **Battery = online-comp organizer flow reused wholesale** (for the comp shell): `OrganizerCompetitionForm` → `createCompetitionFn` (as `competitionType: "benchmark"`); divisions via `initializeCompetitionDivisionsFn` (seed a single "Open" group); events via `createWorkoutAndAddToCompetitionFn`. Benchmark inherits the right feature set automatically from the capability registry (§5.2). -- **HillerFit** ships as a seeded battery (mirror `demo-competition-fns.ts`): 58 trackWorkouts tagged with `benchmarkTestId` + `benchmarkCategory`, one "Open" division, the battery + tests + thresholds rows. Each test carries the correct `scheme` + `scoreType` (the latter encodes direction — §6.1). +- **Training-guide-derived benchmark** ships as a seeded battery (mirror `demo-competition-fns.ts`): 58 designed trackWorkouts tagged with `benchmarkTestId` + `benchmarkCategory`, one "Open" division, the battery + tests + thresholds rows. The seed extracts its test and threshold data from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. Each test carries the correct `scheme` + `scoreType` (the latter encodes direction — §6.1). It does **not** create any HillerFit-branded page, route, navigation item, marketing surface, logo, call to action, or theme. - **"Post your bench"** = same machinery, 1 event, `isOpenJoin = true`, one-column board. Also seeded as a data module in v1. - **v2 — NEW scoring panel** (extend `scoring-settings-form.tsx` at `/compete/organizer/$competitionId/scoring.tsx`): a **tier-table editor** that takes raw values per (test, sex, tier) and **encodes them through `encode/*` before INSERT** into `benchmark_tier_thresholds`. The editor exposes a **"higher value is better / lower value is better" toggle per test** (the mental model organizers actually have) and **derives `scoreType`** from it (`better=higher → max`, `better=lower → min`), then **validates against `getSortDirection(scheme, scoreType)`** so an impossible combination — a load test marked lower-better, or a `sum`/`average`/`first`/`last` scoreType that falls through to the scheme default — is rejected at author time (#19). No raw `scoreType` picker, no `tierDirection` column (direction stays derived). Gated on validated demand for gym-authored batteries. @@ -906,11 +907,11 @@ Series (`series-leaderboard.ts`) aggregates the **same workout across different ## 9. Genericity & reuse -The HillerFit-specific content is **data, not code**: +The training-guide-specific content is **data, not code or branded UI**: -> **The generic vision is preserved in full — it is just phased.** v1 ships HillerFit-shaped (code-seeded data + the absolute-tier engine); the *authoring UI* that lets any gym build a battery is v2. Because every HillerFit-specific thing is a **row**, not code, "any gym authors a battery" needs no data-model change later — only the editor UI. None of the v1 cuts are rewrites. +> **The generic vision is preserved in full — it is just phased.** v1 ships a training-guide-derived seed (data + the absolute-tier engine); the *authoring UI* that lets any gym build a battery is v2. Because every source-specific thing is a **row**, not code or branded UI, "any gym authors a battery" needs no data-model change later — only the editor UI. None of the v1 cuts are rewrites. -- **HillerFit is one `benchmark_batteries` row.** Categories, test counts, weights, and rating bands are JSON columns on that row. The (v1) **55 included** seeded tests are `benchmark_tests` rows (the 2 hybrid Open tests + Weighted C2B are v2 "coming soon", carried with `includedInScoring = false` so they are **excluded from the denominator** — §2.1/#7); the v1 thresholds (10 × 2 sexes per included test, ~1,100) are `benchmark_tier_thresholds` rows (the full-58 chart is ~1,160 at v1.1). The **denominator is derived from `count(active includedInScoring tests)`**, so enabling a deferred test later is a data change, not a math change (#9). **Nothing about "HillerFit" is hard-coded** beyond the seed module `src/lib/seed/hillerfit-battery.ts` (itself just data + an idempotent seeder). +- **The training-guide benchmark is one `benchmark_batteries` row.** Categories, test counts, weights, and rating bands are JSON columns on that row. The (v1) **55 included** seeded tests are `benchmark_tests` rows (the 2 hybrid Open tests + Weighted C2B are v2 "coming soon", carried with `includedInScoring = false` so they are **excluded from the denominator** — §2.1/#7); the v1 thresholds (10 × 2 sexes per included test, ~1,100) are `benchmark_tier_thresholds` rows (the full-58 chart is ~1,160 at v1.1). The **denominator is derived from `count(active includedInScoring tests)`**, so enabling a deferred test later is a data change, not a math change (#9). **Nothing about the PDF source is hard-coded into routes/pages** beyond the seed module (itself just data + an idempotent seeder). - **Any gym authors a battery** (v2) through the organizer flow + the tier-table editor. They pick categories, add tests (choosing `scheme` + `scoreType` from the existing workout vocabulary — `scoreType` sets the tier direction, §6.1), and fill the tier grid. - **Configurable per battery:** number of categories, test counts (a validated cache; the real denominator is derived, §6.4/#9), category weights (`weight` in the categories JSON, now applied as a weighted mean — §6.4), **`maxTier`** (the per-test tier ceiling) and **`scoreMax`** (the 0..100 Overall ceiling) — renamed from the ambiguous `categoryScale`/`overallMax` (#1), rating bands, `isOpenJoin`, **`videoPolicy`**, variant set (the scaling group), and `boardMode`. - **Single-test "post your bench"** is the degenerate case: 1 category, 1 test, `isOpenJoin = true`. Overall == category score == `tier ÷ maxTier × scoreMax`. **Zero special-case branches** — the same (weighted) aggregation math collapses correctly. @@ -922,22 +923,22 @@ The HillerFit-specific content is **data, not code**: **Phasing principle (revised):** approve the architecture, lean the phasing. The thesis (reuse the online runtime, add one `absolute_tier` algorithm) is sound; the earlier plan put two **v2-grade** concerns — perpetual *windowing* (`score_attempts`/`promoteBest`) and the *generic authoring UI* — on the v1 critical path, plus baked the *hybrid* test class (2 of 58) into the core algorithm. We pull all three off the v1 path. Every cut is **additive-later, not a rewrite** — the data model stays generic, the schema columns stay nullable. The goal is a demoable "video-game stat line for your fitness" in front of a real gym in ~2-3 milestones instead of 6. -> **Revision 2 further shrinks the v1 surface (critique #18 — "v1 is still big").** M0 is split so only chokepoints ship first (M0a; the ~100-site cleanup is M0b, post-demo); Weighted C2B is deferred (one fewer special case); the denominator is derived (no hand-maintained counts); and results auto-publish (no per-event publishing). If even HillerFit's 55-test seed feels heavy before the concept is proven, the **leanest validation slice is the single-test "post your bench" battery** (§12.12) — same machinery, one owner-authored test, a stat line in front of users fastest. +> **Revision 2 further shrinks the v1 surface (critique #18 — "v1 is still big").** M0 is split so only chokepoints ship first (M0a; the ~100-site cleanup is M0b, post-demo); Weighted C2B is deferred (one fewer special case); the denominator is derived (no hand-maintained counts); and results auto-publish (no per-event publishing). If even the 55-test training-guide seed feels heavy before the concept is proven, the **leanest validation slice is the single-test "post your bench" battery** (§12.12) — same machinery, one owner-authored test, a stat line in front of users fastest. Each milestone is independently mergeable. **DB workflow:** `pnpm db:push` to apply schema to the dev branch during development; `pnpm db:generate --name=` to generate migrations only before merging to main. The **3** `benchmark_*` definition tables, the two `trackWorkouts` columns, **and the new `scores.benchmarkVariant` column** **require a real migration**; the capability registry, the `"benchmark"` `competitionType` value, and the `"absolute_tier"` `scoringAlgorithm` value are TS-only and need **no** migration. `score_attempts` is a separate later migration (V2A). -### v1 — capability refactor first, then the thinnest stat-line slice (HillerFit-shaped, best-to-date only) +### v1 — capability refactor first, then the thinnest stat-line slice (training-guide-derived, best-to-date only) | M | Milestone | Files touched | Migration? | Effort | |---|-----------|---------------|------------|--------| -| **M0a** | **Capability registry + CHOKEPOINT refactor (foundational; ships on its own)** — `lib/competitions/capabilities.ts` (registry + `competitionCan`/`leaderboardVariant`); refactor ONLY the sites benchmark v1 needs: submission gate (`video-submission-fns.ts:139` + score/video/window-status API routes), publish-gate default (`competition-leaderboard.ts:443`), leaderboard-variant selector, route/tab visibility. Behavior-preserving for online/in-person. **Two test layers:** registry truth-table snapshot **+ characterization tests** on the real chokepoint routes/server-fns (benchmark passes gate; results visible w/o publish; organizer-vs-cohost parity; `scoringAlgorithm==="online"` untouched). Leaves the `scoringAlgorithm`-axis sites alone. Full spec: `ai/research/m0-competition-type-capability-registry.md`. | `lib/competitions/capabilities.ts`, ~10–15 chokepoint files | No | M | +| **M0a** | **Extend existing capability registry + benchmark chokepoint refactor (foundational; ships on its own)** — the current checkout already has `lib/competitions/capabilities.ts` with `competitionCan`/`leaderboardVariant` for `"in-person"`/`"online"` and tests that make `"benchmark"` fail closed. Extend that registry with `"benchmark"` and `perpetual`, then refactor/verify ONLY the sites benchmark v1 needs: submission gate (`video-submission-fns.ts` + score/video/window-status API routes), publish-gate default (`competition-leaderboard.ts`), leaderboard-variant selector, route/tab visibility. Behavior-preserving for online/in-person. **Two test layers:** updated registry truth-table snapshot **+ characterization tests** on the real chokepoint routes/server-fns (benchmark passes the video-submission gate; benchmark does not use submission windows; results visible w/o publish; organizer-vs-cohost parity; `scoringAlgorithm==="online"` untouched). Leaves the `scoringAlgorithm`-axis sites alone. Full spec: `ai/research/m0-competition-type-capability-registry.md`. | `lib/competitions/capabilities.ts`, current chokepoint files | No | M | | **M0b** | **Deferred cleanup (after the demo)** — refactor the remaining ~100 `competitionType`/`isOnline`/`isInPerson` sites (heats, check-in, venue, volunteers, results-entry model, sidebar labels) opportunistically. Until done they keep their literal checks; benchmark (neither type) correctly leaves those features hidden. Not on the demo critical path. | ~45–50 route/component files (mechanical) | No | L | -| **M1** | **Register the `"benchmark"` type + schema + seed (3 tables + 1 scores column, no `score_attempts`)** — add `"benchmark"` to `COMPETITION_TYPES` + the `$type` union + a registry entry declaring `{videoSubmissions, perpetual}` (**NOT** `optInResultPublishing`); `benchmarks.ts` (3 tables with `ownerKey` unique, `maxTier`/`scoreMax`, `videoPolicy`, `includedInScoring`), ULID factories, `trackWorkouts.benchmarkTestId`/`benchmarkCategory` + **`scores.benchmarkVariant`**; `benchmark.schema.ts` (Zod: thresholds `z.array().length(10)`, strict fail-closed `categoriesSchema`, `scoringConfig.superRefine`); `hillerfit-battery.ts` data (**55 included** tests; hybrids + Weighted C2B `includedInScoring=false`) + idempotent seeder publishing a `competitionType:"benchmark"` competition with results auto-visible. | `db/schemas/competitions.ts`, `lib/competitions/capabilities.ts`, `db/schemas/benchmarks.ts`, `db/schemas/common.ts`, `db/schemas/programming.ts`, `db/schemas/scores.ts`, `db/schema.ts`, `schemas/benchmark.schema.ts`, `schemas/scoring.schema.ts`, `lib/seed/hillerfit-battery.ts` | **Yes** (`--name=benchmark-battery`) | M | +| **M1** | **Register the `"benchmark"` type + schema + PDF-derived seed (3 tables + 1 scores column, no `score_attempts`)** — add `"benchmark"` to `COMPETITION_TYPES` + the `$type` union + a registry entry declaring `{videoSubmissions, perpetual}` (**NOT** `optInResultPublishing`); `benchmarks.ts` (3 tables with `ownerKey` unique, `maxTier`/`scoreMax`, `videoPolicy`, `includedInScoring`), ULID factories, `trackWorkouts.benchmarkTestId`/`benchmarkCategory` + **`scores.benchmarkVariant`**; `benchmark.schema.ts` (Zod: thresholds `z.array().length(10)`, strict fail-closed `categoriesSchema`, `scoringConfig.superRefine`); seed data derived from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` (**55 included** tests; hybrids + Weighted C2B `includedInScoring=false`) + idempotent seeder publishing a generic `competitionType:"benchmark"` competition with results auto-visible. No HillerFit-branded pages, routes, navigation, marketing copy, logos, calls to action, or visual theme. | `db/schemas/competitions.ts`, `lib/competitions/capabilities.ts`, `db/schemas/benchmarks.ts`, `db/schemas/common.ts`, `db/schemas/programming.ts`, `db/schemas/scores.ts`, `db/schema.ts`, `schemas/benchmark.schema.ts`, `schemas/scoring.schema.ts`, `lib/seed/*benchmark*` | **Yes** (`--name=benchmark-battery`) | M | | **M2** | **Absolute-tier algorithm (standard only)** — `absolute-tier.ts` (standard tiers + `scoreType`-driven direction; **fail-closed on missing variant/table** via `BenchmarkConfigError`; **no hybrid**); `category-aggregation.ts` (weighted mean, **no double-scale**, derived denominator); `absolute_tier` enum + config block + `superRefine`; **widen `calculateEventPoints` with `ctx:{scoreType, tableByEventId}`**; both dispatch switches (`:182`, `:660`) + un-guarded label fn (`:669`); `EventScoreInput.variant`; **benchmark tier-histogram tiebreaker**. Unit tests: tier 0/0.5/1–10; direction cases (hold `time`+`max`, watts `points`+`max`, run `time`+`min`); **Overall not double-scaled** (all tiers 7 ⇒ 70 not 700); weighted aggregation untested=0; half-tier survives `formatPoints`; null-variant fails closed; **one encode fixture per family** (load, time-min, meters, feet, reps, points, rounds-reps, BW-add). | `lib/scoring/algorithms/absolute-tier.ts`, `lib/scoring/category-aggregation.ts`, `lib/scoring/algorithms/index.ts`, `lib/scoring/tiebreakers.ts` (or new benchmark tiebreaker), `schemas/scoring.schema.ts` | No | M | | **M3** | **Submission (best-to-date, no history)** — snapshot `scores.benchmarkVariant = user.gender` + write Open-division `scalingLevelId` (require gender on profile); **keep-best-on-write** guard (overwrite only when the new tier/raw is better); **guarded** `isOpenJoin` lazy register (published+visible, profile/waiver, transactional individual team, idempotent, rate-limited); verification reset on changed re-submit; (optional) BW-pullup wrapper if not deferred. **No `score_attempts`/`promoteBest`** (invalidation reverts to tier-0 in v1). | `server-fns/video-submission-fns.ts`, `server-fns/submission-verification-fns.ts` (reset on adjust) | No | M | -| **M4** | **Leaderboard + stat-line page (THE DEMO)** — build `genderByUserId`/variant **Map** + `loadAllBenchmarkThresholds` ONCE before the loop (no N+1); variant from `scores.benchmarkVariant` snapshot at `:1102–:1144`; attach `benchmarkCategory` onto each `eventResult`; multiplier=1 **and skip `Math.round`** at `:1153`/`:1327`; category pass before `:1470`; rank-by-Overall at `:1486`; tier-histogram tiebreaker; **results auto-visible** (no publish gate); verification badges; new entry fields; `/stats` route + `` (untested vs attempted vs unavailable); Stats tab; benchmark-flavored copy. Best-to-date only. | `server/competition-leaderboard.ts`, `server-fns/leaderboard-fns.ts`, `routes/compete/$slug/stats.tsx`, `components/compete/benchmark-stat-line.tsx`, `components/competition-tabs.tsx` | No | L | +| **M4** | **Leaderboard + stat-line page (THE DEMO)** — build a score/variant lookup from `scores.benchmarkVariant` + `loadAllBenchmarkThresholds` ONCE before the loop (no N+1); variant from `scores.benchmarkVariant` snapshot at `:1102–:1144`; attach `benchmarkCategory` onto each `eventResult`; multiplier=1 **and skip `Math.round`** at `:1153`/`:1327`; category pass before `:1470`; rank-by-Overall at `:1486`; tier-histogram tiebreaker; **results auto-visible** (no publish gate); verification badges; new entry fields; `/stats` route + `` (untested vs attempted vs unavailable); Stats tab; benchmark-flavored copy. Best-to-date only. | `server/competition-leaderboard.ts`, `server-fns/leaderboard-fns.ts`, `routes/compete/$slug/stats.tsx`, `components/compete/benchmark-stat-line.tsx`, `components/competition-tabs.tsx` | No | L | -**v1 is demoable at the end of M4** — a public, perpetual, absolute-scored Overall/100 board + per-athlete stat line for the seeded HillerFit battery. **Then validate with a real gym before building any of v2.** +**v1 is demoable at the end of M4** — a public, perpetual, absolute-scored Overall/100 board + per-athlete stat line for the seeded PDF-derived benchmark. **Then validate with a real gym before building any of v2.** ### v2 — only after validation (additive, no rewrites) @@ -970,12 +971,12 @@ Per the project's required post-task checklist (`lat check` must pass, update `l ## 12. Open decisions for review (A vs B, with recommendation) -> **Owner must confirm the starred forks.** The critics surfaced two re-ranked decisions (#2 reversed, #5 deferred) and two product gates (validation + data licensing) that need an explicit yes before M1. +> **No product fork is open for v1.** The owner clarified that the first seed is built against the local training PDF and that HillerFit-branded pages are out of scope. The remaining choices below are recorded for traceability and v2 planning. 1. **Sex axis — variant vs division.** **A (recommended):** Sex is a per-athlete **variant** sourced from the required M/F `user.gender` profile field, threaded into `EventScoreInput.variant`; all athletes sit in one "Open" division and rank on **one unified Overall/100 board**. **B (rejected):** Seed "Men"/"Women" **divisions**; sex rides existing `scalingLevelId` plumbing with zero engine change, but the board **splits** by sex. - *Recommendation: A* — but **state the cost honestly:** A is the **single largest source of new engine surface**, not a free win. It forces `EventScoreInput.variant`, `user.gender` threading, the nullable-`scalingLevelId` tier-0 trap (§13.5), and requiring gender (M/F) on the athlete profile — all **mandatory companions** (modest, since `user.gender` already exists as an enum + capture flow). HillerFit is one absolute 0–100 scale for everyone; the unified board is the product, so A still wins. B is the simpler-plumbing fallback if split boards are ever wanted. + *Recommendation: A* — but **state the cost honestly:** A is the **single largest source of new engine surface**, not a free win. It forces `EventScoreInput.variant`, `user.gender` threading, the nullable-`scalingLevelId` tier-0 trap (§13.5), and requiring gender (M/F) on the athlete profile — all **mandatory companions** (modest, since `user.gender` already exists as an enum + capture flow). The training guide's score model is one absolute 0–100 scale for everyone; the unified board is the product, so A still wins. B is the simpler-plumbing fallback if split boards are ever wanted. 2. **✅ RESOLVED (owner steer) — competition type: distinct `"benchmark"` behind a capability registry, NOT reuse `"online"`.** **A (chosen):** Add `competitionType: "benchmark"` and a **capability registry** (`competitionCan(type, capability)`, §5.2). Refactor the **~129** `competitionType`/`isOnline`/`isInPerson` sites (61 files) to capability lookups — behavior-preserving for online/in-person — **in two tranches: M0a chokepoints (v1) + M0b cleanup (deferred, post-demo)** to bound blast radius (#6). Benchmark declares `{videoSubmissions, perpetual}` — **NOT `optInResultPublishing`** (that would hide every event until published; benchmark results are public on valid submission, #15). Foundational **M0a**. @@ -1025,7 +1026,7 @@ Per the project's required post-task checklist (`lat check` must pass, update `l 11. **✅ RESOLVED — gender source.** The platform is **Male/Female only** (`GENDER_ENUM` = `{MALE, FEMALE}`, `users.ts:23`), so there is no non-binary case. Variant = `user.gender` (an indexed enum already captured via the athlete profile, `athlete-profile-fns.ts:148`). Decision: **require `user.gender` on the athlete profile before benchmark participation** (one-field prompt if missing); no per-submission sex picker. This removes the former tier-0-on-missing-variant risk at the source. -12. **⭐ NEW — product gate: is the HillerFit partnership + the 1,160 threshold numbers actually committed and licensed?** The entire schema is sized around 58×2×10. If the brand/data is not locked, M1's shape is speculative. **Confirm before M1:** (a) HillerFit has agreed; (b) the threshold numbers exist and are sourced/licensed; (c) the representative-bodyweight constants (#8) are signed off. If not locked, ship the **single-test "post your bench"** degenerate battery first (same machinery, owner-authored data) to validate the stat-line concept independent of the HillerFit data. +12. **✅ RESOLVED — source data and branding boundary.** The local `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` is the source artifact for the first benchmark seed. The full chart is sized around 58×2×10, while v1 scores 55 tests and defers Weighted C2B plus the 2 hybrid Open tests. **Implementation rule:** extract tests/thresholds from the PDF into generic WODsmith benchmark rows; do **not** build any HillerFit-branded page, route, tab, stats page, product navigation item, marketing surface, logo, call to action, theme treatment, or customer-facing product area. If PDF extraction reveals missing or ambiguous threshold data, ask the owner about the data gap rather than inventing a branded product fork. --- @@ -1067,9 +1068,9 @@ Per the project's required post-task checklist (`lat check` must pass, update `l 17. **Tiebreak must be tier-histogram, not field-relative countback (#5).** Synthesized per-event ranks are field-relative, so `applyCountback` (`tiebreakers.ts:213–276`) would rank Tier-7-on-a-weak-event above Tier-9-on-a-strong-event. Use the tier-histogram tiebreaker fed by `eventResult.tier` (§6.5). -18. **Preload to avoid N+1 / O(n²) (#13).** Build `genderByUserId`/variant as a `Map` and `loadAllBenchmarkThresholds(trackWorkoutIds)` **once** before the per-event loop; never `filteredRegistrations.find(...)` per score or query thresholds per event/division (§6.2, §3.3). +18. **Preload to avoid N+1 / O(n²) (#13).** Build a score-id/user-id to `scores.benchmarkVariant` lookup and `loadAllBenchmarkThresholds(trackWorkoutIds)` **once** before the per-event loop; never `filteredRegistrations.find(...)` per score or query thresholds per event/division (§6.2, §3.3). -19. **Benchmark results must be public on submission, not publish-gated (#15).** The `optInResultPublishing` capability (online-only, `:440–443`) hides every (event,division) until published — for a 56-event perpetual board that is an empty board. Benchmark does NOT declare it, so the default is `undefined` and the `:1082` filter is skipped (§7.4). +19. **Benchmark results must be public on submission, not publish-gated (#15).** The `optInResultPublishing` capability (online-only, `:440–443`) hides every (event,division) until published — for a 55-test v1 perpetual board that is an empty board. Benchmark does NOT declare it, so the default is `undefined` and the `:1082` filter is skipped (§7.4). 20. **`UNIQUE(teamId, slug)` does not enforce global slug uniqueness (#21).** MySQL treats each NULL as distinct, so multiple global (`teamId IS NULL`) batteries could share a public slug. Use the non-null `ownerKey` sentinel (`COALESCE(teamId,'__global__')`) + app-level validation (§5.3). diff --git a/ai/research/hillerfit-benchmark-leaderboard/README.md b/ai/research/hillerfit-benchmark-leaderboard/README.md new file mode 100644 index 000000000..8a319503d --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/README.md @@ -0,0 +1,25 @@ +# Generic Benchmark Leaderboard Implementation Packet + +This packet turns the benchmark leaderboard guide into implementation-ready contracts. + +Source narrative: [`../hillerfit-benchmark-leaderboard-guide.md`](../hillerfit-benchmark-leaderboard-guide.md). + +## Artifact Map + +| Artifact | Purpose | +| --- | --- | +| [`requirements.md`](requirements.md) | Product requirements, fixed assumptions, external gates, and v1/v2 scope. | +| [`technical-design.md`](technical-design.md) | Components, data model, dependencies, write/read flows, and failure behavior. | +| [`tasks.md`](tasks.md) | Ordered implementation tasks with acceptance criteria and explicit dependencies. | +| [`test-strategy.md`](test-strategy.md) | Unit, integration, route, migration, LAT, and manual verification coverage. | +| [`traceability.md`](traceability.md) | Matrix linking guide sections to requirements, design, tasks, and tests. | +| [`assumptions-and-decisions.md`](assumptions-and-decisions.md) | Accepted decisions, deferred decisions, rejected alternatives, and the remaining user gate. | +| [`reviewer-alignment.md`](reviewer-alignment.md) | Independent reviewer summaries and convergence notes. | + +## Build Rule: Source Data, Not Branded UI + +The supplied training PDF is a benchmark seed-data source only. "HillerFit" is allowed as provenance in research notes, seed receipts, and extraction metadata, but it is not a customer-facing product surface. Do not build any HillerFit-branded page, route, tab, product navigation item, marketing copy, logo, call to action, theme treatment, or other branded UI; the shipped UI remains WODsmith's generic benchmark board/stat-line experience. + +Non-goal: no HillerFit-branded pages, routes, tabs, navigation, stats pages, marketing surfaces, logos, calls to action, theme treatments, or customer-facing product areas. + +Do not fork product behavior without an owner decision. In particular, do not switch v1 back to all-time history, Men/Women divisions, `competitionType: "online"`, publish-gated benchmark results, branded UI, or baked-constant Weighted C2B unless the decision sheet is updated first. diff --git a/ai/research/hillerfit-benchmark-leaderboard/architecture-walkthrough.html b/ai/research/hillerfit-benchmark-leaderboard/architecture-walkthrough.html new file mode 100644 index 000000000..c797c88be --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/architecture-walkthrough.html @@ -0,0 +1,1018 @@ + + + + + + feat/hillerfit-plan Architecture Walkthrough + + + +
+
+
Branch architecture review
+

Benchmark board architecture: before and after

+

+ This walkthrough summarizes the architectural delta in + feat/hillerfit-plan: how the branch adds a generic + benchmark competition type without forking the public competition + shell, video-submission path, or leaderboard pipeline. +

+ +
+
+
Branch
+
feat/hillerfit-plan
+
+
+
Base
+
bd52abbfdbe
+
+
+
Head
+
7e48a814
+
+
+
Committed diff
+
121 files, +29,254 / -757
+
+
+ +
+
Working tree note
+

+ The diagrams focus on the committed benchmark architecture. The + current working tree also has an uncommitted multi-instance local + dev change (pnpm dev:multi, portless, and + a per-instance TanStack devtools event-bus port), plus an untracked + apps/wodsmith-start/HillerFit_Training_Guide.pdf. +

+
+
+
+ + + +
+
+

Summary

+
+
+

Before this branch

+

+ WODsmith supported in-person and + online competitions through a shared competition + shell. Online competitions used video submissions, submission + windows, and the online leaderboard variant. There was no + reusable benchmark battery model, no athlete profile variant + snapshot on scores, and no fixed threshold scoring algorithm. +

+
+
+

After this branch

+

+ The branch introduces benchmark as a registered + competition type with videoSubmissions, + perpetual, and + benchmarkScoringTiers. It reuses the existing + competition shell, submission path, and leaderboard path, while + adding benchmark batteries, tests, thresholds, absolute-tier + scoring, category aggregation, and a generic stats page. +

+
+
+ +
+ new competition type: benchmark + perpetual submissions + absolute_tier scoring + Overall/100 leaderboard + source PDF is provenance only + one Open division + best-to-date score retention +
+
+ +
+

Before / After Topology

+
+
Before: online and in-person shell
+
+graph LR
+  Forms["Organizer create/edit forms"] --> Registry["Capability registry: in-person, online"]
+  Registry --> Routes["Public + organizer routes"]
+  Routes --> Submissions["Video submission server fns"]
+  Routes --> Leaderboard["competition-leaderboard.ts"]
+  Submissions --> Scores["scores + video_submissions"]
+  Leaderboard --> Algorithms["traditional / p_score / online / custom"]
+  Algorithms --> Tables["standard or online leaderboard table"]
+
+  Registry -. no benchmark type .-> MissingA["No perpetual benchmark board"]
+  Algorithms -. no threshold context .-> MissingB["No fixed battery scoring"]
+  Scores -. no variant snapshot .-> MissingC["No male/female threshold variant"]
+          
+
+ +
+
After: benchmark added through existing seams
+
+graph LR
+  Registry["Competition type registry"] --> BenchmarkType["benchmark capabilities"]
+  BenchmarkType --> PublicRoutes["Competition shell + stats tab"]
+  BenchmarkType --> OrganizerRoutes["Scoring tiers route"]
+  BenchmarkType --> Submissions["submitVideoFn benchmark wrapper"]
+  BenchmarkType --> Leaderboard["getCompetitionLeaderboard"]
+
+  Seed["Training guide seed"] --> Battery["benchmark_batteries"]
+  Seed --> Tests["benchmark_tests"]
+  Seed --> Thresholds["benchmark_tier_thresholds"]
+  Seed --> TrackWorkouts["track_workouts benchmarkTestId/category"]
+
+  Submissions --> Variant["getBenchmarkProfileVariant"]
+  Submissions --> Best["saveBenchmarkScoreInTransaction"]
+  Variant --> Scores["scores.benchmarkVariant"]
+  Best --> Scores
+
+  Leaderboard --> Context["loadBenchmarkLeaderboardContext"]
+  Context --> Battery
+  Context --> Tests
+  Context --> Thresholds
+  Context --> TrackWorkouts
+  Context --> AbsoluteTier["calculateAbsoluteTierEventPoints"]
+  AbsoluteTier --> Aggregate["aggregateBenchmarkScores"]
+  Aggregate --> UI["Online table + BenchmarkStatLine"]
+
+  OrganizerRoutes --> TierFns["benchmark-scoring-tier-fns"]
+  TierFns --> TierServer["benchmark-scoring-tiers.ts"]
+  TierServer --> Thresholds
+  TierServer --> ScoringConfig["competition.settings.scoringConfig"]
+          
+
+
+ +
+

Module Map

+
+
+

Capability registry

+

+ capabilities.ts is still the source of truth. The + branch adds benchmark with online leaderboard visuals, + perpetual submission semantics, and benchmark-tier controls. +

+
+
+

Schema package

+

+ @repo/wodsmith-db now exports benchmark batteries, + tests, and threshold tables. Shared tables get narrow nullable + benchmark columns instead of a separate competition subsystem. +

+
+
+

Seed data

+

+ buildBenchmarkSeedRows creates one generic benchmark + competition, one Open division, 58 test rows, 55 scoring tests, + rating bands, and 1,100 threshold rows. +

+
+
+

Scoring engine

+

+ absolute_tier plugs into + calculateEventPoints. It requires preloaded + threshold context and returns exact tiers rather than placement + points. +

+
+
+

Submission path

+

+ The existing submitVideoFn detects benchmark context + and delegates benchmark writes to a transaction helper, preserving + normal video behavior for online competitions. +

+
+
+

Leaderboard path

+

+ getCompetitionLeaderboard loads benchmark context + only for absolute_tier, aggregates category scores, + sets Overall/100 as total points, and applies tier-histogram + tiebreaks. +

+
+
+

Organizer controls

+

+ /scoring/tiers is guarded by the capability registry + and team permissions. It edits raw thresholds and switches a + benchmark board between online and absolute-tier scoring. +

+
+
+

Public UI

+

+ The generic online leaderboard table renders benchmark-specific + columns when the scoring algorithm is absolute_tier. + A Stats tab appears only for that algorithm. +

+
+
+

Validation layer

+

+ Zod schemas validate categories, rating bands, variants, threshold + counts, and scoring config shape before read/write paths consume + benchmark data. +

+
+
+
+ +
+

Data Model

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaBeforeAfterWhy it matters
Competition discriminatorin-person | onlinein-person | online | benchmarkThe existing competition shell can route by capabilities.
Benchmark metadataAbsentbenchmark_batteriesStores category cache, rating bands, score scale, video policy, open-join flag, and linked competition.
Benchmark testsModeled only as workouts/eventsbenchmark_tests plus track_workouts.benchmarkTestIdSeparates source test definition from event rendering while preserving one scorable event per test.
ThresholdsAbsentbenchmark_tier_thresholdsDefines per-test, per-variant, per-tier encoded values and keeps raw source display values.
Score variantNo variant snapshotscores.benchmarkVariantCaptures the athlete profile variant at submit time so future profile changes do not rewrite historical scoring context.
+ +
+
ER view of new benchmark tables and reused shell
+
+erDiagram
+  competitions ||--o| benchmark_batteries : "has benchmark config"
+  benchmark_batteries ||--o{ benchmark_tests : "defines"
+  benchmark_tests ||--o{ benchmark_tier_thresholds : "has thresholds"
+  benchmark_tests ||--o| track_workouts : "mapped by exactly one"
+  programming_tracks ||--o{ track_workouts : "contains"
+  competitions ||--o{ programming_tracks : "uses"
+  competitions ||--o{ competition_registrations : "has"
+  track_workouts ||--o{ scores : "receives"
+  competition_registrations ||--o{ scores : "athlete score"
+  scores ||--o{ video_submissions : "optional evidence"
+
+  benchmark_batteries {
+    varchar id
+    text categories
+    text ratingBands
+    int maxTier
+    int scoreMax
+    varchar videoPolicy
+    boolean isOpenJoin
+    varchar competitionId
+    varchar status
+  }
+
+  benchmark_tests {
+    varchar id
+    varchar batteryId
+    varchar categoryKey
+    varchar scheme
+    varchar scoreType
+    boolean includedInScoring
+  }
+
+  benchmark_tier_thresholds {
+    varchar testId
+    varchar variant
+    int tier
+    int thresholdValue
+    varchar rawValue
+  }
+          
+
+
+ +
+

Critical Flows

+ +
+
Submission write flow
+
+sequenceDiagram
+  autonumber
+  participant Athlete
+  participant submitVideoFn
+  participant BenchmarkContext
+  participant ScoreWriter
+  participant DB
+
+  Athlete->>submitVideoFn: submit score plus optional video URL
+  submitVideoFn->>DB: load registration and submission window
+  submitVideoFn->>BenchmarkContext: getBenchmarkSubmissionContext(competitionId, trackWorkoutId)
+  BenchmarkContext->>DB: load battery, competition, Open division, benchmark test
+  submitVideoFn->>BenchmarkContext: getBenchmarkProfileVariant(userId)
+  submitVideoFn->>ScoreWriter: saveBenchmarkScoreInTransaction(...)
+  ScoreWriter->>DB: load threshold table and lock existing score
+  ScoreWriter->>ScoreWriter: calculate candidate tier
+  alt candidate is better
+    ScoreWriter->>DB: insert/update scores with benchmarkVariant
+    ScoreWriter->>DB: reset score/video verification state
+  else worse or equal retest
+    ScoreWriter-->>submitVideoFn: retainedCurrentBest = true
+  end
+  submitVideoFn-->>Athlete: success and best-retention status
+          
+
+ +
+
Leaderboard read flow
+
+sequenceDiagram
+  autonumber
+  participant Route
+  participant Leaderboard
+  participant BenchmarkLoader
+  participant Scoring
+  participant Aggregate
+  participant UI
+
+  Route->>Leaderboard: getCompetitionLeaderboard(competitionId, divisionId)
+  Leaderboard->>Leaderboard: parse scoringConfig
+  alt algorithm is absolute_tier
+    Leaderboard->>BenchmarkLoader: loadBenchmarkLeaderboardContext(...)
+    BenchmarkLoader->>BenchmarkLoader: validate categories, mappings, variants, thresholds
+    BenchmarkLoader-->>Leaderboard: AbsoluteTierScoringContext plus metadata
+  end
+  Leaderboard->>Scoring: calculateEventPoints(eventId, scores, scheme, config, context)
+  Scoring->>Scoring: calculateAbsoluteTierEventPoints
+  Leaderboard->>Aggregate: aggregateBenchmarkScores(categories, event tiers)
+  Aggregate-->>Leaderboard: category scores and Overall/100
+  Leaderboard->>Leaderboard: apply benchmark tier histogram tiebreaks
+  Leaderboard-->>UI: entries with rating, category, tier, verification metadata
+          
+
+ +
+
Organizer tier-control flow
+
+sequenceDiagram
+  autonumber
+  participant Organizer
+  participant Route as /scoring/tiers
+  participant ServerFn
+  participant TierServer
+  participant DB
+
+  Organizer->>Route: open Benchmark tiers
+  Route->>ServerFn: getBenchmarkScoringTiersFn
+  ServerFn->>ServerFn: require session, verified email for writes, manage_programming
+  ServerFn->>TierServer: loadBenchmarkScoringTierSummary
+  TierServer->>DB: battery, tests, thresholds
+  TierServer-->>Route: summary or setupRequired
+  Organizer->>ServerFn: save raw threshold edits
+  ServerFn->>TierServer: saveBenchmarkTierThresholds
+  TierServer->>TierServer: encode and validate threshold values
+  TierServer->>DB: transactionally update threshold rows
+  Organizer->>ServerFn: activate absolute_tier or switch to online
+  ServerFn->>DB: update competition.settings.scoringConfig
+          
+
+
+ +
+

Function Signatures

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BoundarySignatureContract
Capability lookupcompetitionCan(type: string, capability: CompetitionCapability): booleanFail-closed capability check for routes, submissions, navigation, and leaderboard gates.
Leaderboard contextloadBenchmarkLeaderboardContext({ competitionId, scoringConfig, trackWorkouts }): Promise<BenchmarkLeaderboardContext | null>Returns null outside absolute_tier; otherwise validates battery, category counts, one-to-one mappings, variants, and threshold completeness.
Pure context builderbuildBenchmarkLeaderboardContext({ battery, tests, thresholds, trackWorkouts }): BenchmarkLeaderboardContextTestable fail-closed builder that creates metadata maps and AbsoluteTierScoringContext.
Absolute tier scorercalculateAbsoluteTier(score, table, scheme): numberScores one raw score against variant thresholds; inactive statuses return 0, below threshold returns 0.5, matched thresholds return 1..10.
Scoring dispatchcalculateEventPoints(eventId, scores, scheme, config, context?): Map<string, EventPointsResult>Adds absolute_tier as another algorithm instead of branching the leaderboard wholesale.
Category aggregationaggregateBenchmarkScores(input): BenchmarkAggregationResultTurns per-event tiers into category scores and weighted Overall/100 without rescaling again in the UI.
Submission contextgetBenchmarkSubmissionContext(competitionId, trackWorkoutId): Promise<BenchmarkSubmissionContext | null>Loads battery policy, board status, Open division, and source test mapping for one submission.
Variant snapshotgetBenchmarkProfileVariant(userId): Promise<BenchmarkVariant>Requires supported profile gender and snapshots it to scores.benchmarkVariant.
Best-to-date writesaveBenchmarkScoreInTransaction({ db, context, variant, score, now? }): Promise<BenchmarkScoreWriteResult>Locks existing live score, compares candidate by tier then raw score direction, writes only improvements, and clears stale verification.
Evidence policyisBenchmarkVideoEvidenceRequired({ context, variant, score }): Promise<boolean>Implements battery videoPolicy: never, always, or top-tier-only.
Tier editor loadloadBenchmarkScoringTierSummary({ db, competitionId, scoringConfig }): Promise<BenchmarkScoringTierSummary>Feeds the organizer editor with category/test/threshold/rating metadata and current active mode.
Tier editor savesaveBenchmarkTierThresholds({ db, competitionId, thresholds }): Promise<void>Re-encodes raw values, validates tier bounds, and updates threshold rows in one transaction.
Mode switchactivateBenchmarkScoring({ db, competitionId }): Promise<void>Writes scoringConfig.algorithm = "absolute_tier" with the competition battery id.
Seed builderbuildBenchmarkSeedRows(ts: string)Creates generic benchmark rows from the source-data receipt, including batteries, tests, threshold rows, workouts, and track-workout mappings.
+
+ +
+

Seams And Decisions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SeamWhat changedWhy this is the boundary
1Capability registryBenchmark becomes a registry entry instead of hardcoded route checks.Existing code already uses capability helpers for online/in-person divergence, so this keeps new behavior behind the same product axis.
2Scoring algorithm dispatchabsolute_tier is a scoring algorithm, not a competition-type-specific leaderboard implementation.Leaderboard ranking can stay centered on calculateEventPoints, with threshold context passed only when needed.
3Benchmark context loaderAll fail-closed battery/test/threshold validation happens once per leaderboard request.The scoring loop receives a compact read model and does not repeatedly query or parse JSON per event.
4Video-submission wrapperBenchmark scores enter through submitVideoFn, then branch into benchmark write helpers.Athletes keep the same submission UX and existing non-benchmark video behavior remains isolated.
5Best-to-date write helperLive score retention is centralized in saveBenchmarkScoreInTransaction.V1 stores the current best only. There is no attempts table, so write-time monotonicity is the critical invariant.
6Organizer tier controlsThe tier editor uses server functions with permission checks and raw-value encoding.Raw threshold text belongs in the editor, but encoded values belong in persisted scoring rows.
7Branding boundaryThe PDF is seed provenance only; public surfaces say generic benchmark language.The feature is a WODsmith benchmark board, not a branded product fork.
+
+ +
+

Source Map

+ +
+
+ +
+
+ Generated from the local checkout on 2026-07-04. Mermaid diagrams render + in a browser through the CDN script below. +
+
+ + + + diff --git a/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md new file mode 100644 index 000000000..99887686c --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md @@ -0,0 +1,49 @@ +# Benchmark Leaderboard Assumptions And Decisions + +This file records what implementers should treat as settled, what still requires owner input, and what is intentionally deferred. + +## Accepted Decisions + +- Use distinct `competitionType: "benchmark"`. +- Reuse current competition infrastructure through capabilities instead of overloading `online`. +- Extend the existing registry in `apps/wodsmith-start/src/lib/competitions/capabilities.ts`; do not create a parallel registry or rebuild M0 from scratch. +- Represent sex as a per-score variant snapshot from required `user.gender`. +- Keep every benchmark board in one Open division for v1. +- Use `absolute_tier` as a new scoring algorithm. +- Store tier thresholds in real tables, pre-encoded in engine units. +- Derive direction from `scheme` + `scoreType`; do not add a `direction` column. +- Compute v1 as best-to-date from the live `scores` row with keep-best-on-write. +- Do not declare `optInResultPublishing` for benchmark. +- Defer Weighted C2B, Open 16.2, and Open 18.4 from v1 scoring. +- Use `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` as the source artifact for the first benchmark seed. +- Treat the PDF as source data only; do not build any HillerFit-branded page, route, tab, product navigation item, marketing surface, logo, call to action, theme treatment, or customer-facing product area. +- The first seed sets `videoPolicy: "never"` and `isOpenJoin: false`, so athletes register through the normal flow before scoring; the guarded open-join checks stay dormant until M3 wires the lazy-register path into the submit flow. +- M3 uses a small mockable in-process benchmark open-join rate-limit boundary because no shared app-level limiter exists yet. A durable store-backed limiter can replace the boundary without changing the benchmark submission helper contract. +- Until a v2 top-N setting exists, `videoPolicy: "for_top_scores"` means video evidence is required when the candidate reaches the top configured tier for that benchmark test. Non-top attempts may remain score-only. + +## Source Data Check + +Before completing the first seed, extract tests, categories, units, and thresholds from the local training PDF and record assumptions. If the PDF is ambiguous or incomplete, ask the owner about the data gap. Do not solve ambiguity by creating branded product surfaces; the answer is data clarification, not UI scope expansion. + +## Deferred Decisions + +- Whether v2 history stores every attempt before or after organizer adjustment. +- Whether Weighted C2B v2 captures athlete bodyweight at submit or remains representative-bodyweight based. +- Which materialization threshold should trigger cached windowed leaderboards. +- Whether stats pages are public by default with privacy toggle or private by default if product feedback changes. + +## Rejected Alternatives + +- Reusing `competitionType: "online"` for benchmark. +- HillerFit-branded pages, routes, stats pages, product navigation, marketing copy, logos, calls to action, visual themes, or customer-facing product areas. +- Requiring videos for every v1 benchmark seed submission before the scoring concept is validated. +- Men/Women divisions for v1. +- `settings` JSON for all tier thresholds. +- A fourth category table in v1. +- Calling overwrite-in-place all-time history. +- Field-relative countback for absolute-tier ties. +- Silent tier 0 for missing threshold tables or missing variants. + +## User Decision Needed + +Only source-data ambiguity in the training PDF should block the first seed. All other v1 choices above are implementation assumptions unless the owner explicitly reopens them. diff --git a/ai/research/hillerfit-benchmark-leaderboard/benchmark-scoring-walkthrough.html b/ai/research/hillerfit-benchmark-leaderboard/benchmark-scoring-walkthrough.html new file mode 100644 index 000000000..fcc34f8f4 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/benchmark-scoring-walkthrough.html @@ -0,0 +1,339 @@ + + + + + +Benchmark Tier Scoring — Calculation & Leaderboard Walkthrough + + + + +
+ +

Benchmark Tier Scoring

+

How the organizer setup at /compete/organizer/$competitionId/scoring/tiers becomes tier badges, category scores, and ratings on the leaderboard.

+ + + +

1. The big picture

+

Benchmark scoring is two parallel systems that meet on the leaderboard:

+
    +
  • Ranking — the online algorithm (place-based, golf-style: 1st = 1 pt, lowest total wins) decides who is above whom.
  • +
  • Tier context — the threshold tables you edit on the tiers page turn every raw result into a tier (1…maxTier, or 0.5), tiers aggregate into category scores and an overall x/100 with a rating band. This is additive display data, not the ranking — unless the competition still uses the legacy absolute_tier algorithm.
  • +
+ +
+
+flowchart LR
+    subgraph SETUP["Organizer setup"]
+        A["tiers.tsx
threshold editor"] --> B["saveBenchmarkScoringTiersFn"] + B --> C["encodeBenchmarkThresholdValue()
'19:30' → 1170000 ms
'315' → 315 reps/lbs"] + C --> D[("benchmark_tier_thresholds
testId × variant × tier")] + end + + subgraph ATHLETE["Athlete"] + E["Submits video + score"] --> F["scores row
+ benchmarkVariant
(from profile gender)"] + end + + subgraph LB["getCompetitionLeaderboard()"] + G["loadBenchmarkLeaderboardContext()"] --> H["calculateAbsoluteTier()
per score, per event"] + F2[("scores")] --> H + H --> I["aggregateBenchmarkScores()
tiers → category scores → overall"] + I --> J["findBenchmarkRatingBand()"] + K["online algorithm
place = points"] --> L["totalPoints → overallRank"] + end + + D --> G + F --> F2 + J --> M["Leaderboard UI
87.5/100 · Elite ·
Tier badges per event"] + L --> M +
+
+ +

2. Data model

+

One battery per competition holds the config; tests belong to categories; thresholds are one row per test × variant × tier.

+ +
+
+erDiagram
+    COMPETITION ||--|| BENCHMARK_BATTERY : "has one"
+    BENCHMARK_BATTERY ||--o{ BENCHMARK_TEST : "contains"
+    BENCHMARK_TEST ||--o{ TIER_THRESHOLD : "test × variant × tier"
+    BENCHMARK_TEST ||--|| TRACK_WORKOUT : "1:1 via benchmarkTestId"
+    TRACK_WORKOUT ||--o{ SCORE : "athlete results"
+
+    BENCHMARK_BATTERY {
+        int maxTier "e.g. 5"
+        int scoreMax "e.g. 100"
+        json categories "key, label, weight, testCount"
+        json ratingBands "label, minScore, maxScore"
+    }
+    BENCHMARK_TEST {
+        string categoryKey
+        string scoreType "time / reps / load..."
+        bool includedInScoring
+        string scoreModel "standard | hybrid"
+        int hybridFlipTier "nullable"
+    }
+    TIER_THRESHOLD {
+        string variant "male | female"
+        int tier "1..maxTier"
+        int thresholdValue "canonical encoding"
+    }
+    SCORE {
+        int scoreValue "encoded"
+        int secondaryValue "reps at cap"
+        string benchmarkVariant "male | female"
+        string status "scored/cap/dnf/dns"
+    }
+
+
+ +

3. Organizer setup — tiers.tsx

+

The route loader gates on competitionCan(type, "benchmarkScoringTiers") and fetches the full summary via getBenchmarkScoringTiersFn. The page is one editor with a local draft keyed testId:variant:tier holding raw strings exactly as typed ("19:30", "315", "42").

+ +
+
+flowchart TD
+    L["Route loader"] --> G{"competitionCan
benchmarkScoringTiers?"} + G -- no --> NF["notFound()"] + G -- yes --> S["getBenchmarkScoringTiersFn
→ BenchmarkScoringTierSummary"] + S --> E["BenchmarkScoringTiersEditor"] + E --> D1["buildDraft()
flat map: testId:variant:tier → rawValue"] + D1 --> T["ThresholdTable per category
one input per tier, per variant"] + T -- "Save tier table" --> F1["flattenThresholdUpdates()"] + F1 --> SV["saveBenchmarkScoringTiersFn"] + SV --> EN["encodeBenchmarkThresholdValue()
per row, per dimension"] + EN --> DB[("benchmark_tier_thresholds")] + SV --> VAL["validateThresholdSet()
every variant has tiers 1..maxTier"] +
+
+ +

Raw value encoding

+

encodeBenchmarkThresholdValue() (src/server/benchmark-scoring-tiers.ts:187) converts typed strings into the same canonical integers the scores table uses, so tier comparison is a plain number comparison later:

+ + + + + + + +
Input unitExample rawStored as
time (mm:ss)19:301,170,000 (ms)
reps4242
lb315load encoding via encodeScore(…, unit lbs)
in / ft110 inconverted to feet, then encoded
hybrid, tier < flip250reps (whole number enforced — parseInt("19:59") would silently give 19)
+ +
+Hybrid tests (time-cap workouts): benchmarkThresholdDimension() splits the tier row. Tiers below hybridFlipTier are "reps completed at the cap"; tiers at or above it are finish times. The editor shows placeholder reps vs mm:ss per cell accordingly. +
+ +

The same page also edits: battery settings (maxTier, scoreMax), categories with weights, rating bands (label + min/max score), and test CRUD — each through its own server fn, each returning a fresh summary that resets the draft.

+ +

4. Athlete score submission

+

When an athlete submits (src/server-fns/video-submission-fns.ts), benchmark competitions take a dedicated path: individual-only, and the score row is stamped with benchmarkVariant resolved from the athlete's profile gender (getBenchmarkProfileVariant). That stored variant — not a live profile lookup — decides which threshold column their result is compared against at leaderboard time.

+ +

5. Tier calculation per result — calculateAbsoluteTier()

+

src/lib/scoring/algorithms/absolute-tier.ts:39. Runs once per (score, event). Walks every threshold for the athlete's variant and keeps the highest tier whose threshold the score meets.

+ +
+
+flowchart TD
+    A["score + threshold table + scheme"] --> B{"variant present
on score?"} + B -- no --> ERR["BenchmarkConfigError"] + B -- yes --> C{"status dnf / dns /
withdrawn?"} + C -- yes --> Z0["tier = 0"] + C -- no --> D{"hybrid flip tier?"} + + D -- "no (standard)" --> E{"sort direction
from scheme"} + E -- "asc (time)" --> F["meets if score ≤ threshold"] + E -- "desc (reps/load)" --> G["meets if score ≥ threshold"] + + D -- yes --> H{"threshold tier ≥ flip?"} + H -- "yes → time tier" --> I["meets if status = scored
AND time ≤ threshold"] + H -- "no → reps-at-cap tier" --> J{"finished (scored)?"} + J -- yes --> K["auto-meets
(finisher did all reps)"] + J -- "no (capped)" --> L["meets if secondaryValue
(reps at cap) ≥ threshold"] + + F --> M["bestTier = max(matching tiers)"] + G --> M + I --> M + K --> M + L --> M + M --> N{"bestTier > 0?"} + N -- yes --> O["tier = bestTier"] + N -- no --> P["tier = 0.5
(tried, but below T1)"] +
+
+ +
+The 0.5 tier: any valid attempt below the tier-1 threshold still earns half a tier — participation counts toward the category average, distinct from tier 0 for DNF/DNS/withdrawn. +
+ +

6. Category & overall aggregation — aggregateBenchmarkScores()

+

src/lib/scoring/category-aggregation.ts:36. Pure function; results with includedInScoring: false are dropped first.

+ +
+categoryScore = (Σ tiers in category) / (testCount × maxTier) × scoreMax +
+
+overallScore = Σ (categoryScore × weight) / Σ weights +
+ +

Note the denominator uses the category's configured testCount, not the number of tests the athlete completed — skipping a test costs you exactly its missing tiers. Then findBenchmarkRatingBand() picks the band where minScore ≤ overall ≤ maxScore (e.g. Elite / Advanced / Intermediate).

+ +

7. Ranking: online points vs. tier context

+

Inside getCompetitionLeaderboard() (src/server/competition-leaderboard.ts):

+ +
+
+sequenceDiagram
+    participant R as Leaderboard route
+    participant CL as getCompetitionLeaderboard
+    participant BC as loadBenchmarkLeaderboardContext
+    participant ALG as calculateEventPoints (online)
+    participant AT as calculateAbsoluteTier
+    participant AGG as aggregateBenchmarkScores
+
+    R->>CL: competitionId, divisionId
+    CL->>BC: battery + tests + thresholds
+    Note over BC: Validates complete tables:
every variant × every tier 1..maxTier.
Additive mode: config error → null
(board renders without tiers) + BC-->>CL: BenchmarkLeaderboardContext + + loop each event + CL->>ALG: eventScoreInputs (value, status, variant) + ALG-->>CL: points = place (1st = 1pt), rank + CL->>AT: same inputs vs threshold table + AT-->>CL: displayTiersByUserId (context only) + Note over CL: eventResults get rank, points,
benchmarkTier, categoryKey + end + + loop each athlete entry + CL->>AGG: eventTiers (skip if none — additive stays null, not 0/100) + AGG-->>CL: category scores + overallScore + CL->>CL: findBenchmarkRatingBand(overall) + Note over CL: legacy absolute_tier only:
totalPoints = overallScore + end + + CL->>CL: applyTiebreakers per division → overallRank + CL-->>R: entries + events + scoringConfig +
+
+ + + + + + + +
online (current)absolute_tier (legacy)
Ranks the boardSum of places, lowest winsWeighted overall tier score, highest wins
Tier roleDisplay context only (displayTiersByUserId)Tier is the event points
Broken tier configDegrades gracefully — board renders without tier contextThrows BenchmarkConfigError
Athlete with no tiered scoresBenchmark fields stay null (no "0/100" branding)Scored as 0
+ +

The amber banner on tiers.tsx ("Leaderboard is ranking by tier score") appears when summary.isActive — i.e. the competition still runs legacy absolute_tier — and the button calls activateBenchmarkOnlineScoringFn to flip it to online.

+ +

8. Leaderboard display

+

src/components/online-competition-leaderboard-table.tsx flips into benchmark mode when any entry has benchmarkOverallScore !== null:

+
    +
  • Overall cell: formatBenchmarkOverall() → "87.5/100" plus the rating-band label badge (points are hidden).
  • +
  • Rating column: sortable, shows the band label.
  • +
  • Category summary: BenchmarkCategorySummary renders each category's score.
  • +
  • Event cells: formatted score with "#rank · Tier N" subtext (formatBenchmarkTier()) and verification badges; event columns are grouped under benchmark-category headers instead of parent events.
  • +
  • leaderboard-page-content.tsx adds benchmark-only filters: category tabs and a gender filter (server exposes benchmarkGender only on benchmark boards).
  • +
+ +

9. Worked example

+

Battery: maxTier = 5, scoreMax = 100. Two categories: Strength (3 tests, weight 2) and Engine (2 tests, weight 1). Male athlete:

+ + + + + + + + +
TestCategoryResultT1…T5 thresholdsTier
Back Squat (load, desc)Strength315 lb185 / 225 / 275 / 315 / 3654 (≥315, <365)
Deadlift (load, desc)Strength350 lb225 / 275 / 335 / 405 / 4553
Press (load, desc)Strength85 lb95 / 115 / 135 / 155 / 1850.5 (below T1)
5k Row (time, asc)Engine19:1021:00 / 20:00 / 19:30 / 19:00 / 18:303 (≤19:30, not ≤19:00)
Chad (hybrid, flip=3)Enginecapped, 780 reps500 reps / 800 reps / 60:00 / 55:00 / 50:001 (780 ≥ 500, < 800; time tiers need a finish)
+ +
+Strength = (4 + 3 + 0.5) / (3 × 5) × 100 = 7.5/15 × 100 = 50.0
+Engine = (3 + 1) / (2 × 5) × 100 = 4/10 × 100 = 40.0
+Overall = (50.0 × 2 + 40.0 × 1) / 3 = 46.7 / 100 +
+ +

If rating bands are Beginner 0–39, Intermediate 40–69, Advanced 70–89, Elite 90–100 → the leaderboard shows 46.7/100 · Intermediate, with per-event "Tier 4 / Tier 3 / …" subtext — while the athlete's position on the board still comes from their sum of per-event places under online scoring.

+ +

Key files

+ + + + + + + + + + +
FileRole
src/routes/compete/organizer/$competitionId/scoring/tiers.tsxOrganizer editor: thresholds, categories, bands, settings, tests
src/server/benchmark-scoring-tiers.tsSummary building, threshold encoding/validation, all mutations
src/server/benchmark-leaderboard.tsLoads/validates tier context for the leaderboard; rating band lookup
src/lib/scoring/algorithms/absolute-tier.tscalculateAbsoluteTier() — result → tier (incl. hybrid + 0.5 rule)
src/lib/scoring/category-aggregation.tsaggregateBenchmarkScores() — tiers → category → overall
src/server/competition-leaderboard.tsOrchestrates points + tiers + aggregation + tiebreakers per entry
src/components/online-competition-leaderboard-table.tsxBenchmark-mode rendering: x/100, rating, tier subtext, category headers
src/server-fns/video-submission-fns.tsStamps benchmarkVariant (profile gender) onto submitted scores
+ +
+ + diff --git a/ai/research/hillerfit-benchmark-leaderboard/requirements.md b/ai/research/hillerfit-benchmark-leaderboard/requirements.md new file mode 100644 index 000000000..08b972856 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/requirements.md @@ -0,0 +1,62 @@ +# Benchmark Leaderboard Requirements + +These requirements define the v1 benchmark leaderboard product and record assumptions that must not be re-decided during implementation. + +## Fixed Assumptions + +- Benchmark boards use a distinct `competitionType: "benchmark"` behind the competition-type capability registry. +- The current checkout already has a capability registry for `in-person` and `online`; v1 extends it with `benchmark` rather than creating a second registry. +- Benchmark boards declare `videoSubmissions` and `perpetual`, and intentionally do not declare `submissionWindows`, `optInResultPublishing`, physical-venue, heat, check-in, volunteer, or organizer-entered-results capabilities. +- The first seed is derived from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- The PDF is source data only. We are not building any HillerFit-branded page, route, tab, product navigation item, marketing surface, logo, call to action, theme treatment, or other customer-facing product area. +- The sex axis is a score variant, not a division. Athletes rank together in one Open division; `scores.benchmarkVariant` snapshots `user.gender` at submit time. +- v1 is best-to-date, not all-time history. The live `scores` row is kept monotonic with a keep-best-on-write guard; `score_attempts`, windows, and invalidation-restore are v2. +- v1 includes 55 scoring tests from the training guide. Weighted C2B, Open 16.2, and Open 18.4 are seeded as unavailable or deferred with `includedInScoring = false`. +- Benchmark results are public on valid submission. Moderation uses existing invalid verification status, not the per-event publish gate. + +## Source Data Check + +The local PDF is the source of truth for the first seed. Before completing M1: + +- Extract the included tests, categories, units, and tier thresholds from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- Record any extraction assumptions in the seed file or a companion receipt. +- Ask the owner if a threshold, unit, category, or deferred-test status is missing or ambiguous. + +Do not create a HillerFit-branded landing page, route, navigation entry, stats page, marketing surface, or theme as a substitute for missing seed data. Source-data ambiguity must be resolved as data work or owner input. + +## Branding Boundary + +"HillerFit" is provenance for the local training PDF only. Engineers must not create customer-facing HillerFit pages, routes, tabs, navigation entries, stats pages, marketing surfaces, logos, calls to action, or theme treatments. + +The implementation target is WODsmith's generic benchmark board. Any missing or ambiguous PDF value must be handled as source-data extraction work, a generic benchmark label, or an owner decision, not as a product fork. + +## V1 User Requirements + +- Athletes can join or submit to a published benchmark board when allowed by registration or guarded open-join rules. +- Athletes must have a Male/Female profile gender before submitting to a benchmark board. +- Athletes submit scores through the existing video-submission path; video requirement follows the battery `videoPolicy`. +- Submitted scores are encoded through existing score encoders and scored as absolute tiers: `0`, `0.5`, or `1..10`. +- A worse or equal retest does not overwrite an athlete's current best-to-date score. +- A changed retest resets stale verification state so old verified badges do not attach to new values. +- The public leaderboard ranks the unified Open division by Overall/100 and shows category scores, rating band, per-event tiers, and verification state. +- The public stats page shows Overall/100, category stat line, per-test grid, and visually distinct states for untested, attempted tier 0, unavailable configuration, pending, verified, adjusted, and invalid/excluded. + +## V1 Non-Requirements + +- No per-attempt history table. +- No 12-month or 24-month windows. +- No current-vs-best comparison. +- No generic tier-table authoring UI. +- No hybrid reps-or-time scoring. +- No team benchmark divisions. +- No Men/Women division split. +- No benchmark publish workflow that requires organizers to publish 55 event/division cells. +- No HillerFit-branded pages, routes, tabs, stats pages, marketing copy, logos, calls to action, visual theme, or product navigation. + +## Definition Of Done + +v1 is done when a seeded benchmark board can be created from the training PDF data, a qualified athlete can submit scores, worse retests are ignored, valid scores appear immediately on the public board, the unified leaderboard ranks by Overall/100, the stats page renders the category breakdown, no HillerFit-branded customer-facing surface has been introduced, and the focused automated/manual tests in `test-strategy.md` pass. + +## Current Repository Baseline + +As of M4 on `feat/hillerfit-plan`, the feature base contains the benchmark capability registry entry, schema primitives, PDF-derived seed, absolute-tier scoring, best-to-date submission path, public Overall/100 leaderboard, generic stats route, and focused tests. Benchmark declares `videoSubmissions` and `perpetual`, remains hidden from the generic create picker, and has no HillerFit-branded customer-facing surface. diff --git a/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md new file mode 100644 index 000000000..c80d6e8a2 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md @@ -0,0 +1,53 @@ +# Reviewer Alignment + +This file records independent reviewer summaries and the final convergence check for the current implementation packet. + +## Reviewer A Summary + +Reviewer A reviewed the server and data-model slice. They identified and then re-checked one blocking ambiguity: benchmark category denominators assume the seeded test/event mirror is complete, so the read path must fail closed when a benchmark test is missing a scorable event or when a test is mapped by duplicate events. The implementation now validates that one-to-one mapping in `server/benchmark-leaderboard.ts`, covers missing and duplicate mappings in `test/server/benchmark-leaderboard.test.ts`, and records the invariant in `technical-design.md`, `test-strategy.md`, `tasks.md`, and `lat.md/domain.md`. + +Reviewer A now describes the server contract as coherent: generic benchmark battery data, one Open division, `scores.benchmarkVariant` snapshots, preloaded absolute-tier context, fail-closed mapping/category/threshold/variant validation, Overall/100 aggregation, benchmark tier-histogram ties, generic stats output, and no HillerFit-branded customer-facing surface. + +## Reviewer B Summary + +Reviewer B reviewed the UI, route, test, and branding slice. They identified and then re-checked the blocking gaps: the route tree had dropped the TanStack Start registration, the stats route collapsed load/configuration errors into the empty no-score state, tests did not yet pin all required stat states or the actual benchmark table rendering, and the docs needed a sharper no-branded-pages boundary. The implementation now preserves the route registration, renders an explicit stats load-error state, covers all required stats states, covers the actual `OnlineCompetitionLeaderboardTable` benchmark display, and explicitly states that the training PDF is source data only. + +Reviewer B now describes the UI contract as coherent: `CompetitionTabs` exposes a generic Stats tab only for `absolute_tier`, `/compete/$slug/stats` renders generic benchmark stats or explicit fallback states, the leaderboard table shows Overall/100, rating, category, tier, and verification fields, and the branding boundary keeps HillerFit references out of customer-facing routes/components. + +## Orchestrator Contract + +Components: + +- Competition-type capability registry with M0a already landed. +- Benchmark schema and training-PDF-derived seed. +- Absolute-tier scoring, category aggregation, and benchmark tier-histogram tiebreaks. +- Benchmark submission wrapper on the existing video-submission path. +- Existing leaderboard pipeline extended with a preloaded benchmark context. +- Generic benchmark leaderboard display, Stats tab, and per-athlete stat-line route. + +Data model: + +- `benchmark_batteries`, `benchmark_tests`, and `benchmark_tier_thresholds`. +- `trackWorkouts.benchmarkTestId` and `trackWorkouts.benchmarkCategory`, with exactly one scorable event per benchmark test. +- `scores.benchmarkVariant` as the score-time sex/variant snapshot. +- One Open division per benchmark competition. +- `score_attempts` deferred to v2. + +Dependencies: + +- M0a capability registry is present and benchmark is registered with `videoSubmissions` and `perpetual`. +- M1 schema/seed must provide complete category caches, test rows, one-to-one event mappings, and threshold rows. +- M2 absolute-tier scoring supplies the `0 / 0.5 / 1..10` tier semantics. +- M3 submission supplies profile-variant snapshots and best-to-date writes. +- The first seed derives source data from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- No HillerFit-branded customer-facing pages, routes, stats pages, navigation, marketing copy, logos, calls to action, or theme treatments. + +Definition of done: + +- Seeded generic benchmark board, valid score submission, keep-best retest behavior, public Overall/100 leaderboard, generic stat line, invalid-score exclusion, branding boundary, focused automated tests, LAT updates, and manual smoke are satisfied. +- Current focused M4 tests, package type-check, and PR CI pass. +- `lat check` has no benchmark errors; current failures are existing Crew references outside this slice. + +## Convergence Result + +The two independent reviewers materially agree on components, data model, dependencies, branding boundary, and definition of done for M4. The remaining caveat is not a product fork: `lat check` is blocked by existing Crew references outside the benchmark artifacts. diff --git a/ai/research/hillerfit-benchmark-leaderboard/tasks.md b/ai/research/hillerfit-benchmark-leaderboard/tasks.md new file mode 100644 index 000000000..ebce36f6c --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -0,0 +1,107 @@ +# Benchmark Leaderboard Tasks + +Tasks are ordered by dependency. Each task must update LAT documentation when it changes architecture, behavior, or test specs, and must run `lat check`. + +## Global Acceptance Criteria + +These criteria apply to every milestone, not just the seed or UI slices. + +- The implementation builds a generic WODsmith benchmark board from the training PDF data; it does not build any HillerFit-branded page, route, tab, product navigation item, marketing copy, logo, call to action, visual theme, or customer-facing product area. +- The PDF may appear in seed/provenance code, extraction receipts, research docs, and tests that verify source data. Product copy and navigation must use generic WODsmith benchmark language. + +## M0a Capability Registry Extension And Chokepoints + +Extend the existing registry and refactor only benchmark-critical chokepoints. + +Acceptance criteria: + +- Existing `competitionCan`, `leaderboardVariant`, create selectability helpers, result-entry helpers, and truth-table tests remain intact for `in-person` and `online`. +- Registry types are widened to include `benchmark` and the `perpetual` capability. +- Benchmark is not selectable in the generic create picker. +- Benchmark declares `videoSubmissions` and `perpetual`, but not `submissionWindows` or `optInResultPublishing`. +- Existing in-person and online behavior is unchanged at submission, window-status, leaderboard-variant, and publish-gate chokepoints. +- Benchmark-capable registry entry can be added without reintroducing literal online/in-person checks at those chokepoints. +- `scoringAlgorithm === "online"` branches remain untouched. + +## M1a Schema Primitives + +Add benchmark persistence primitives, shared columns, and validation schemas. This slice does not seed PDF-derived benchmark rows. + +Acceptance criteria: + +- `competitionType: "benchmark"` is supported in TS and registry without SQL enum migration. +- `benchmark_batteries`, `benchmark_tests`, and `benchmark_tier_thresholds` are defined and exported. +- `trackWorkouts.benchmarkTestId`, `trackWorkouts.benchmarkCategory`, and `scores.benchmarkVariant` are migrated. +- Categories JSON validates on write, publish, and read; `testCount` is treated as a validated cache. +- `absolute_tier` config validation requires `absoluteTier.batteryId`, while the scoring dispatcher fails closed until M2 implements the engine. +- Generic create/edit forms do not expose `benchmark` as a selectable type or render in-person-only venue fields for stored benchmark rows. +- Global branding acceptance criteria pass for this schema slice. + +## M1b PDF-Derived Seed And Receipt + +Create the first generic benchmark battery from the local training PDF and record the extraction assumptions. + +Acceptance criteria: + +- Seed creates one Open division, individual-only tests, benchmark competition settings, included/deferred tests, and pre-encoded thresholds from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- Seed defaults are explicit: `videoPolicy: "never"`, `isOpenJoin: false` (registration stays explicit until the guarded M3 open-join path is wired into the submit flow), and no submission-window rows. +- Seed or companion receipt records extraction assumptions and any intentionally deferred tests. +- Seed validation proves every included test has 10 thresholds per supported variant and a matching tagged `trackWorkout`. +- Global branding acceptance criteria pass for this seed slice. + +## M2 Absolute-Tier Scoring + +Add the standard absolute-tier algorithm and category aggregation. + +Acceptance criteria: + +- `absolute_tier` scoring config requires `absoluteTier.batteryId`. +- `calculateEventPoints` accepts preloaded absolute-tier context and never queries thresholds inside the dispatch. +- `EventScoreInput.variant` is required by absolute-tier scoring and never defaults to male. +- Standard tests score `0`, `0.5`, and `1..10` by encoded thresholds and `getSortDirection(scheme, scoreType)`. +- Category scores and Overall/100 are weighted means with no second rescale. +- Half tiers survive scoring and formatting. +- Tier-histogram tiebreaker replaces field-relative countback for benchmark ties. +- Global branding acceptance criteria pass for this scoring slice. + +## M3 Submission And Retest + +Implement benchmark write behavior around the existing video submission path. + +Acceptance criteria: + +- Benchmark submissions require profile gender and write `scores.benchmarkVariant`. +- Scores use the Open division `scalingLevelId`, not a sex/variant level. +- Keep-best-on-write prevents equal or worse retests from replacing the live score. +- Changed retests clear stale verification/review state. +- Guarded `isOpenJoin` is transactional, idempotent, published/visible only, waiver/profile gated, and rate-limited. +- Team benchmark submission is rejected or impossible because batteries are individual-only. +- Global branding acceptance criteria pass for this submission slice. + +## M4 Leaderboard And Stats Demo + +Expose the public benchmark board and per-athlete stat line. + +Acceptance criteria: + +- Leaderboard loads benchmark battery and thresholds once per request, not per event/score. +- Leaderboard fails closed if benchmark tests and scorable events are not a one-to-one mirror. +- Benchmark event results include tier and category. +- Valid benchmark scores are visible without organizer publish actions. +- Invalid scores remain excluded from public reads. +- Public leaderboard ranks one Open division by Overall/100 and displays rating band/category fields. +- Stats route renders Overall/100, category scores, per-test grid, and verification states. +- Stats route distinguishes benchmark load/configuration errors from empty no-score states. +- Benchmark-visible copy does not imply a normal online competition with submission windows. +- Benchmark-visible copy remains generic WODsmith benchmark language, not HillerFit-branded page, route, navigation, stats, CTA, or marketing copy. +- Global branding acceptance criteria pass for this leaderboard/stats slice. + +## V2 Deferred Tasks + +Do not implement these in v1 unless the decision sheet changes: + +- `score_attempts`, `promoteBest`, invalidation-restore, and 12/24-month windows. +- Hybrid reps-or-time tests and `EventScoreInput.secondaryValue`. +- Weighted C2B accurate bodyweight handling. +- Generic tier-table authoring UI. +- Broad M0b capability cleanup outside benchmark chokepoints. diff --git a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md new file mode 100644 index 000000000..a5bdb1986 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -0,0 +1,80 @@ +# Benchmark Leaderboard Technical Design + +This design defines the components and contracts engineers should build for v1. + +## Components + +- Capability registry: existing `lib/competitions/capabilities.ts` owns competition-type feature flags and `leaderboardVariant`; benchmark work widens this current registry with a `benchmark` entry and `perpetual` capability. +- Benchmark schema: `db/schemas/benchmarks.ts` owns battery, test, and threshold definition tables. +- Existing competition shell: `competitions`, `programmingTracks`, `trackWorkouts`, `workouts`, `scalingGroups`, `scalingLevels`, `scores`, and `video_submissions` are reused. +- Absolute-tier scoring: `lib/scoring/algorithms/absolute-tier.ts` computes a tier from encoded score, status, score type, and variant threshold table. +- Category aggregation: `lib/scoring/category-aggregation.ts` derives category scores and Overall/100 from per-event tiers. +- Submission wrapper: benchmark-specific write logic around `submitVideoFn` snapshots variant, writes Open division, applies keep-best-on-write, and resets stale verification. +- Leaderboard integration: `server/competition-leaderboard.ts` loads benchmark context once, supplies `EventScoreInput.variant`, skips publish gating, preserves half tiers, ranks by Overall/100, and exposes benchmark fields. +- Stats route: `/compete/$slug/stats` reuses leaderboard data to render a per-athlete stat line. +- Seed module: a training-PDF-derived benchmark seed creates the benchmark battery, tests, thresholds, Open division, workouts, and benchmark competition. The source artifact is `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`; it feeds data, not branded pages or routes. + +## Data Model + +### New Tables + +- `benchmark_batteries`: battery metadata, owner, slug, categories JSON payload, rating bands JSON payload, `maxTier`, `scoreMax`, `videoPolicy`, `isOpenJoin`, `variantScalingGroupId`, `competitionId`, status, and non-null `ownerKey` for global slug uniqueness. The current MySQL schema stores these JSON payloads in text columns; every write/read/publish path must parse and validate them fail-closed. +- `benchmark_tests`: one test per battery test, with category key, name, position, workout scheme, score type, input unit, `includedInScoring`, optional time cap, and nullable v2 hybrid metadata. +- `benchmark_tier_thresholds`: one pre-encoded threshold per `(test, variant, tier)`, with raw display value preserved. + +### Shared Table Changes + +- `trackWorkouts.benchmarkTestId`: links the competition event to a benchmark test. +- `trackWorkouts.benchmarkCategory`: copies category for hot leaderboard aggregation. +- `scores.benchmarkVariant`: snapshots the scoring variant at submit time, normally `male` or `female`. + +### Deferred Table + +- `score_attempts` is v2 only. Do not add it to v1 migrations or v1 code paths. + +## Core Dependencies + +- M0a registry extension must land before benchmark submission and leaderboard chokepoints. The base registry already exists for current competition types. +- M1a schema primitives must land before M1b can seed benchmark rows. +- M1b PDF-derived seed and extraction receipt must land before M2 integration tests can use real benchmark rows. +- M2 absolute-tier algorithm must land before M3 can implement keep-best-on-write correctly. +- M3 submission must land before M4 can verify real leaderboard and stats flows. +- M1b seed data depends on extracting tests/thresholds from the local training PDF and recording extraction assumptions. +- The first seeded battery uses `videoPolicy: "never"` and `isOpenJoin: false` as seed defaults; the later M3 write path is responsible for published/visible, profile, waiver, idempotency, and rate-limit guards before any battery enables open-join submissions. + +## Read Flow + +1. `getCompetitionLeaderboard` loads competition settings and detects `algorithm === "absolute_tier"`. +2. It loads battery, categories, included-test counts, and all threshold tables once for the requested track workouts. +3. It fails closed unless every benchmark test is mapped by exactly one scorable `trackWorkout`, so category denominators and rendered tests cannot drift. +4. It fetches registrations and scores as today, including `scores.benchmarkVariant`. +5. For each event group, it creates `EventScoreInput` with `variant` from `scores.benchmarkVariant`. +6. `calculateEventPoints` dispatches to `absolute_tier` with preloaded `ctx`. +7. Event results carry `tier` and `benchmarkCategory`. +8. Category aggregation computes category scores and Overall/100 before final ranking. +9. Ranking feeds Overall/100 into tiebreakers and uses a benchmark tier-histogram tiebreaker. + +## Write Flow + +1. Submission requires a published/visible board, a valid registration or guarded open-join path, and profile gender. +2. The wrapper encodes the submitted score using the benchmark test scheme and score type. +3. The wrapper computes the candidate tier using the same absolute-tier helper as the read path. +4. It loads the existing live score for `(event, user, Open division)`. +5. It writes only if the candidate beats the stored tier and raw score according to the test direction; equal or worse retests leave the live row unchanged. +6. If the written value differs from a previously verified value, score verification and video review state reset to pending/null. +7. The score row stores `scalingLevelId = Open division` and `benchmarkVariant = user.gender`. + +## Failure Behavior + +- Missing or invalid profile gender blocks submission before scoring. +- Missing `absoluteTier.batteryId` fails schema validation. +- Missing categories JSON, stale test counts, duplicate/missing test-to-event mappings, missing thresholds, or missing variant tables fail closed with a typed configuration error. +- Configuration-unavailable cells are rendered separately from athlete tier 0 attempts. +- The public stats route distinguishes configuration/load failures from an empty no-scores board. +- DB `dq` maps to engine `dnf` and scores tier 0. +- Benchmark publish gating is disabled by capability omission; invalid scores remain excluded by existing verification filtering. +- Missing or ambiguous PDF source data blocks the seed value for that row/test until the owner decides; it does not justify adding branded pages, product navigation, marketing copy, visual theme, or changing the generic product scope. + +## Branding Boundary + +Benchmark routes, tabs, product navigation, headings, empty states, and stats components use WODsmith's generic benchmark language. The PDF may influence seed data names, test labels, thresholds, categories, rating bands, and extraction receipts, but it must not introduce any HillerFit-branded page, route, tab, stats page, navigation entry, logo, marketing section, call to action, or visual theme. diff --git a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md new file mode 100644 index 000000000..c2a146d94 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md @@ -0,0 +1,63 @@ +# Benchmark Leaderboard Test Strategy + +This strategy makes the implementation testable without requiring the full historical/windowed v2 feature set. + +## Unit Tests + +- Capability registry truth table for in-person, online, benchmark, and a separate unknown type fallback. +- Create-picker tests prove benchmark is registered but not selectable on create. +- Scoring config validation requires `absoluteTier.batteryId` only when `algorithm === "absolute_tier"`. +- Absolute-tier standard scoring covers tier 0, 0.5, 1, 10, DB `dq` mapped to engine `dnf`, and missing variant/table failures. +- Direction cases cover time hold with `scoreType: "max"`, run with `time` + `min`, points/watts with `points` + `max`, load, reps, feet/meters, and rounds-reps. +- Category aggregation proves all tiers 7 produces Overall 70, deferred tests are excluded from denominator, untested included tests count as 0, and weights are honored. +- Tier-histogram tiebreaker proves Tier 9 on a strong event beats Tier 7 on a weak event when Overall ties. +- Formatting preserves `0.5` and does not round benchmark points. + +## Integration Tests + +- Seed validation asserts every included test has 10 thresholds per variant and every included test has a matching tagged `trackWorkout`. +- Seed validation traces test names, categories, units, included/deferred status, and thresholds back to `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` or a checked-in extraction receipt. +- Publish/read validation rejects malformed categories JSON, stale `testCount`, missing thresholds, missing variant tables, and team-sized batteries. +- Publish/read validation rejects missing or duplicate `benchmarkTestId` mappings so every benchmark test has exactly one scorable event. +- Submission writes Open division `scalingLevelId` and `scores.benchmarkVariant`. +- Keep-best-on-write rejects worse/equal retests and accepts better retests. +- Changed retest clears stale `verificationStatus` and video review status. +- Benchmark leaderboard read loads thresholds in one prepass and does not query per event. +- Benchmark results are visible without `divisionResults` publish entries; invalid rows are excluded. +- Benchmark lacks submission-window capability, so window-status and submission paths do not require seeded window rows. + +## Route And Component Tests + +- Leaderboard chooses online visual variant for benchmark through `leaderboardVariant` while avoiding publish gating. +- Stats tab appears only for `absolute_tier` competitions. +- Direct `/stats` visits on non-benchmark competitions show unavailable copy, and benchmark load/configuration failures do not render as empty no-score states. +- Stats page distinguishes untested, attempted tier 0, unavailable config, pending, verified, adjusted, and invalid/excluded states. +- Route/component/navigation assertions confirm benchmark pages use generic WODsmith benchmark language and do not introduce HillerFit-branded pages, routes, tabs, stats pages, product navigation entries, calls to action, logos, marketing sections, or theme treatments. +- Static/source review allows HillerFit references only in research docs, seed/provenance metadata, extraction receipts, and source-data tests; app routes/components/navigation must not use HillerFit as customer-facing copy. +- Benchmark event detail submission UI blocks missing gender and surfaces the profile-completion path. +- Open-join flow rejects unpublished/private boards and duplicate races collapse to one registration. + +## Migration And Seed Checks + +- Generated migration contains only the benchmark tables and the declared shared columns for v1. +- No `score_attempts` table appears in the v1 migration. +- `ownerKey` enforces global slug uniqueness where `teamId` is null. +- Threshold values are pre-encoded and fit signed int. + +## Manual Verification + +1. Create or seed the benchmark board. +2. Confirm it has one Open division and individual-only tests. +3. Submit a profile-complete athlete score that lands below tier 1 and verify `0.5` displays. +4. Submit a worse retest and confirm the current best remains unchanged. +5. Submit a better retest and confirm the board updates and review state resets. +6. Mark a score invalid and confirm public leaderboard/stat page excludes it. +7. Confirm the leaderboard shows Overall/100, category scores, rating band, verification state, and no publish-required empty board. +8. Confirm standard online and in-person competition smoke paths still behave as before for the refactored chokepoints. +9. Confirm no HillerFit-branded page, route, tab, stats page, product navigation entry, logo, marketing section, call to action, theme treatment, or customer-facing product area was added. + +## Required Checks + +- Focused tests for each touched slice. +- Type-check for touched app package. +- `lat check` after LAT updates. diff --git a/ai/research/hillerfit-benchmark-leaderboard/traceability.md b/ai/research/hillerfit-benchmark-leaderboard/traceability.md new file mode 100644 index 000000000..58027f14f --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/traceability.md @@ -0,0 +1,17 @@ +# Benchmark Leaderboard Traceability + +This matrix links implementation artifacts back to the guide and the required test surface. + +| Contract | Guide Source | Implementation Artifact | Tests | +| --- | --- | --- | --- | +| Distinct benchmark competition type | Guide §0 D-A, §5.2, §10 M0a/M1 | `requirements.md`, `technical-design.md`, `tasks.md` M0a/M1a | Extend current registry, capability truth table, chokepoint characterization | +| Benchmark schema primitives | Guide §5.2, §6.3, §10 M1 | `technical-design.md` data model, `tasks.md` M1a, `lat.md/domain.md` | Migration, schema ownership, JSON validators, scoring-config validation, fail-closed scoring dispatch | +| Variant snapshot, not division | Guide §5.1, §8.1, §13.5 | `requirements.md`, `technical-design.md` data/write flow, `tasks.md` M3 | Submission integration, leaderboard variant read, missing variant fail-closed | +| Best-to-date v1 | Guide §7, §8.1, §10 M3 | `requirements.md`, `technical-design.md` write flow, `tasks.md` M3 | Keep-best-on-write, worse/equal retest ignored | +| No publish gate for benchmark | Guide §5.2, §7.4, §13.19 | `requirements.md`, `technical-design.md` failure behavior, `tasks.md` M4 | Public visibility without `divisionResults`, invalid rows excluded | +| 55-test training-guide Lite denominator | Guide §2.1, §9, §10 M1 | `requirements.md`, `technical-design.md` data model, `tasks.md` M1b/M2 | PDF-derived seed validation, derived denominator, deferred tests excluded | +| Absolute tier scoring | Guide §6.1-§6.5, §10 M2 | `technical-design.md`, `tasks.md` M2 | Tier, direction, half-tier, tiebreak, aggregation unit tests | +| Stats page | Guide §8.3, §10 M4 | `requirements.md`, `technical-design.md`, `tasks.md` M4 | Route/component tests and manual QA | +| V2 boundary | Guide §7, §10 V2 | `requirements.md`, `tasks.md` V2, `assumptions-and-decisions.md` | Migration check excludes `score_attempts` | +| PDF source data only | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `tasks.md` global criteria, `assumptions-and-decisions.md` | Extraction receipt, seed validation, and allowlisted source-data references | +| No HillerFit-branded customer-facing surface | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `tasks.md` global criteria, `test-strategy.md`, `assumptions-and-decisions.md` | Route/component/navigation/static assertions and manual QA | diff --git a/ai/research/m0-competition-type-capability-registry.md b/ai/research/m0-competition-type-capability-registry.md index ebaf33c81..ef1c123aa 100644 --- a/ai/research/m0-competition-type-capability-registry.md +++ b/ai/research/m0-competition-type-capability-registry.md @@ -1,8 +1,8 @@ # M0 — Competition-Type Capability Registry (Refactor Spec) -> **Status:** Refactor proposal for review. No code written yet. +> **Status:** Partially implemented in this checkout. The registry, helper tests, and several chokepoint refactors already exist for `"in-person"` and `"online"`; benchmark implementation should extend that current registry rather than recreate it. > **Standalone:** This refactor **ships on its own and is valuable independent of any new competition type.** It de-clutters the existing in-person/online products by replacing scattered `competitionType === "online"` / `=== "in-person"` checks with one declarative source of truth. It is the foundation that makes future types (benchmark boards, leagues, ladders, hybrid formats) cheap — but it earns its keep on the current two types alone. -> **Companion:** Phase **M0** of `hillerfit-benchmark-leaderboard-guide.md`. That guide consumes this registry to add a `"benchmark"` type later (M1); **this spec does not add `"benchmark"`** — it only refactors the two types that exist today. Adding a new type afterward is a few lines (see §9). +> **Companion:** Phase **M0** of `hillerfit-benchmark-leaderboard-guide.md`. That guide consumes this registry to add a `"benchmark"` type later (M1); **this spec's implemented base does not add `"benchmark"`** — current tests intentionally assert that unknown types fail closed. Adding benchmark means widening the registry type, adding the `perpetual` capability, and updating tests/characterization coverage (see §9). > **Migration:** None. `competitionType` stays a TS-only `varchar` discriminator; the registry is a pure function of it. --- @@ -97,7 +97,7 @@ export function isSelectableType(type: string): boolean { } ``` -> **`optInResultPublishing` — verified online-only (was the one site to confirm; now confirmed).** `competition-leaderboard.ts:440-443` resolves `divisionResults = bypassPublicationFilter ? undefined : (settings?.divisionResults ?? (competitionType === "online" ? {} : undefined))`. So absent an explicit setting, **online** defaults to `{}` (hide every division until published — opt-in) while **in-person** defaults to `undefined` (show all — backwards compat). Therefore `optInResultPublishing` is declared by `"online"` **only**; in-person does NOT carry it. The refactored call site is `... ?? (competitionCan(type, "optInResultPublishing") ? {} : undefined)` — behavior-identical for both existing types, and a future opt-in type (e.g. benchmark) gets `{}` by declaring the capability. The snapshot test (§7) pins it. +> **`optInResultPublishing` — verified online-only (was the one site to confirm; now confirmed).** `competition-leaderboard.ts:440-443` resolves `divisionResults = bypassPublicationFilter ? undefined : (settings?.divisionResults ?? (competitionType === "online" ? {} : undefined))`. So absent an explicit setting, **online** defaults to `{}` (hide every division until published — opt-in) while **in-person** defaults to `undefined` (show all — backwards compat). Therefore `optInResultPublishing` is declared by `"online"` **only**; in-person does NOT carry it. The refactored call site is `... ?? (competitionCan(type, "optInResultPublishing") ? {} : undefined)` — behavior-identical for both existing types, and a future type gets `{}` only if it explicitly declares the capability. Benchmark deliberately will not declare it. The snapshot test (§7) pins it. **Client helper (optional, for components that need several flags):** @@ -248,12 +248,12 @@ After M0, a new type is a registry entry + (if needed) one new capability — ** // later (e.g. benchmark M1) — additive, no refactor: "benchmark": { id: "benchmark", label: "Benchmark", leaderboardVariant: "online", selectableOnCreate: false, - capabilities: new Set(["videoSubmissions", "optInResultPublishing", "perpetual"]), + capabilities: new Set(["videoSubmissions", "perpetual"]), } // "perpetual" added to CompetitionCapability; the ~3 sites that should respect it read competitionCan(type,"perpetual"). ``` -It inherits video submission, opt-in publishing, the online leaderboard table, and (by omission) no heats/check-in/venue/windows — automatically, everywhere. +It inherits video submission and the online leaderboard table, and (by omission) no opt-in result publishing, heats, check-in, venue, or submission windows. Benchmark results are public on valid submission; moderation uses invalid verification status rather than per-event publish gates. ## 10. Risks & gotchas diff --git a/apps/crew/package.json b/apps/crew/package.json index c4f06a4d3..62dae7afb 100644 --- a/apps/crew/package.json +++ b/apps/crew/package.json @@ -84,12 +84,12 @@ "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router": "1.144.0", "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-ssr-query": "^1.131.7", - "@tanstack/react-start": "^1.132.0", + "@tanstack/react-start": "1.145.0", "@tanstack/react-table": "^8.21.3", - "@tanstack/router-plugin": "^1.132.0", + "@tanstack/router-plugin": "1.144.0", "@vimeo/player": "^2.30.3", "agents": "^0.12.3", "ai": "^6.0.77", diff --git a/apps/crew/src/server/crew-judge-rotations.server.ts b/apps/crew/src/server/crew-judge-rotations.server.ts index eb561fec0..b30104b94 100644 --- a/apps/crew/src/server/crew-judge-rotations.server.ts +++ b/apps/crew/src/server/crew-judge-rotations.server.ts @@ -51,6 +51,8 @@ type RotationQueryDb = Pick type CompetitionJudgeRotation = typeof competitionJudgeRotationsTable.$inferSelect type JudgeAssignmentVersion = typeof judgeAssignmentVersionsTable.$inferSelect +type StoredCompetitionType = + (typeof competitionsTable.$inferSelect)["competitionType"] type CrewJudgeHeatRow = { id: string trackWorkoutId: string @@ -74,7 +76,7 @@ export interface CrewJudgeEvent { startDate: string endDate: string timezone: string | null - competitionType: "in-person" | "online" + competitionType: StoredCompetitionType } export interface CrewJudgeWorkout { diff --git a/apps/wodsmith-gameday/package.json b/apps/wodsmith-gameday/package.json index 12eada462..c14284430 100644 --- a/apps/wodsmith-gameday/package.json +++ b/apps/wodsmith-gameday/package.json @@ -15,7 +15,7 @@ "@capacitor/core": "^7.2.0", "@capacitor/network": "^7.0.0", "@capacitor/preferences": "^7.0.0", - "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router": "1.144.0", "effect": "^3.14.0", "@effect/platform": "^0.78.0", "react": "catalog:", @@ -25,7 +25,7 @@ "devDependencies": { "@biomejs/biome": "2.0.6", "@capacitor/cli": "^7.2.0", - "@tanstack/router-plugin": "^1.132.0", + "@tanstack/router-plugin": "1.144.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "^5.0.4", diff --git a/apps/wodsmith-start/e2e/competition-registration.spec.ts b/apps/wodsmith-start/e2e/competition-registration.spec.ts index 573c70860..c23d1c120 100644 --- a/apps/wodsmith-start/e2e/competition-registration.spec.ts +++ b/apps/wodsmith-start/e2e/competition-registration.spec.ts @@ -4,6 +4,10 @@ import {TEST_DATA} from './fixtures/test-data' test.describe('Competition Registration', () => { test('should register for a competition', async ({page}) => { + // The full flow (login + popover comboboxes + payment server fn) can + // exceed the default 30s budget on cold CI runners. + test.setTimeout(60_000) + await loginAsTestUser(page) const comp = TEST_DATA.competition @@ -17,9 +21,13 @@ test.describe('Competition Registration', () => { page.getByRole('heading', {name: comp.name}), ).toBeVisible({timeout: 15000}) - // Click Register now + // A timed-out prior attempt may have completed the registration + // server-side, so on retry the CTA is replaced by the registered card. + // Treat that state as success instead of failing on a missing CTA. + const registeredCard = page.getByText(/you're registered/i).first() const registerLink = page.getByRole('link', {name: /register now/i}) - await expect(registerLink).toBeVisible({timeout: 10000}) + await expect(registerLink.or(registeredCard)).toBeVisible({timeout: 10000}) + if (await registeredCard.isVisible()) return await registerLink.click() // Should be on registration page diff --git a/apps/wodsmith-start/package.json b/apps/wodsmith-start/package.json index 729bb9e94..92c9a07ed 100644 --- a/apps/wodsmith-start/package.json +++ b/apps/wodsmith-start/package.json @@ -2,8 +2,10 @@ "name": "wodsmith-start", "private": true, "type": "module", + "portless": "wodsmith", "scripts": { "dev": "vite dev --port 3000", + "dev:multi": "portless run vite dev", "build": "vite build", "serve": "vite preview", "test": "vitest run", @@ -87,12 +89,12 @@ "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-router": "^1.132.0", + "@tanstack/react-router": "1.144.0", "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-ssr-query": "^1.131.7", - "@tanstack/react-start": "^1.132.0", + "@tanstack/react-start": "1.145.0", "@tanstack/react-table": "^8.21.3", - "@tanstack/router-plugin": "^1.132.0", + "@tanstack/router-plugin": "1.144.0", "@vimeo/player": "^2.30.3", "agents": "^0.12.3", "ai": "^6.0.77", @@ -152,6 +154,7 @@ "@vitest/coverage-v8": "3.2.3", "jsdom": "^27.0.0", "playwright": "^1.60.0", + "portless": "^0.15.1", "tsx": "^4.20.3", "typescript": "^5.7.2", "vite": "^7.3.0", diff --git a/apps/wodsmith-start/scripts/seed/cleanup.ts b/apps/wodsmith-start/scripts/seed/cleanup.ts index 991cf20ee..ab8faf487 100644 --- a/apps/wodsmith-start/scripts/seed/cleanup.ts +++ b/apps/wodsmith-start/scripts/seed/cleanup.ts @@ -21,6 +21,9 @@ export async function cleanup(client: Connection): Promise { "series_division_mappings", "series_template_divisions", // Competition deep children + "benchmark_tier_thresholds", + "benchmark_tests", + "benchmark_batteries", "score_rounds", "scores", "judge_heat_assignments", diff --git a/apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts b/apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts new file mode 100644 index 000000000..0204e75b5 --- /dev/null +++ b/apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts @@ -0,0 +1,2860 @@ +import { STATUS_ORDER } from "../../../src/lib/scoring/constants" +import { encodeScore } from "../../../src/lib/scoring/encode" +import { + computeSortKey, + sortKeyToString, +} from "../../../src/lib/scoring/sort/sort-key" +import type { ScoreType, WorkoutScheme } from "../../../src/lib/scoring/types" + +export const BENCHMARK_SEED_IDS = { + batteryId: "bbat_training_guide_v1", + competitionId: "comp_training_guide_benchmark", + competitionTeamId: "team_training_guide_benchmark", + divisionId: "slvl_training_guide_benchmark_open", + organizingTeamId: "team_cokkpu1klwo0ulfhl1iwzpvnbox1", + organizerMembershipId: "tmem_admin_training_guide_benchmark", + organizerUserId: "usr_demo1admin", + scalingGroupId: "sgrp_training_guide_benchmark_open", + trackId: "track_training_guide_benchmark", +} as const + +export const BENCHMARK_SOURCE_RECEIPT = { + sourceArtifact: { + path: "HillerFit_Training_Guide.pdf", + sha256: + "a80c7ab33874ff4fb8a4eea6a044df83511d164e55588a94ec455145a8f3cc38", + pageCount: 17, + }, + extractedPageRanges: [ + { categoryKey: "strength", pages: [8, 9] }, + { categoryKey: "gymnastics", pages: [10, 11] }, + { categoryKey: "engine", pages: [12, 13] }, + { categoryKey: "benchmark_workout", pages: [14, 15] }, + { categoryKey: "scoresheet_crosswalk", pages: [17] }, + ], + designedTestCount: 58, + includedTestCount: 58, + deferredTestCount: 0, + assumptions: [ + "The PDF is source data only; seeded app-facing names and descriptions stay generic.", + "Weighted C2B Pull Up scores the added load in pounds: the BW threshold encodes as 0 lb, +N thresholds as N lb, and athletes enter 0 for a bodyweight rep.", + "Open 16.2 and Open 18.4 use hybrid reps/time scoring: rep tiers below hybridFlipTier are reps achieved at the time cap (encoded as plain integers), while time tiers at or above hybridFlipTier are finish times (encoded as milliseconds). A capped athlete stores the cap ms as score_value with reps completed in secondary_value.", + "The v1 seed uses videoPolicy \"never\" and isOpenJoin false so athletes use the normal registration flow before submitting scores.", + "Vertical Jump is scored as whole inches via the points scheme (like BikeErg watts): the athlete score input derives its unit from the workout scheme, and the only distance schemes collect meters or feet, so integer-inch thresholds and athlete entries share the same plain-number space.", + "L Sit Hold's tier-10 standard is a 03:00 hold (the three-minute L-sit standard) rather than the source PDF's 01:30; tiers 1-9 keep the source progression.", + ], +} as const + +export const BENCHMARK_CATEGORIES = [ + { key: "strength", label: "Strength", testCount: 15, weight: 1 }, + { key: "gymnastics", label: "Gymnastics", testCount: 14, weight: 1 }, + { key: "engine", label: "Engine", testCount: 14, weight: 1 }, + { + key: "benchmark_workout", + label: "Benchmark Workouts", + testCount: 15, + weight: 1, + }, +] as const + +export const BENCHMARK_RATING_BANDS = [ + { key: "elite", label: "Elite", minScore: 90, maxScore: 100 }, + { + key: "regional_caliber", + label: "Regional caliber", + minScore: 75, + maxScore: 89.999, + }, + { + key: "seriously_trained", + label: "Seriously trained", + minScore: 60, + maxScore: 74.999, + }, + { + key: "intermediate", + label: "Intermediate", + minScore: 45, + maxScore: 59.999, + }, + { + key: "trained_beginner", + label: "Trained beginner", + minScore: 30, + maxScore: 44.999, + }, + { key: "early", label: "Early", minScore: 0, maxScore: 29.999 }, +] as const + +type BenchmarkCategoryKey = (typeof BENCHMARK_CATEGORIES)[number]["key"] +type BenchmarkVariant = "male" | "female" +type BenchmarkScoreModel = "standard" | "hybrid" +type ThresholdTuple = [string, string, string, string, string, string, string, string, string, string] + +interface RawBenchmarkTestRow { + page: number + categoryKey: BenchmarkCategoryKey + sourceSection: string + name: string + scheme: WorkoutScheme + scoreType: ScoreType + inputUnit: string + includedInScoring: boolean + scoreModel: BenchmarkScoreModel + hybridFlipTier: number | null + deferReason: string | null + /** Time cap in seconds; only set for hybrid reps/time tests. */ + timeCapSeconds?: number + thresholds: Record +} + +const RAW_BENCHMARK_TESTS = [ + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Strict Press", + "thresholds": { + "male": [ + "115", + "130", + "150", + "170", + "185", + "195", + "210", + "220", + "235", + "245" + ], + "female": [ + "75", + "90", + "100", + "110", + "125", + "135", + "140", + "150", + "155", + "165" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Push Press", + "thresholds": { + "male": [ + "165", + "190", + "215", + "240", + "265", + "280", + "295", + "305", + "320", + "335" + ], + "female": [ + "115", + "130", + "140", + "150", + "165", + "175", + "185", + "195", + "205", + "215" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Bench Press", + "thresholds": { + "male": [ + "225", + "240", + "260", + "280", + "295", + "310", + "325", + "335", + "350", + "365" + ], + "female": [ + "125", + "140", + "150", + "160", + "175", + "190", + "205", + "215", + "230", + "245" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Deadlift", + "thresholds": { + "male": [ + "275", + "325", + "375", + "425", + "475", + "505", + "530", + "560", + "585", + "615" + ], + "female": [ + "185", + "210", + "235", + "260", + "285", + "310", + "335", + "355", + "380", + "405" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Power Snatch", + "thresholds": { + "male": [ + "135", + "155", + "175", + "195", + "215", + "225", + "235", + "245", + "255", + "265" + ], + "female": [ + "95", + "110", + "120", + "130", + "145", + "150", + "160", + "165", + "175", + "180" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Squat Snatch", + "thresholds": { + "male": [ + "135", + "160", + "190", + "215", + "240", + "255", + "270", + "285", + "300", + "315" + ], + "female": [ + "95", + "110", + "125", + "140", + "155", + "170", + "180", + "195", + "205", + "220" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Power Clean", + "thresholds": { + "male": [ + "185", + "210", + "230", + "255", + "280", + "295", + "315", + "330", + "350", + "365" + ], + "female": [ + "135", + "150", + "160", + "170", + "185", + "200", + "215", + "225", + "240", + "255" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Squat Clean", + "thresholds": { + "male": [ + "185", + "220", + "250", + "280", + "315", + "335", + "350", + "370", + "385", + "405" + ], + "female": [ + "135", + "155", + "175", + "195", + "215", + "225", + "240", + "250", + "265", + "275" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Clean & Jerk", + "thresholds": { + "male": [ + "185", + "210", + "240", + "270", + "295", + "315", + "330", + "350", + "365", + "385" + ], + "female": [ + "135", + "150", + "170", + "190", + "205", + "215", + "220", + "230", + "235", + "245" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 8, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Front Squat", + "thresholds": { + "male": [ + "225", + "250", + "280", + "310", + "335", + "355", + "380", + "400", + "425", + "445" + ], + "female": [ + "155", + "170", + "185", + "200", + "215", + "225", + "240", + "250", + "265", + "275" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 9, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Back Squat", + "thresholds": { + "male": [ + "245", + "280", + "310", + "340", + "375", + "400", + "425", + "455", + "480", + "505" + ], + "female": [ + "165", + "185", + "205", + "225", + "245", + "265", + "285", + "310", + "330", + "350" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 9, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Overhead Squat", + "thresholds": { + "male": [ + "155", + "190", + "225", + "260", + "295", + "310", + "325", + "345", + "360", + "375" + ], + "female": [ + "105", + "125", + "145", + "165", + "185", + "195", + "210", + "220", + "235", + "245" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 9, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Max Reps Bench (225/155)", + "thresholds": { + "male": [ + "1", + "4", + "6", + "9", + "12", + "15", + "18", + "21", + "23", + "25" + ], + "female": [ + "1", + "3", + "6", + "9", + "11", + "13", + "16", + "18", + "21", + "23" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 9, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "20 Rep Back Squat (lb)", + "thresholds": { + "male": [ + "165", + "190", + "215", + "245", + "270", + "300", + "325", + "350", + "380", + "405" + ], + "female": [ + "115", + "135", + "150", + "170", + "185", + "205", + "220", + "240", + "255", + "275" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 9, + "categoryKey": "strength", + "sourceSection": "Strength (1RM, lb)", + "name": "Gwen in 10:00 (lb)", + "thresholds": { + "male": [ + "105", + "115", + "125", + "135", + "145", + "155", + "170", + "185", + "210", + "225" + ], + "female": [ + "80", + "85", + "90", + "95", + "100", + "105", + "120", + "135", + "145", + "155" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lb", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Strict Pull Up", + "thresholds": { + "male": [ + "5", + "8", + "12", + "15", + "18", + "21", + "25", + "28", + "32", + "35" + ], + "female": [ + "3", + "6", + "9", + "12", + "15", + "17", + "19", + "21", + "23", + "25" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Chest to Bar Pull Up", + "thresholds": { + "male": [ + "8", + "16", + "24", + "32", + "40", + "45", + "50", + "55", + "60", + "65" + ], + "female": [ + "5", + "11", + "18", + "24", + "30", + "35", + "40", + "45", + "50", + "55" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Weighted C2B Pull Up (lb)", + "thresholds": { + "male": [ + "BW", + "+5", + "25", + "45", + "55", + "70", + "85", + "100", + "115", + "130" + ], + "female": [ + "BW", + "+3", + "10", + "25", + "35", + "45", + "55", + "65", + "75", + "85" + ] + }, + "scheme": "load", + "scoreType": "max", + "inputUnit": "lbs_added", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Toes to Bar (unbroken)", + "thresholds": { + "male": [ + "10", + "15", + "20", + "25", + "30", + "42", + "54", + "66", + "78", + "90" + ], + "female": [ + "10", + "12", + "15", + "18", + "20", + "28", + "36", + "44", + "52", + "60" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Strict HSPU", + "thresholds": { + "male": [ + "5", + "9", + "12", + "16", + "20", + "26", + "32", + "38", + "44", + "50" + ], + "female": [ + "3", + "6", + "9", + "12", + "15", + "20", + "25", + "30", + "35", + "40" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Kipping Ring Muscle Up", + "thresholds": { + "male": [ + "8", + "10", + "12", + "13", + "15", + "18", + "22", + "25", + "29", + "32" + ], + "female": [ + "5", + "6", + "8", + "9", + "10", + "12", + "14", + "16", + "18", + "20" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Bar Muscle Up", + "thresholds": { + "male": [ + "3", + "6", + "9", + "12", + "15", + "20", + "25", + "30", + "35", + "40" + ], + "female": [ + "1", + "3", + "4", + "6", + "8", + "12", + "17", + "21", + "26", + "30" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Max Strict Ring Dip", + "thresholds": { + "male": [ + "5", + "10", + "15", + "20", + "25", + "29", + "33", + "37", + "41", + "45" + ], + "female": [ + "3", + "7", + "10", + "14", + "18", + "21", + "25", + "28", + "32", + "35" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "L Sit Hold", + "thresholds": { + "male": [ + "00:00:10", + "00:00:19", + "00:00:28", + "00:00:36", + "00:00:45", + "00:00:54", + "00:01:03", + "00:01:12", + "00:01:21", + "00:03:00" + ], + "female": [ + "00:00:10", + "00:00:16", + "00:00:22", + "00:00:29", + "00:00:35", + "00:00:46", + "00:00:57", + "00:01:08", + "00:01:19", + "00:03:00" + ] + }, + "scheme": "time", + "scoreType": "max", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 10, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "3:00 AMRAP GHDSU (reps)", + "thresholds": { + "male": [ + "30", + "38", + "47", + "55", + "63", + "72", + "80", + "88", + "97", + "105" + ], + "female": [ + "30", + "38", + "47", + "55", + "63", + "72", + "80", + "88", + "97", + "105" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 11, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Vertical Jump (in)", + "thresholds": { + "male": [ + "20", + "22", + "23", + "25", + "26", + "28", + "30", + "33", + "36", + "38" + ], + "female": [ + "16", + "17", + "19", + "20", + "22", + "24", + "25", + "27", + "28", + "30" + ] + }, + "scheme": "points", + "scoreType": "max", + "inputUnit": "inches", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 11, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Dead Hang", + "thresholds": { + "male": [ + "00:30", + "01:00", + "01:30", + "02:00", + "02:30", + "03:00", + "03:30", + "04:00", + "04:30", + "05:00" + ], + "female": [ + "00:30", + "00:53", + "01:17", + "01:40", + "02:03", + "02:27", + "02:50", + "03:13", + "03:37", + "04:00" + ] + }, + "scheme": "time", + "scoreType": "max", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 11, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Unbroken Handstand Walk (ft)", + "thresholds": { + "male": [ + "10", + "26", + "42", + "59", + "75", + "120", + "165", + "210", + "255", + "300" + ], + "female": [ + "10", + "26", + "42", + "59", + "75", + "120", + "165", + "210", + "255", + "300" + ] + }, + "scheme": "feet", + "scoreType": "max", + "inputUnit": "ft", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 11, + "categoryKey": "gymnastics", + "sourceSection": "Gymnastics (max reps / time)", + "name": "Unbroken Pegboard Ascents", + "thresholds": { + "male": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ], + "female": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "BikeErg 20 min Avg Watts", + "thresholds": { + "male": [ + "150", + "180", + "210", + "240", + "270", + "295", + "320", + "350", + "375", + "400" + ], + "female": [ + "100", + "135", + "170", + "200", + "235", + "245", + "255", + "270", + "280", + "290" + ] + }, + "scheme": "points", + "scoreType": "max", + "inputUnit": "watts", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Echo Bike 50 cal", + "thresholds": { + "male": [ + "00:02:45", + "00:02:18", + "00:01:50", + "00:01:22", + "00:00:55", + "00:00:50", + "00:00:45", + "00:00:40", + "00:00:35", + "00:00:30" + ], + "female": [ + "00:04:00", + "00:03:20", + "00:02:40", + "00:02:00", + "00:01:20", + "00:01:17", + "00:01:14", + "00:01:11", + "00:01:08", + "00:01:05" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Ski Erg 2K", + "thresholds": { + "male": [ + "00:08:30", + "00:08:15", + "00:08:00", + "00:07:45", + "00:07:30", + "00:07:20", + "00:07:10", + "00:07:00", + "00:06:50", + "00:06:40" + ], + "female": [ + "00:09:15", + "00:09:00", + "00:08:45", + "00:08:30", + "00:08:15", + "00:08:06", + "00:07:57", + "00:07:48", + "00:07:39", + "00:07:30" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Max Unbroken Double Unders", + "thresholds": { + "male": [ + "35", + "76", + "118", + "159", + "200", + "260", + "320", + "380", + "440", + "500" + ], + "female": [ + "35", + "76", + "118", + "159", + "200", + "260", + "320", + "380", + "440", + "500" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Beat Bagent", + "thresholds": { + "male": [ + "00:14:00", + "00:13:15", + "00:12:30", + "00:11:45", + "00:11:00", + "00:10:30", + "00:10:00", + "00:09:30", + "00:09:00", + "00:08:30" + ], + "female": [ + "00:16:00", + "00:15:08", + "00:14:15", + "00:13:22", + "00:12:30", + "00:11:54", + "00:11:18", + "00:10:42", + "00:10:06", + "00:09:30" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Regional Triple 3", + "thresholds": { + "male": [ + "00:50:00", + "00:47:00", + "00:44:00", + "00:41:00", + "00:38:00", + "00:37:00", + "00:36:00", + "00:35:00", + "00:34:00", + "00:33:00" + ], + "female": [ + "00:55:00", + "00:51:30", + "00:48:00", + "00:44:30", + "00:41:00", + "00:40:00", + "00:39:00", + "00:38:00", + "00:37:00", + "00:36:00" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "Acid Bath", + "thresholds": { + "male": [ + "05:35", + "05:30", + "05:25", + "05:20", + "05:15", + "05:10", + "05:05", + "05:00", + "04:55", + "04:50" + ], + "female": [ + "06:00", + "05:55", + "05:50", + "05:45", + "05:40", + "05:35", + "05:30", + "05:25", + "05:20", + "05:15" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "400m Sprint", + "thresholds": { + "male": [ + "00:01:40", + "00:01:31", + "00:01:22", + "00:01:14", + "00:01:05", + "00:01:03", + "00:01:01", + "00:00:59", + "00:00:57", + "00:00:55" + ], + "female": [ + "00:01:45", + "00:01:40", + "00:01:35", + "00:01:30", + "00:01:25", + "00:01:21", + "00:01:17", + "00:01:13", + "00:01:09", + "00:01:05" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "1 Mile Run", + "thresholds": { + "male": [ + "00:08:00", + "00:07:30", + "00:07:00", + "00:06:30", + "00:06:00", + "00:05:48", + "00:05:36", + "00:05:24", + "00:05:12", + "00:05:00" + ], + "female": [ + "00:08:30", + "00:08:08", + "00:07:45", + "00:07:22", + "00:07:00", + "00:06:44", + "00:06:28", + "00:06:12", + "00:05:56", + "00:05:40" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 12, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "5K Run", + "thresholds": { + "male": [ + "00:25:00", + "00:24:00", + "00:23:00", + "00:22:00", + "00:21:00", + "00:20:18", + "00:19:36", + "00:18:54", + "00:18:12", + "00:17:30" + ], + "female": [ + "00:26:00", + "00:25:15", + "00:24:30", + "00:23:45", + "00:23:00", + "00:22:06", + "00:21:12", + "00:20:18", + "00:19:24", + "00:18:30" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 13, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "10K Run", + "thresholds": { + "male": [ + "00:55:00", + "00:52:15", + "00:49:30", + "00:46:45", + "00:44:00", + "00:42:36", + "00:41:12", + "00:39:48", + "00:38:24", + "00:37:00" + ], + "female": [ + "01:00:00", + "00:57:00", + "00:54:00", + "00:51:00", + "00:48:00", + "00:46:08", + "00:44:16", + "00:42:24", + "00:40:32", + "00:38:40" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 13, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "500m Row", + "thresholds": { + "male": [ + "00:01:40", + "00:01:36", + "00:01:32", + "00:01:29", + "00:01:25", + "00:01:24", + "00:01:22", + "00:01:21", + "00:01:19", + "00:01:18" + ], + "female": [ + "00:01:55", + "00:01:50", + "00:01:45", + "00:01:40", + "00:01:35", + "00:01:34", + "00:01:32", + "00:01:31", + "00:01:29", + "00:01:28" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 13, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "2K Row", + "thresholds": { + "male": [ + "00:08:00", + "00:07:44", + "00:07:28", + "00:07:11", + "00:06:55", + "00:06:47", + "00:06:39", + "00:06:31", + "00:06:23", + "00:06:15" + ], + "female": [ + "00:08:30", + "00:08:15", + "00:08:00", + "00:07:45", + "00:07:30", + "00:07:23", + "00:07:16", + "00:07:09", + "00:07:02", + "00:06:55" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 13, + "categoryKey": "engine", + "sourceSection": "Engine (time / watts / reps)", + "name": "5K Row", + "thresholds": { + "male": [ + "00:22:30", + "00:21:38", + "00:20:45", + "00:19:52", + "00:19:00", + "00:18:32", + "00:18:04", + "00:17:36", + "00:17:08", + "00:16:40" + ], + "female": [ + "00:25:00", + "00:24:00", + "00:23:00", + "00:22:00", + "00:21:00", + "00:20:30", + "00:20:00", + "00:19:30", + "00:19:00", + "00:18:30" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Fran", + "thresholds": { + "male": [ + "00:08:00", + "00:06:45", + "00:05:30", + "00:04:15", + "00:03:00", + "00:02:47", + "00:02:34", + "00:02:21", + "00:02:08", + "00:01:55" + ], + "female": [ + "00:08:00", + "00:06:45", + "00:05:30", + "00:04:15", + "00:03:00", + "00:02:47", + "00:02:34", + "00:02:21", + "00:02:08", + "00:01:55" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Diane", + "thresholds": { + "male": [ + "00:08:00", + "00:06:41", + "00:05:22", + "00:04:04", + "00:02:45", + "00:02:36", + "00:02:27", + "00:02:18", + "00:02:09", + "00:02:00" + ], + "female": [ + "00:08:00", + "00:06:44", + "00:05:28", + "00:04:11", + "00:02:55", + "00:02:46", + "00:02:37", + "00:02:28", + "00:02:19", + "00:02:10" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Helen", + "thresholds": { + "male": [ + "00:12:00", + "00:10:51", + "00:09:42", + "00:08:34", + "00:07:25", + "00:07:19", + "00:07:13", + "00:07:07", + "00:07:01", + "00:06:55" + ], + "female": [ + "00:12:00", + "00:11:00", + "00:10:00", + "00:09:00", + "00:08:00", + "00:07:51", + "00:07:42", + "00:07:33", + "00:07:24", + "00:07:15" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Grace", + "thresholds": { + "male": [ + "00:06:00", + "00:04:58", + "00:03:55", + "00:02:52", + "00:01:50", + "00:01:42", + "00:01:34", + "00:01:26", + "00:01:18", + "00:01:10" + ], + "female": [ + "00:06:00", + "00:04:58", + "00:03:55", + "00:02:52", + "00:01:50", + "00:01:42", + "00:01:34", + "00:01:26", + "00:01:18", + "00:01:10" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Isabel", + "thresholds": { + "male": [ + "00:06:00", + "00:04:58", + "00:03:55", + "00:02:52", + "00:01:50", + "00:01:40", + "00:01:30", + "00:01:20", + "00:01:10", + "00:01:00" + ], + "female": [ + "00:06:00", + "00:04:58", + "00:03:55", + "00:02:52", + "00:01:50", + "00:01:40", + "00:01:30", + "00:01:20", + "00:01:10", + "00:01:00" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Amanda", + "thresholds": { + "male": [ + "00:10:00", + "00:08:28", + "00:06:55", + "00:05:22", + "00:03:50", + "00:03:42", + "00:03:34", + "00:03:26", + "00:03:18", + "00:03:10" + ], + "female": [ + "00:10:00", + "00:09:08", + "00:08:15", + "00:07:22", + "00:06:30", + "00:05:56", + "00:05:22", + "00:04:48", + "00:04:14", + "00:03:40" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Elizabeth", + "thresholds": { + "male": [ + "00:08:00", + "00:07:34", + "00:07:08", + "00:06:41", + "00:06:15", + "00:05:46", + "00:05:17", + "00:04:48", + "00:04:19", + "00:03:50" + ], + "female": [ + "00:08:00", + "00:07:34", + "00:07:08", + "00:06:41", + "00:06:15", + "00:05:46", + "00:05:17", + "00:04:48", + "00:04:19", + "00:03:50" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Nancy", + "thresholds": { + "male": [ + "00:18:00", + "00:16:38", + "00:15:15", + "00:13:52", + "00:12:30", + "00:12:00", + "00:11:30", + "00:11:00", + "00:10:30", + "00:10:00" + ], + "female": [ + "00:18:00", + "00:16:45", + "00:15:30", + "00:14:15", + "00:13:00", + "00:12:24", + "00:11:48", + "00:11:12", + "00:10:36", + "00:10:00" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Murph (vest)", + "thresholds": { + "male": [ + "01:00:00", + "00:54:15", + "00:48:30", + "00:42:45", + "00:37:00", + "00:36:36", + "00:36:12", + "00:35:48", + "00:35:24", + "00:35:00" + ], + "female": [ + "01:00:00", + "00:55:30", + "00:51:00", + "00:46:30", + "00:42:00", + "00:41:12", + "00:40:24", + "00:39:36", + "00:38:48", + "00:38:00" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 14, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Cindy (rounds in 20)", + "thresholds": { + "male": [ + "10", + "14", + "18", + "22", + "26", + "27", + "28", + "30", + "31", + "32" + ], + "female": [ + "10", + "14", + "18", + "22", + "26", + "27", + "28", + "30", + "31", + "32" + ] + }, + "scheme": "rounds-reps", + "scoreType": "max", + "inputUnit": "rounds", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 15, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "100 Wall Ball / 100 Cal Row", + "thresholds": { + "male": [ + "00:14:00", + "00:13:08", + "00:12:15", + "00:11:22", + "00:10:30", + "00:09:58", + "00:09:26", + "00:08:54", + "00:08:22", + "00:07:50" + ], + "female": [ + "00:16:00", + "00:15:08", + "00:14:15", + "00:13:22", + "00:12:30", + "00:11:54", + "00:11:18", + "00:10:42", + "00:10:06", + "00:09:30" + ] + }, + "scheme": "time", + "scoreType": "min", + "inputUnit": "time", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 15, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Open 14.4 (reps)", + "thresholds": { + "male": [ + "160", + "175", + "180", + "181", + "185", + "190", + "200", + "222", + "240", + "300" + ], + "female": [ + "145", + "153", + "162", + "170", + "180", + "181", + "189", + "196", + "205", + "265" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 15, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Open 16.2 (time/reps)", + "thresholds": { + "male": [ + "90", + "141", + "192", + "242", + "293", + "343", + "423", + "429", + "19:59", + "16:00" + ], + "female": [ + "90", + "141", + "192", + "242", + "293", + "343", + "423", + "429", + "19:59", + "16:00" + ] + }, + "scheme": "time-with-cap", + "scoreType": "min", + "inputUnit": "hybrid_reps_time", + "includedInScoring": true, + "scoreModel": "hybrid", + "hybridFlipTier": 9, + "timeCapSeconds": 1200, + "deferReason": null + }, + { + "page": 15, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "7 min AMRAP Burpees (reps)", + "thresholds": { + "male": [ + "63", + "75", + "87", + "98", + "110", + "123", + "130", + "138", + "144", + "150" + ], + "female": [ + "63", + "75", + "87", + "98", + "110", + "123", + "130", + "138", + "144", + "150" + ] + }, + "scheme": "reps", + "scoreType": "max", + "inputUnit": "reps", + "includedInScoring": true, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": null + }, + { + "page": 15, + "categoryKey": "benchmark_workout", + "sourceSection": "Benchmark Workouts", + "name": "Open 18.4 (reps/time)", + "thresholds": { + "male": [ + "62", + "82", + "103", + "123", + "144", + "164", + "8:30", + "7:55", + "6:50", + "6:00" + ], + "female": [ + "35", + "61", + "87", + "112", + "138", + "164", + "8:00", + "7:00", + "6:20", + "5:30" + ] + }, + "scheme": "time-with-cap", + "scoreType": "min", + "inputUnit": "hybrid_reps_time", + "includedInScoring": true, + "scoreModel": "hybrid", + "hybridFlipTier": 7, + "timeCapSeconds": 540, + "deferReason": null + } +] as const satisfies ReadonlyArray + +export interface BenchmarkSeedTest extends RawBenchmarkTestRow { + id: string + position: number + slug: string + trackWorkoutId: string + workoutId: string +} + +function slugForBenchmarkTest(name: string): string { + return name + .toLowerCase() + .replace(/&/g, "and") + .replace(/\+/g, "plus") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") +} + +export const BENCHMARK_SEED_TESTS: ReadonlyArray = + RAW_BENCHMARK_TESTS.map((test, index) => { + const slug = slugForBenchmarkTest(test.name) + return { + ...test, + id: `btst_training_guide_${slug}`, + position: index + 1, + slug, + trackWorkoutId: `tw_training_guide_${slug}`, + workoutId: `wod_training_guide_${slug}`, + } + }) + +function commonColumns(ts: string) { + return { created_at: ts, updated_at: ts, update_counter: 0 } +} + +function encodeThresholdValue( + rawValue: string, + test: BenchmarkSeedTest, + tier: number, +): number { + // Hybrid reps/time tests: tiers below the flip point are reps achieved at + // the time cap, encoded as plain integers. Tiers at/above the flip point are + // finish times and fall through to the standard time encoding below. + if ( + test.scoreModel === "hybrid" && + test.hybridFlipTier !== null && + tier < test.hybridFlipTier + ) { + if (!/^\d+$/.test(rawValue)) { + throw new Error( + `Expected integer reps for hybrid rep tier ${tier} of ${test.name}, got "${rawValue}"`, + ) + } + return Number.parseInt(rawValue, 10) + } + + let encoded: number | null + + if (test.inputUnit === "in") { + const inches = Number.parseFloat(rawValue) + encoded = Number.isFinite(inches) + ? encodeScore(String(inches / 12), "feet", { unit: "ft" }) + : null + } else if (test.inputUnit === "ft") { + encoded = encodeScore(rawValue, test.scheme, { unit: "ft" }) + } else if (test.inputUnit === "lb") { + encoded = encodeScore(rawValue, test.scheme, { unit: "lbs" }) + } else if (test.inputUnit === "lbs_added") { + // Added-load tests score only the extra weight: the source "BW" tier + // encodes as 0 lb and "+N" tiers as N lb, so athletes enter 0 for a + // bodyweight rep and the added pounds otherwise. + const numeric = rawValue === "BW" ? "0" : rawValue.replace(/^\+/, "") + encoded = encodeScore(numeric, test.scheme, { unit: "lbs" }) + } else { + encoded = encodeScore(rawValue, test.scheme) + } + + if (encoded === null) { + throw new Error( + `Unable to encode threshold ${rawValue} for ${test.name} (${test.inputUnit})`, + ) + } + + return encoded +} + +export function buildBenchmarkThresholdRows(ts: string) { + return BENCHMARK_SEED_TESTS.flatMap((test) => { + if (!test.includedInScoring) return [] + + return (["male", "female"] as const).flatMap((variant) => + test.thresholds[variant].map((rawValue, index) => { + const tier = index + 1 + return { + id: `bthr_training_guide_${test.slug}_${variant}_t${tier}`, + test_id: test.id, + variant, + tier, + threshold_value: encodeThresholdValue(rawValue, test, tier), + raw_value: rawValue, + ...commonColumns(ts), + } + }), + ) + }) +} + +const benchmarkScoringConfig = { + algorithm: "online", + tiebreaker: { primary: "countback" }, + statusHandling: { dnf: "zero", dns: "zero", withdrawn: "zero" }, +} as const + +const benchmarkCompetitionSettings = { + boardMode: "perpetual", + divisions: { scalingGroupId: BENCHMARK_SEED_IDS.scalingGroupId }, + scoringConfig: benchmarkScoringConfig, +} as const + +export function buildBenchmarkSeedRows(ts: string) { + return { + teams: [ + { + id: BENCHMARK_SEED_IDS.competitionTeamId, + name: "Training Guide Benchmark Athletes", + slug: "training-guide-benchmark-athletes", + type: "competition_event", + description: "Athlete team for the generic benchmark board.", + is_personal_team: 0, + personal_team_owner_id: null, + current_plan_id: null, + parent_organization_id: BENCHMARK_SEED_IDS.organizingTeamId, + ...commonColumns(ts), + }, + ], + teamMemberships: [ + { + id: BENCHMARK_SEED_IDS.organizerMembershipId, + team_id: BENCHMARK_SEED_IDS.competitionTeamId, + user_id: BENCHMARK_SEED_IDS.organizerUserId, + role_id: "admin", + is_system_role: 1, + joined_at: ts, + is_active: 1, + metadata: null, + ...commonColumns(ts), + }, + ], + scalingGroups: [ + { + id: BENCHMARK_SEED_IDS.scalingGroupId, + title: "Benchmark Board Division", + description: "Single Open division for the benchmark board.", + team_id: BENCHMARK_SEED_IDS.organizingTeamId, + is_default: 0, + is_system: 0, + ...commonColumns(ts), + }, + ], + scalingLevels: [ + { + id: BENCHMARK_SEED_IDS.divisionId, + scaling_group_id: BENCHMARK_SEED_IDS.scalingGroupId, + label: "Open", + position: 0, + team_size: 1, + ...commonColumns(ts), + }, + ], + competitions: [ + { + id: BENCHMARK_SEED_IDS.competitionId, + organizing_team_id: BENCHMARK_SEED_IDS.organizingTeamId, + competition_team_id: BENCHMARK_SEED_IDS.competitionTeamId, + group_id: null, + slug: "training-guide-benchmark", + name: "Training Guide Benchmark", + description: + "A generic, always-open benchmark board with one Open division and fixed tier thresholds.", + start_date: "2026-01-01", + end_date: "2026-12-31", + registration_opens_at: "2026-01-01", + registration_closes_at: null, + timezone: "America/Denver", + settings: JSON.stringify(benchmarkCompetitionSettings), + default_registration_fee_cents: 0, + visibility: "public", + status: "published", + competition_type: "benchmark", + ...commonColumns(ts), + }, + ], + competitionDivisions: [ + { + id: "cdiv_training_guide_benchmark_open", + competition_id: BENCHMARK_SEED_IDS.competitionId, + division_id: BENCHMARK_SEED_IDS.divisionId, + fee_cents: 0, + description: "Open individual benchmark division.", + max_spots: null, + ...commonColumns(ts), + }, + ], + programmingTracks: [ + { + id: BENCHMARK_SEED_IDS.trackId, + name: "Training Guide Benchmark Tests", + description: "Generic benchmark tests seeded from a source training guide.", + type: "team_owned", + owner_team_id: BENCHMARK_SEED_IDS.organizingTeamId, + scaling_group_id: BENCHMARK_SEED_IDS.scalingGroupId, + is_public: 0, + competition_id: BENCHMARK_SEED_IDS.competitionId, + ...commonColumns(ts), + }, + ], + workouts: BENCHMARK_SEED_TESTS.map((test) => ({ + id: test.workoutId, + name: test.name, + description: + test.scoreModel === "hybrid" + ? "Hybrid reps/time benchmark test. Finishers enter their time; capped athletes enter reps completed at the cap." + : test.inputUnit === "lbs_added" + ? "Benchmark test scored as added weight in pounds. Enter 0 if bodyweight." + : test.includedInScoring + ? `Benchmark test using ${test.inputUnit} thresholds.` + : `Deferred benchmark test: ${test.deferReason}`, + scope: "public", + scheme: test.scheme, + score_type: test.scoreType, + rounds_to_score: 1, + team_id: BENCHMARK_SEED_IDS.organizingTeamId, + time_cap: test.timeCapSeconds ?? null, + scaling_group_id: BENCHMARK_SEED_IDS.scalingGroupId, + ...commonColumns(ts), + })), + trackWorkouts: BENCHMARK_SEED_TESTS.map((test) => ({ + id: test.trackWorkoutId, + track_id: BENCHMARK_SEED_IDS.trackId, + workout_id: test.workoutId, + parent_event_id: null, + track_order: test.position, + notes: test.includedInScoring ? null : test.deferReason, + points_multiplier: 100, + heat_status: "draft", + // All benchmark track workouts are published so every test — including + // the deferred ones — maps onto the perpetual board. The public + // leaderboard read fetches published-only events, and + // loadBenchmarkLeaderboardContext requires a 1:1 test↔track-workout + // mapping for every test, so a draft event here would throw + // "Benchmark test … is missing a mapped track workout". Deferred tests + // stay out of scoring via includedInScoring=false, not via draft status. + event_status: "published", + benchmark_test_id: test.id, + benchmark_category: test.categoryKey, + ...commonColumns(ts), + })), + competitionEvents: [], + benchmarkBatteries: [ + { + id: BENCHMARK_SEED_IDS.batteryId, + owner_team_id: BENCHMARK_SEED_IDS.organizingTeamId, + owner_key: `${BENCHMARK_SEED_IDS.organizingTeamId}:training-guide-benchmark-v1`, + slug: "training-guide-benchmark-v1", + name: "Training Guide Benchmark", + description: + "Generic benchmark battery extracted from a source training guide PDF.", + categories: JSON.stringify(BENCHMARK_CATEGORIES), + rating_bands: JSON.stringify(BENCHMARK_RATING_BANDS), + max_tier: 10, + score_max: 100, + video_policy: "never", + is_open_join: false, + variant_scaling_group_id: null, + competition_id: BENCHMARK_SEED_IDS.competitionId, + status: "published", + ...commonColumns(ts), + }, + ], + benchmarkTests: BENCHMARK_SEED_TESTS.map((test) => ({ + id: test.id, + battery_id: BENCHMARK_SEED_IDS.batteryId, + category_key: test.categoryKey, + name: test.name, + position: test.position, + scheme: test.scheme, + score_type: test.scoreType, + input_unit: test.inputUnit, + included_in_scoring: test.includedInScoring, + time_cap_ms: test.timeCapSeconds ? test.timeCapSeconds * 1000 : null, + score_model: test.scoreModel, + hybrid_flip_tier: test.hybridFlipTier, + hybrid_scale: null, + ...commonColumns(ts), + })), + benchmarkTierThresholds: buildBenchmarkThresholdRows(ts), + } +} + +export interface BenchmarkSeedAthlete { + key: string + userId: string + membershipId: string + registrationId: string + variant: BenchmarkVariant + /** Typical tier (1-10) this athlete lands on across the battery. */ + baseTier: number + /** Per-category tier adjustment to give each athlete a distinct profile. */ + categoryBias: Partial> +} + +function benchmarkAthlete( + key: string, + userId: string, + variant: BenchmarkVariant, + baseTier: number, + categoryBias: Partial> = {}, +): BenchmarkSeedAthlete { + return { + key, + userId, + membershipId: `tmem_${key}_training_guide`, + registrationId: `creg_${key}_training_guide`, + variant, + baseTier, + categoryBias, + } +} + +/** + * Ten seeded athletes (users come from 03-users) spanning the rating bands. + * Male and female athletes score against their own variant thresholds. + */ +export const BENCHMARK_SEED_ATHLETES: ReadonlyArray = [ + benchmarkAthlete("tyler", "usr_athlete_tyler", "male", 9), + benchmarkAthlete("nathan", "usr_athlete_nathan", "male", 7, { + engine: 1, + strength: -1, + }), + benchmarkAthlete("derek", "usr_athlete_derek", "male", 6, { strength: 1 }), + benchmarkAthlete("marcus", "usr_athlete_marcus", "male", 5, { + strength: 2, + engine: -1, + }), + benchmarkAthlete("alex", "usr_athlete_alex", "male", 3, { engine: 2 }), + benchmarkAthlete("sarah", "usr_athlete_sarah", "female", 8, { + gymnastics: 1, + }), + benchmarkAthlete("megan", "usr_athlete_megan", "female", 6, { + benchmark_workout: 1, + }), + benchmarkAthlete("lauren", "usr_athlete_lauren", "female", 5, { + gymnastics: -1, + engine: 1, + }), + benchmarkAthlete("nicole", "usr_athlete_nicole", "female", 4), + benchmarkAthlete("ashley", "usr_athlete_ashley", "female", 2, { + strength: 1, + }), +] + +/** + * Deterministic tier for an athlete on a test: base tier plus category bias + * plus a -2..+2 jitter derived from the test position, clamped to 1..10. + */ +function benchmarkTierFor( + athlete: BenchmarkSeedAthlete, + athleteIndex: number, + test: BenchmarkSeedTest, +): number { + const bias = athlete.categoryBias[test.categoryKey] ?? 0 + const jitter = ((test.position * 7 + athleteIndex * 3) % 5) - 2 + return Math.min(10, Math.max(1, athlete.baseTier + bias + jitter)) +} + +/** + * Memberships on the competition team, registrations into the Open division, + * and one score per athlete per included test. Score values sit exactly on a + * tier threshold so the achieved tier is known by construction. + */ +export function buildBenchmarkAthleteSeedRows(ts: string) { + const scores = BENCHMARK_SEED_ATHLETES.flatMap((athlete, athleteIndex) => + BENCHMARK_SEED_TESTS.filter((test) => test.includedInScoring).map( + (test) => { + const tier = benchmarkTierFor(athlete, athleteIndex, test) + const rawValue = test.thresholds[athlete.variant][tier - 1] + const capMs = test.timeCapSeconds + ? test.timeCapSeconds * 1000 + : null + const isHybridRepTier = + test.scoreModel === "hybrid" && + test.hybridFlipTier !== null && + tier < test.hybridFlipTier + + // Shared columns. batchInsert derives the column list from the first + // row, so every score row must carry the same keys — including + // time_cap_ms and secondary_value even when they are null. + const base = { + id: `scr_tgb_${athlete.key}_${test.slug}`, + user_id: athlete.userId, + team_id: BENCHMARK_SEED_IDS.organizingTeamId, + workout_id: test.workoutId, + competition_event_id: test.trackWorkoutId, + scheme: test.scheme, + score_type: test.scoreType, + scaling_level_id: BENCHMARK_SEED_IDS.divisionId, + benchmark_variant: athlete.variant, + as_rx: 1, + recorded_at: ts, + ...commonColumns(ts), + } + + if (isHybridRepTier) { + // Capped athlete: stores the cap time as the primary value with + // reps completed in secondary_value. Hybrid tests always have a cap. + const cap = capMs as number + const reps = Number.parseInt(rawValue, 10) + return { + ...base, + score_value: cap, + status: "cap", + status_order: STATUS_ORDER.cap, + time_cap_ms: cap, + secondary_value: reps, + sort_key: sortKeyToString( + computeSortKey({ + value: cap, + status: "cap", + scheme: test.scheme, + scoreType: test.scoreType, + timeCap: { ms: cap, secondaryValue: reps }, + }), + ), + } + } + + const scoreValue = encodeThresholdValue(rawValue, test, tier) + return { + ...base, + score_value: scoreValue, + status: "scored", + status_order: 0, + time_cap_ms: capMs, + secondary_value: null, + sort_key: sortKeyToString( + computeSortKey({ + value: scoreValue, + status: "scored", + scheme: test.scheme, + scoreType: test.scoreType, + }), + ), + } + }, + ), + ) + + return { + teamMemberships: BENCHMARK_SEED_ATHLETES.map((athlete) => ({ + id: athlete.membershipId, + team_id: BENCHMARK_SEED_IDS.competitionTeamId, + user_id: athlete.userId, + role_id: "member", + is_system_role: 1, + joined_at: ts, + is_active: 1, + metadata: null, + ...commonColumns(ts), + })), + competitionRegistrations: BENCHMARK_SEED_ATHLETES.map((athlete) => ({ + id: athlete.registrationId, + event_id: BENCHMARK_SEED_IDS.competitionId, + user_id: athlete.userId, + team_member_id: athlete.membershipId, + division_id: BENCHMARK_SEED_IDS.divisionId, + captain_user_id: athlete.userId, + registered_at: ts, + payment_status: "FREE", + paid_at: null, + ...commonColumns(ts), + })), + scores, + } +} diff --git a/apps/wodsmith-start/scripts/seed/index.ts b/apps/wodsmith-start/scripts/seed/index.ts index 67aa7cd65..5881d57d6 100644 --- a/apps/wodsmith-start/scripts/seed/index.ts +++ b/apps/wodsmith-start/scripts/seed/index.ts @@ -37,6 +37,7 @@ import { seed as seedBroadcasts } from "./seeders/19-broadcasts" import { seed as seedCompetitionInvites } from "./seeders/20-competition-invites" import { seed as seedCohosts } from "./seeders/21-cohosts" import { seed as seedRouteDocs } from "./seeders/22-route-docs" +import { seed as seedBenchmark } from "./seeders/23-benchmark" const seeders = [ { name: "01-global-defaults", fn: seedGlobalDefaults }, @@ -62,6 +63,7 @@ const seeders = [ { name: "20-competition-invites", fn: seedCompetitionInvites }, { name: "21-cohosts", fn: seedCohosts }, { name: "22-route-docs", fn: seedRouteDocs }, + { name: "23-benchmark", fn: seedBenchmark }, ] async function main() { diff --git a/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts b/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts index 7d281183e..29185f678 100644 --- a/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts +++ b/apps/wodsmith-start/scripts/seed/seeders/02-billing.ts @@ -175,6 +175,17 @@ export async function seed(client: Connection): Promise { updated_at: ts, update_counter: 0, }, + { + id: "feat_create_benchmarks", + key: "create_benchmarks", + name: "Create Benchmarks", + description: "Create perpetual benchmark competition boards", + category: "team", + is_active: 1, + created_at: ts, + updated_at: ts, + update_counter: 0, + }, { id: "feat_workout_tracking", key: "workout_tracking", diff --git a/apps/wodsmith-start/scripts/seed/seeders/06-team-entitlements.ts b/apps/wodsmith-start/scripts/seed/seeders/06-team-entitlements.ts index 9b1328962..300709831 100644 --- a/apps/wodsmith-start/scripts/seed/seeders/06-team-entitlements.ts +++ b/apps/wodsmith-start/scripts/seed/seeders/06-team-entitlements.ts @@ -33,6 +33,16 @@ export async function seed(client: Connection): Promise { tfe("tfe_box1_ai_workout", "team_cokkpu1klwo0ulfhl1iwzpvnbox1", "feat_ai_workout_generation", "pro"), tfe("tfe_box1_multi_team", "team_cokkpu1klwo0ulfhl1iwzpvnbox1", "feat_multi_team_management", "pro"), tfe("tfe_box1_host_comp", "team_cokkpu1klwo0ulfhl1iwzpvnbox1", "feat_host_competitions", "pro"), + { + id: "tfe_box1_create_benchmarks", + team_id: "team_cokkpu1klwo0ulfhl1iwzpvnbox1", + feature_id: "feat_create_benchmarks", + source: "override", + source_plan_id: null, + created_at: cts, + updated_at: cts, + update_counter: 0, + }, // Winter Throwdown (inherits Pro) tfe("tfe_winter_basic", "team_winter_throwdown_2025", "feat_basic_workouts", "pro"), tfe("tfe_winter_tracks", "team_winter_throwdown_2025", "feat_programming_tracks", "pro"), diff --git a/apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts b/apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts new file mode 100644 index 000000000..1a3b210cf --- /dev/null +++ b/apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts @@ -0,0 +1,41 @@ +import type { Connection } from "mysql2/promise" +import { + buildBenchmarkAthleteSeedRows, + buildBenchmarkSeedRows, +} from "../data/benchmark-training-guide" +import { batchInsert, now } from "../helpers" + +export async function seed(client: Connection): Promise { + console.log("Seeding benchmark battery data...") + + const ts = now() + const rows = buildBenchmarkSeedRows(ts) + const athleteRows = buildBenchmarkAthleteSeedRows(ts) + + await batchInsert(client, "teams", rows.teams) + await batchInsert(client, "team_memberships", rows.teamMemberships) + await batchInsert(client, "scaling_groups", rows.scalingGroups) + await batchInsert(client, "scaling_levels", rows.scalingLevels) + await batchInsert(client, "competitions", rows.competitions) + await batchInsert(client, "competition_divisions", rows.competitionDivisions) + await batchInsert(client, "programming_tracks", rows.programmingTracks) + await batchInsert(client, "workouts", rows.workouts) + await batchInsert(client, "track_workouts", rows.trackWorkouts) + await batchInsert(client, "benchmark_batteries", rows.benchmarkBatteries) + await batchInsert(client, "benchmark_tests", rows.benchmarkTests) + await batchInsert( + client, + "benchmark_tier_thresholds", + rows.benchmarkTierThresholds, + ) + + // Athletes: memberships on the competition team, Open-division + // registrations, and tier-anchored scores for every included test. + await batchInsert(client, "team_memberships", athleteRows.teamMemberships) + await batchInsert( + client, + "competition_registrations", + athleteRows.competitionRegistrations, + ) + await batchInsert(client, "scores", athleteRows.scores) +} diff --git a/apps/wodsmith-start/src/components/benchmark-stat-line.tsx b/apps/wodsmith-start/src/components/benchmark-stat-line.tsx new file mode 100644 index 000000000..4b51b1084 --- /dev/null +++ b/apps/wodsmith-start/src/components/benchmark-stat-line.tsx @@ -0,0 +1,362 @@ +"use client" + +import { Link } from "@tanstack/react-router" +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Loader2, + MinusCircle, +} from "lucide-react" +import { useCallback, useState } from "react" +import { VideoSubmissionForm } from "@/components/compete/video-submission-form" +import { Badge } from "@/components/ui/badge" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { cn } from "@/lib/utils" +import type { CompetitionLeaderboardEntry } from "@/server-fns/leaderboard-fns" +import { + getVideoSubmissionFn, + type VideoSubmissionResult, +} from "@/server-fns/video-submission-fns" + +interface BenchmarkTestSubmissionContext { + competitionId: string + slug: string + divisionId: string + divisionLabel: string + timezone?: string | null + onSubmitSuccess: () => void +} + +interface BenchmarkStatLineProps { + entry: CompetitionLeaderboardEntry + /** + * When provided, each test row becomes an expandable score-submission + * dropdown. Only supplied for the viewer's own entry on competitions that + * support video submissions. + */ + submission?: BenchmarkTestSubmissionContext +} + +type TestState = + | "unavailable" + | "excluded" + | "untested" + | "pending" + | "verified" + | "adjusted" + | "penalized" + | "tier_0" + | "logged" + +const stateLabels: Record = { + unavailable: "Unavailable", + excluded: "Excluded", + untested: "Untested", + pending: "Pending", + verified: "Verified", + adjusted: "Adjusted", + penalized: "Penalized", + tier_0: "Tier 0", + logged: "Logged", +} + +function formatBenchmarkNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} + +function getTestState( + result: CompetitionLeaderboardEntry["eventResults"][number], +): TestState { + const reviewStatus = result.reviewSummary?.worstStatus ?? null + + if (result.benchmarkIncludedInScoring === false) return "unavailable" + if (result.verificationStatus === "invalid" || reviewStatus === "invalid") { + return "excluded" + } + if (reviewStatus === "pending" || reviewStatus === "under_review") { + return "pending" + } + if (result.rawScore === null) return "untested" + if (result.penaltyType || reviewStatus === "penalized") return "penalized" + if ( + result.isDirectlyModified || + result.verificationStatus === "adjusted" || + reviewStatus === "adjusted" + ) { + return "adjusted" + } + if (result.verificationStatus === "verified" || reviewStatus === "verified") { + return "verified" + } + if (result.benchmarkTier === 0) return "tier_0" + return "logged" +} + +function StateBadge({ state }: { state: TestState }) { + const Icon = + state === "verified" + ? CheckCircle2 + : state === "pending" + ? Clock + : state === "untested" || state === "unavailable" + ? MinusCircle + : AlertTriangle + + return ( + + + {stateLabels[state]} + + ) +} + +export function BenchmarkStatLine({ + entry, + submission, +}: BenchmarkStatLineProps) { + const overall = entry.benchmarkOverallScore ?? entry.totalPoints + const tests = [...entry.eventResults].sort( + (a, b) => a.trackOrder - b.trackOrder, + ) + + return ( +
+
+
+
+ Overall +
+
+ + {formatBenchmarkNumber(overall)} + + + {entry.benchmarkOverallScore === null ? "pts" : "/100"} + +
+ {entry.benchmarkRatingBand ? ( + + {entry.benchmarkRatingBand.label} + + ) : null} +
+ +
+
+ Categories +
+
+ {entry.benchmarkCategoryScores.map((category) => ( +
+
+ + {category.label ?? category.key} + + + {formatBenchmarkNumber(category.score)} + +
+
+
+
+
+ {formatBenchmarkNumber(category.tierSum)} tier points across{" "} + {category.testCount} tests +
+
+ ))} +
+
+
+ +
+
+

Test Grid

+
+
+ {tests.map((result) => ( + + ))} +
+
+
+ ) +} + +function TestRow({ + result, + submission, +}: { + result: CompetitionLeaderboardEntry["eventResults"][number] + submission?: BenchmarkTestSubmissionContext +}) { + const state = getTestState(result) + // Deferred tests (excluded from scoring) reject submissions server-side, so + // their rows stay read-only instead of offering a form that can only fail. + const submittable = state === "unavailable" ? undefined : submission + const [open, setOpen] = useState(false) + const [formData, setFormData] = useState(null) + const [formLoading, setFormLoading] = useState(false) + const [formError, setFormError] = useState(false) + + const competitionId = submittable?.competitionId + const divisionId = submittable?.divisionId + + const loadFormData = useCallback(() => { + if (!competitionId || !divisionId) return + setFormLoading(true) + setFormError(false) + getVideoSubmissionFn({ + data: { + trackWorkoutId: result.trackWorkoutId, + competitionId, + divisionId, + }, + }) + .then(setFormData) + .catch(() => setFormError(true)) + .finally(() => setFormLoading(false)) + }, [result.trackWorkoutId, competitionId, divisionId]) + + const handleOpenChange = (next: boolean) => { + setOpen(next) + if (next && !formData && !formLoading) { + loadFormData() + } + } + + const cells = ( + <> +
+
{result.eventName}
+
+ {result.benchmarkCategoryLabel ?? "Benchmark"} +
+
+
+
Score
+
+ {result.rawScore === null ? "-" : result.formattedScore} +
+
+
+
Tier
+
+ {result.benchmarkTier === null + ? "-" + : formatBenchmarkNumber(result.benchmarkTier)} +
+
+
+ + {submittable ? ( + + ) : null} +
+ + ) + + if (!submittable) { + return ( +
+ {cells} +
+ ) + } + + const linkEventId = result.parentEventId ?? result.trackWorkoutId + + return ( + + + + + +
+ {formLoading ? ( +
+ + Loading submission form... +
+ ) : formError ? ( +
+

+ Failed to load the submission form. +

+ +
+ ) : formData ? ( + + ) : null} + + View full workout + + +
+
+
+ ) +} diff --git a/apps/wodsmith-start/src/components/benchmark-tiers/benchmark-settings-form.tsx b/apps/wodsmith-start/src/components/benchmark-tiers/benchmark-settings-form.tsx new file mode 100644 index 000000000..8d326d4b4 --- /dev/null +++ b/apps/wodsmith-start/src/components/benchmark-tiers/benchmark-settings-form.tsx @@ -0,0 +1,256 @@ +"use client" + +import { AlertTriangle, Loader2, Save } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import type { BenchmarkVideoPolicy } from "@/schemas/benchmark.schema" + +export interface BenchmarkSettingsDraft { + name: string + description?: string | null + scoreMax: number + maxTier: number + videoPolicy: BenchmarkVideoPolicy +} + +export interface BenchmarkSettingsFormProps { + settings: { + name: string + description: string | null + scoreMax: number + maxTier: number + videoPolicy: string + } + disabled?: boolean + onSave: (settings: BenchmarkSettingsDraft) => Promise +} + +const VIDEO_POLICY_OPTIONS: ReadonlyArray<{ + value: BenchmarkVideoPolicy + label: string +}> = [ + { value: "never", label: "Never require video" }, + { value: "for_top_scores", label: "Require for top scores" }, + { value: "always", label: "Always require video" }, +] + +function isVideoPolicy(value: string): value is BenchmarkVideoPolicy { + return VIDEO_POLICY_OPTIONS.some((option) => option.value === value) +} + +export function BenchmarkSettingsForm({ + settings, + disabled = false, + onSave, +}: BenchmarkSettingsFormProps) { + const [name, setName] = useState(settings.name) + const [description, setDescription] = useState(settings.description ?? "") + const [scoreMax, setScoreMax] = useState(String(settings.scoreMax)) + const [maxTier, setMaxTier] = useState(String(settings.maxTier)) + const [videoPolicy, setVideoPolicy] = useState(settings.videoPolicy) + const [isSaving, setIsSaving] = useState(false) + + // Re-sync the draft whenever a save (or another editor on the page) + // refreshes the summary from the server. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on server state change only + useEffect(() => { + setName(settings.name) + setDescription(settings.description ?? "") + setScoreMax(String(settings.scoreMax)) + setMaxTier(String(settings.maxTier)) + setVideoPolicy(settings.videoPolicy) + }, [ + settings.name, + settings.description, + settings.scoreMax, + settings.maxTier, + settings.videoPolicy, + ]) + + const parsedScoreMax = Number(scoreMax) + const parsedMaxTier = Number(maxTier) + + const isValid = + name.trim().length > 0 && + Number.isInteger(parsedScoreMax) && + parsedScoreMax > 0 && + Number.isInteger(parsedMaxTier) && + parsedMaxTier > 0 && + isVideoPolicy(videoPolicy) + + const isDirty = + name !== settings.name || + description !== (settings.description ?? "") || + scoreMax !== String(settings.scoreMax) || + maxTier !== String(settings.maxTier) || + videoPolicy !== settings.videoPolicy + + const canSave = !disabled && !isSaving && isValid && isDirty + + const tierDecreased = + Number.isInteger(parsedMaxTier) && parsedMaxTier < settings.maxTier + const tierIncreased = + Number.isInteger(parsedMaxTier) && parsedMaxTier > settings.maxTier + + const handleSave = async () => { + if (!canSave || !isVideoPolicy(videoPolicy)) return + setIsSaving(true) + try { + const trimmedDescription = description.trim() + await onSave({ + name: name.trim(), + description: trimmedDescription.length > 0 ? trimmedDescription : null, + scoreMax: parsedScoreMax, + maxTier: parsedMaxTier, + videoPolicy, + }) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save benchmark settings", + ) + } finally { + setIsSaving(false) + } + } + + return ( + + + Benchmark settings + + Name your benchmark and choose the scoring scale athletes see. + + + +
+
+ + setName(event.target.value)} + disabled={isSaving} + placeholder="e.g. Gym Fitness Benchmark" + /> +
+ +
+ + +

+ When athletes must attach a video with their score. +

+
+
+ +
+ +