From 54ce9c8c4687857c9649d971014d2b5a06ccffe9 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 17:09:26 -0600 Subject: [PATCH 01/51] docs: prepare benchmark leaderboard implementation plan --- .../hillerfit-benchmark-leaderboard-guide.md | 81 +++++++++--------- .../hillerfit-benchmark-leaderboard/README.md | 21 +++++ .../assumptions-and-decisions.md | 45 ++++++++++ .../requirements.md | 56 +++++++++++++ .../reviewer-alignment.md | 47 +++++++++++ .../hillerfit-benchmark-leaderboard/tasks.md | 83 +++++++++++++++++++ .../technical-design.md | 76 +++++++++++++++++ .../test-strategy.md | 60 ++++++++++++++ .../traceability.md | 16 ++++ ...m0-competition-type-capability-registry.md | 10 +-- 10 files changed, 450 insertions(+), 45 deletions(-) create mode 100644 ai/research/hillerfit-benchmark-leaderboard/README.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/requirements.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/tasks.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/technical-design.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/test-strategy.md create mode 100644 ai/research/hillerfit-benchmark-leaderboard/traceability.md diff --git a/ai/research/hillerfit-benchmark-leaderboard-guide.md b/ai/research/hillerfit-benchmark-leaderboard-guide.md index cd68e81f9..3047638b6 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 -> **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:** Design proposal for review (**revision 2** — a second adversarial critique was run against the codebase and incorporated). No benchmark feature code has been written; the existing capability-registry base is already present for current competition types. This is the document Zac reviews *before* implementation begins. +> **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. **We are not building HillerFit-branded pages, routes, marketing surfaces, or theme treatments.** 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: the competition capability registry already exists for `"in-person"`/`"online"` and intentionally fails closed for `"benchmark"`; benchmark work extends that registry instead of rebuilding M0 from scratch. > > **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. | # | 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 and branding boundary | **Use `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` as the benchmark data source; do not build HillerFit-branded pages** | 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 surface 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, not HillerFit-branded pages. 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 `/`. | @@ -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 HillerFit-branded pages. - **"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. @@ -926,18 +927,18 @@ The HillerFit-specific content is **data, not code**: 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. | `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 HillerFit-branded pages, routes, marketing surfaces, or theme treatments. 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..918e8b740 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/README.md @@ -0,0 +1,21 @@ +# HillerFit 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 + +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, or baked-constant Weighted C2B unless the decision sheet is updated first. 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..7138ec192 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md @@ -0,0 +1,45 @@ +# 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. +- Do not build HillerFit-branded pages, routes, marketing surfaces, logos, or theme treatments. + +## 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. + +## 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 or product navigation. +- 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/requirements.md b/ai/research/hillerfit-benchmark-leaderboard/requirements.md new file mode 100644 index 000000000..16e5e7042 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/requirements.md @@ -0,0 +1,56 @@ +# 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`. +- We are not building HillerFit-branded pages, routes, marketing surfaces, logos, or theme treatments. The UI remains WODsmith's generic benchmark board/stat-line experience. +- 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 or branded route as a substitute for missing seed data. + +## 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, marketing copy, logos, visual theme, or product navigation. + +## Definition Of Done + +v1 is done when a seeded benchmark board can be created, 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, and the focused automated/manual tests in `test-strategy.md` pass. + +## Current Repository Baseline + +As of this packet, `apps/wodsmith-start/src/lib/competitions/capabilities.ts` already defines `competitionCan`, `leaderboardVariant`, selectability helpers, result-entry helpers, and a registry for `in-person` and `online`. The existing test suite intentionally expects `benchmark` to be unknown and fail closed. The first benchmark task updates that baseline deliberately. 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..39cd67484 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md @@ -0,0 +1,47 @@ +# Reviewer Alignment + +This file records independent reviewer summaries and the final convergence check. It starts with the orchestrator's current contract and should be updated after independent review. + +## Reviewer A Summary + +Reviewer A found that the largest remaining gap was current-state drift: the guide framed M0a as a new registry deliverable, but this checkout already has `apps/wodsmith-start/src/lib/competitions/capabilities.ts` and tests for `in-person`/`online`, with `benchmark` intentionally failing closed. The implementation contract now says to extend the existing registry, add `benchmark` and `perpetual`, preserve current tests, and add benchmark characterization tests. + +## Reviewer B Summary + +Reviewer B independently described the same intended system: capability registry, benchmark schema/seed, `absolute_tier` scoring, category aggregation, keep-best-on-write submission, public Overall/100 leaderboard, and stats page. Their largest gap was that the guide referenced an implementation packet that did not exist in their fork; the packet now exists and is the implementation contract. + +## Orchestrator Contract + +Components: + +- Competition-type capability registry. +- Benchmark schema and seed. +- Absolute-tier scoring and category aggregation. +- Benchmark submission wrapper on the existing video submission path. +- Existing leaderboard pipeline extended for benchmark context. +- Public stats route and benchmark-flavored leaderboard display. + +Data model: + +- Three v1 benchmark definition tables. +- Shared `trackWorkouts` benchmark binding columns. +- Shared `scores.benchmarkVariant` snapshot column. +- One Open division per benchmark competition. +- `score_attempts` deferred to v2. + +Dependencies: + +- M0a before benchmark write/read chokepoints. +- M1 seed/schema before algorithm integration tests. +- M2 algorithm before M3 keep-best-on-write. +- M3 submission before M4 public demo verification. +- First seed derived from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- No HillerFit-branded pages or routes. + +Definition of done: + +- Seeded approved board, absolute-tier scoring, best-to-date submission semantics, public Overall/100 leaderboard, public stat line, focused tests, LAT updates, and manual smoke all pass. + +## Convergence Result + +The reviewers materially agree on components, data model, dependencies, and definition of done. The one substantive divergence risk was M0a current-state drift, now resolved by making the packet current-state-aware. The owner clarified that the build uses the training PDF as benchmark source data and does not include HillerFit-branded pages. diff --git a/ai/research/hillerfit-benchmark-leaderboard/tasks.md b/ai/research/hillerfit-benchmark-leaderboard/tasks.md new file mode 100644 index 000000000..47e661ec9 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -0,0 +1,83 @@ +# 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`. + +## 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. + +## M1 Schema And PDF-Derived Seed + +Add benchmark type, benchmark tables, shared columns, validation schemas, and the training-PDF-derived seed. + +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. +- 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 or companion receipt records extraction assumptions and any intentionally deferred tests. +- No task creates HillerFit-branded pages, routes, marketing copy, logos, or theme treatments. + +## 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. + +## 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. + +## 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. +- 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. +- 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 copy. + +## 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..901fad07f --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -0,0 +1,76 @@ +# 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`. + +## Data Model + +### New Tables + +- `benchmark_batteries`: named product, owner, slug, categories JSON, rating bands JSON, `maxTier`, `scoreMax`, `videoPolicy`, `isOpenJoin`, `variantScalingGroupId`, `competitionId`, status, and non-null `ownerKey` for global slug uniqueness. +- `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. +- M1 schema/seed 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. +- M1 seed data depends on extracting tests/thresholds from the local training PDF and recording extraction assumptions. + +## 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 fetches registrations and scores as today, including `scores.benchmarkVariant`. +4. For each event group, it creates `EventScoreInput` with `variant` from `scores.benchmarkVariant`. +5. `calculateEventPoints` dispatches to `absolute_tier` with preloaded `ctx`. +6. Event results carry `tier` and `benchmarkCategory`. +7. Category aggregation computes category scores and Overall/100 before final ranking. +8. 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, missing thresholds, or missing variant tables fail closed with a typed configuration error. +- Configuration-unavailable cells are rendered separately from athlete tier 0 attempts. +- 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 or changing the generic product scope. + +## Branding Boundary + +Benchmark routes, tabs, headings, empty states, and stats components use WODsmith's generic benchmark language. The PDF may influence seed data names, test labels, thresholds, categories, and rating bands, but it must not introduce HillerFit-branded pages, routes, logos, marketing sections, 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..c8a406bf0 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md @@ -0,0 +1,60 @@ +# 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. +- 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. +- Stats page distinguishes untested, attempted tier 0, unavailable config, pending, verified, adjusted, and invalid/excluded states. +- Route/component assertions confirm benchmark pages use generic WODsmith benchmark language and do not introduce HillerFit-branded pages/routes. +- 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, logo, marketing section, or theme treatment 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..55e508b47 --- /dev/null +++ b/ai/research/hillerfit-benchmark-leaderboard/traceability.md @@ -0,0 +1,16 @@ +# 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/M1 | Extend current registry, capability truth table, chokepoint characterization | +| 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` M1/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` | +| No HillerFit-branded pages | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `assumptions-and-decisions.md` | Route/component assertions and manual QA | +| PDF source data | Guide §0 D-D, §12.12 | `requirements.md`, `tasks.md` M1, `test-strategy.md` | Extraction receipt and seed validation | 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 From 3561531b5bcf66dfb47666857320244534d9ae83 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 17:26:53 -0600 Subject: [PATCH 02/51] feat: add benchmark competition capabilities --- .../src/lib/competitions/capabilities.ts | 17 +++- .../src/routes/api/compete/scores/submit.ts | 16 +++- .../api/compete/scores/window-status.ts | 8 ++ .../src/routes/api/compete/video/submit.ts | 4 + .../src/server-fns/athlete-score-fns.ts | 8 ++ .../src/server-fns/video-submission-fns.ts | 10 +- .../lib/competitions/capabilities.test.ts | 29 +++++- .../api/compete/scores/window-status.test.ts | 95 +++++++++++++++++++ .../server-fns/video-submission-fns.test.ts | 55 ++++++++++- lat.md/competition-type-capabilities.md | 22 +++-- 10 files changed, 242 insertions(+), 22 deletions(-) create mode 100644 apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts diff --git a/apps/wodsmith-start/src/lib/competitions/capabilities.ts b/apps/wodsmith-start/src/lib/competitions/capabilities.ts index 85d738f77..4caef6e17 100644 --- a/apps/wodsmith-start/src/lib/competitions/capabilities.ts +++ b/apps/wodsmith-start/src/lib/competitions/capabilities.ts @@ -3,18 +3,21 @@ export type CompetitionCapability = | "videoSubmissions" | "submissionWindows" | "optInResultPublishing" + | "perpetual" | "heatScheduling" | "dayOfCheckIn" | "physicalVenue" | "volunteerScheduling" | "organizerEntersResults" +export type RegisteredCompetitionTypeId = "in-person" | "online" | "benchmark" +export type CompetitionTypeId = Exclude export type LeaderboardVariant = "standard" | "online" export type ResultsEntryMode = "organizer-entered" | "athlete-submitted" export type ResultsNavLabel = "Results" | "Submissions" export interface CompetitionTypeDef { - id: string + id: RegisteredCompetitionTypeId label: string createPickerDescription: string capabilities: ReadonlySet @@ -32,7 +35,7 @@ export interface CompetitionTypePickerOption { const EMPTY_CAPABILITIES: ReadonlySet = new Set() export const COMPETITION_TYPE_REGISTRY: Readonly< - Record<"in-person" | "online", CompetitionTypeDef> + Record > = { "in-person": { id: "in-person", @@ -60,10 +63,16 @@ export const COMPETITION_TYPE_REGISTRY: Readonly< "optInResultPublishing", ]), }, + benchmark: { + id: "benchmark", + label: "Benchmark", + createPickerDescription: "Perpetual benchmark board with video submissions", + leaderboardVariant: "online", + selectableOnCreate: false, + capabilities: new Set(["videoSubmissions", "perpetual"]), + }, } -export type CompetitionTypeId = keyof typeof COMPETITION_TYPE_REGISTRY - export function competitionCan( type: string, capability: CompetitionCapability, diff --git a/apps/wodsmith-start/src/routes/api/compete/scores/submit.ts b/apps/wodsmith-start/src/routes/api/compete/scores/submit.ts index 9902bcf62..9a120edc1 100644 --- a/apps/wodsmith-start/src/routes/api/compete/scores/submit.ts +++ b/apps/wodsmith-start/src/routes/api/compete/scores/submit.ts @@ -68,10 +68,18 @@ async function checkSubmissionWindow( .where(eq(competitionsTable.id, competitionId)) .limit(1) - if ( - !competition || - !competitionCan(competition.competitionType, "submissionWindows") - ) { + if (!competition) { + return { + isOpen: false, + reason: "Submission windows are not available for this competition type", + } + } + + if (competitionCan(competition.competitionType, "perpetual")) { + return { isOpen: true, reason: undefined } + } + + if (!competitionCan(competition.competitionType, "submissionWindows")) { return { isOpen: false, reason: "Submission windows are not available for this competition type", diff --git a/apps/wodsmith-start/src/routes/api/compete/scores/window-status.ts b/apps/wodsmith-start/src/routes/api/compete/scores/window-status.ts index 57a655a1c..e0bfc0b4d 100644 --- a/apps/wodsmith-start/src/routes/api/compete/scores/window-status.ts +++ b/apps/wodsmith-start/src/routes/api/compete/scores/window-status.ts @@ -38,6 +38,14 @@ async function getSubmissionWindowStatus( } } + if (competitionCan(competition.competitionType, "perpetual")) { + return { + isOpen: true, + opensAt: null, + closesAt: null, + } + } + if (!competitionCan(competition.competitionType, "submissionWindows")) { return { isOpen: false, diff --git a/apps/wodsmith-start/src/routes/api/compete/video/submit.ts b/apps/wodsmith-start/src/routes/api/compete/video/submit.ts index 58f258ad5..222ca7223 100644 --- a/apps/wodsmith-start/src/routes/api/compete/video/submit.ts +++ b/apps/wodsmith-start/src/routes/api/compete/video/submit.ts @@ -85,6 +85,10 @@ async function checkVideoSubmissionWindow( } } + if (competitionCan(competition.competitionType, "perpetual")) { + return { allowed: true } + } + const [event] = await db .select({ submissionOpensAt: competitionEventsTable.submissionOpensAt, diff --git a/apps/wodsmith-start/src/server-fns/athlete-score-fns.ts b/apps/wodsmith-start/src/server-fns/athlete-score-fns.ts index 0acd65b74..a2b2190ed 100644 --- a/apps/wodsmith-start/src/server-fns/athlete-score-fns.ts +++ b/apps/wodsmith-start/src/server-fns/athlete-score-fns.ts @@ -149,6 +149,14 @@ async function checkSubmissionWindow( } } + if (competitionCan(competition.competitionType, "perpetual")) { + return { + isOpen: true, + opensAt: null, + closesAt: null, + } + } + if (!competitionCan(competition.competitionType, "submissionWindows")) { return { isOpen: false, diff --git a/apps/wodsmith-start/src/server-fns/video-submission-fns.ts b/apps/wodsmith-start/src/server-fns/video-submission-fns.ts index 6e38553e0..773339ed6 100644 --- a/apps/wodsmith-start/src/server-fns/video-submission-fns.ts +++ b/apps/wodsmith-start/src/server-fns/video-submission-fns.ts @@ -167,6 +167,10 @@ async function checkSubmissionWindow( } } + if (competitionCan(competition.competitionType, "perpetual")) { + return { allowed: true } + } + // If no event record exists, allow submission (backward compatibility) if (!event) { return { allowed: true } @@ -932,9 +936,13 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ if (!competition) { allowed = false windowReason = "Competition not found" - } else if (competition.competitionType !== "online") { + } else if ( + !competitionCan(competition.competitionType, "videoSubmissions") + ) { allowed = false windowReason = "Video submissions are only for online competitions" + } else if (competitionCan(competition.competitionType, "perpetual")) { + allowed = true } else { const event = eventMap.get(twId) // No event record / no configured window → submission allowed diff --git a/apps/wodsmith-start/test/lib/competitions/capabilities.test.ts b/apps/wodsmith-start/test/lib/competitions/capabilities.test.ts index 7ab2d2bba..d5177649f 100644 --- a/apps/wodsmith-start/test/lib/competitions/capabilities.test.ts +++ b/apps/wodsmith-start/test/lib/competitions/capabilities.test.ts @@ -16,6 +16,7 @@ const CAPABILITIES: CompetitionCapability[] = [ "videoSubmissions", "submissionWindows", "optInResultPublishing", + "perpetual", "heatScheduling", "dayOfCheckIn", "physicalVenue", @@ -28,6 +29,7 @@ const EXPECTED = { videoSubmissions: false, submissionWindows: false, optInResultPublishing: false, + perpetual: false, heatScheduling: true, dayOfCheckIn: true, physicalVenue: true, @@ -40,6 +42,7 @@ const EXPECTED = { videoSubmissions: true, submissionWindows: true, optInResultPublishing: true, + perpetual: false, heatScheduling: false, dayOfCheckIn: false, physicalVenue: false, @@ -48,11 +51,24 @@ const EXPECTED = { leaderboardVariant: "online", selectableOnCreate: true, }, + benchmark: { + videoSubmissions: true, + submissionWindows: false, + optInResultPublishing: false, + perpetual: true, + heatScheduling: false, + dayOfCheckIn: false, + physicalVenue: false, + volunteerScheduling: false, + organizerEntersResults: false, + leaderboardVariant: "online", + selectableOnCreate: false, + }, } as const describe("competition type capabilities", () => { // @lat: [[competition-type-capabilities#Capability Truth Table Test#Current Type Matrix]] - it("pins every in-person and online capability to the existing behavior table", () => { + it("pins every registered capability to the expected behavior table", () => { for (const [type, expected] of Object.entries(EXPECTED)) { for (const capability of CAPABILITIES) { expect(competitionCan(type, capability), `${type}:${capability}`).toBe( @@ -68,6 +84,7 @@ describe("competition type capabilities", () => { // @lat: [[competition-type-capabilities#Capability Truth Table Test#Registry Metadata Alignment]] it("keeps registry metadata aligned with the supported type identities", () => { expect(Object.keys(COMPETITION_TYPE_REGISTRY).sort()).toEqual([ + "benchmark", "in-person", "online", ]) @@ -82,11 +99,11 @@ describe("competition type capabilities", () => { // @lat: [[competition-type-capabilities#Capability Truth Table Test#Unknown Type Fallback]] it("falls back safely for unknown competition types", () => { for (const capability of CAPABILITIES) { - expect(competitionCan("benchmark", capability)).toBe(false) + expect(competitionCan("unregistered", capability)).toBe(false) } - expect(leaderboardVariant("benchmark")).toBe("standard") - expect(isSelectableType("benchmark")).toBe(false) + expect(leaderboardVariant("unregistered")).toBe("standard") + expect(isSelectableType("unregistered")).toBe(false) }) // @lat: [[competition-type-capabilities#Create Picker Selectability Test]] @@ -102,6 +119,7 @@ describe("competition type capabilities", () => { expect(isSelectableType(type.id)).toBe(true) expect(isSelectableCompetitionTypeValue(type.id)).toBe(true) } + expect(isSelectableType("benchmark")).toBe(false) expect(isSelectableCompetitionTypeValue("benchmark")).toBe(false) expect(isSelectableCompetitionTypeValue(null)).toBe(false) @@ -128,5 +146,8 @@ describe("competition type capabilities", () => { expect(resultsEntryMode("online")).toBe("athlete-submitted") expect(resultsNavLabel("online")).toBe("Submissions") + + expect(resultsEntryMode("benchmark")).toBe("athlete-submitted") + expect(resultsNavLabel("benchmark")).toBe("Submissions") }) }) diff --git a/apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts b/apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts new file mode 100644 index 000000000..f6b404127 --- /dev/null +++ b/apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockLimit = vi.hoisted(() => vi.fn()) + +const mockDb = vi.hoisted(() => { + const chain: Record = {} + + chain.select = vi.fn(() => chain) + chain.from = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.limit = (...args: unknown[]) => mockLimit(...args) + + return chain +}) + +vi.mock("@/db", () => ({ + getDb: vi.fn(() => mockDb), +})) + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: unknown) => config, +})) + +vi.mock("@tanstack/react-start", () => ({ + json: (data: unknown, init?: { status?: number; headers?: HeadersInit }) => + new Response(JSON.stringify(data), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), +})) + +import { Route } from "@/routes/api/compete/scores/window-status" + +const routeConfig = Route as unknown as { + server: { + handlers: { + GET: (args: { request: Request }) => Promise + } + } +} + +function windowStatusRequest() { + return new Request( + "https://wodsmith.test/api/compete/scores/window-status?competitionId=comp-1&trackWorkoutId=tw-1", + ) +} + +describe("score window-status API route", () => { + beforeEach(() => { + vi.clearAllMocks() + mockLimit.mockReset() + }) + + it("keeps online competitions closed when no submission window row exists", async () => { + mockLimit + .mockResolvedValueOnce([{ competitionType: "online" }]) + .mockResolvedValueOnce([]) + + const response = await routeConfig.server.handlers.GET({ + request: windowStatusRequest(), + }) + const data = (await response.json()) as { + isOpen: boolean + reason: string + } + + expect(response.status).toBe(200) + expect(data).toMatchObject({ + isOpen: false, + reason: "Submission window not configured", + }) + }) + + it("treats benchmark perpetual status as open without window rows", async () => { + // @lat: [[competition-type-capabilities#Perpetual Window Status Test]] + mockLimit.mockResolvedValueOnce([{ competitionType: "benchmark" }]) + + const response = await routeConfig.server.handlers.GET({ + request: windowStatusRequest(), + }) + const data = (await response.json()) as { + isOpen: boolean + opensAt: string | null + closesAt: string | null + } + + expect(response.status).toBe(200) + expect(data).toEqual({ + isOpen: true, + opensAt: null, + closesAt: null, + }) + expect(mockLimit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/wodsmith-start/test/server-fns/video-submission-fns.test.ts b/apps/wodsmith-start/test/server-fns/video-submission-fns.test.ts index b8051ef60..2b3f5baa1 100644 --- a/apps/wodsmith-start/test/server-fns/video-submission-fns.test.ts +++ b/apps/wodsmith-start/test/server-fns/video-submission-fns.test.ts @@ -4,6 +4,7 @@ import { getVideoSubmissionFn, submitVideoFn, getBatchSubmissionStatusFn, + getBatchEventVideoSubmissionsFn, getOrganizerSubmissionsFn, getOrganizerSubmissionDetailFn, markSubmissionReviewedFn, @@ -78,7 +79,7 @@ const setMockSession = (session: unknown) => { function createTestCompetition( overrides?: Partial<{ id: string - competitionType: "in-person" | "online" + competitionType: "in-person" | "online" | "benchmark" }>, ) { return { @@ -323,6 +324,32 @@ describe("Video Submission Server Functions (TanStack)", () => { expect(result.isRegistered).toBe(true) }) + it("returns canSubmit true for benchmark competitions without submission windows", async () => { + const registration = createTestRegistration() + const competition = createTestCompetition({ competitionType: "benchmark" }) + + const limitMock = mockDb.getChainMock().limit as ReturnType + const orderByMock = mockDb.getChainMock().orderBy as ReturnType + limitMock + .mockResolvedValueOnce([registration]) // Registration found + .mockResolvedValueOnce([competition]) // Competition type check + .mockResolvedValueOnce([]) // No event row required for perpetual benchmark + .mockResolvedValueOnce([{ teamSize: 1 }]) // getTeamSize + .mockResolvedValueOnce([]) // No workout details + .mockResolvedValueOnce([]) // No existing score + orderByMock.mockResolvedValueOnce([]) // No existing submission + + const result = await getVideoSubmissionFn({ + data: { + trackWorkoutId: "tw-1", + competitionId: "comp-1", + }, + }) + + expect(result.canSubmit).toBe(true) + expect(result.isRegistered).toBe(true) + }) + it("returns canSubmit false when submission window has not opened", async () => { const registration = createTestRegistration() const competition = createTestCompetition({ competitionType: "online" }) @@ -1496,6 +1523,32 @@ describe("Video Submission Server Functions (TanStack)", () => { }) }) + describe("getBatchEventVideoSubmissionsFn", () => { + it("returns canSubmit true for benchmark competitions without submission windows", async () => { + const registration = createTestRegistration() + const competition = createTestCompetition({ competitionType: "benchmark" }) + + mockDb.setMockReturnValue([]) + const limitMock = mockDb.getChainMock().limit as ReturnType + const orderByMock = mockDb.getChainMock().orderBy as ReturnType + limitMock + .mockResolvedValueOnce([registration]) // getAthleteRegistration + .mockResolvedValueOnce([{ teamSize: 1 }]) // getTeamSize + .mockResolvedValueOnce([competition]) // Competition type + orderByMock.mockResolvedValueOnce([]) // No existing submissions + + const result = await getBatchEventVideoSubmissionsFn({ + data: { + competitionId: "comp-1", + trackWorkoutIds: ["tw-1"], + }, + }) + + expect(result.results["tw-1"]?.canSubmit).toBe(true) + expect(result.results["tw-1"]?.reason).toBeUndefined() + }) + }) + describe("getOrganizerSubmissionsFn", () => { const validInput = { trackWorkoutId: "tw-1", diff --git a/lat.md/competition-type-capabilities.md b/lat.md/competition-type-capabilities.md index 82d109a5d..4d42731d4 100644 --- a/lat.md/competition-type-capabilities.md +++ b/lat.md/competition-type-capabilities.md @@ -10,23 +10,23 @@ Competition type capabilities define which product behaviors each stored competi The registry maps each competition type to named capabilities, its leaderboard variant, and create-picker selectability without adding a database field. -[[apps/wodsmith-start/src/lib/competitions/capabilities.ts#COMPETITION_TYPE_REGISTRY]] keeps `competitionType` as the stored discriminator and exposes [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#competitionCan]], [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#leaderboardVariant]], and [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableType]] for later call-site refactors. In-person competitions retain heat scheduling, day-of check-in, physical venue, volunteer scheduling, and organizer-entered results. Online competitions retain video submissions, submission windows, opt-in result publishing, and the online leaderboard variant. +[[apps/wodsmith-start/src/lib/competitions/capabilities.ts#COMPETITION_TYPE_REGISTRY]] keeps `competitionType` as the stored discriminator and exposes [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#competitionCan]], [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#leaderboardVariant]], and [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableType]] for later call-site refactors. In-person competitions retain heat scheduling, day-of check-in, physical venue, volunteer scheduling, and organizer-entered results. Online competitions retain video submissions, submission windows, opt-in result publishing, and the online leaderboard variant. Benchmark competitions retain video submissions and the online leaderboard visual variant, add the `perpetual` capability, and intentionally skip submission windows and opt-in result publishing. -Server submission gates now consume the registry for the API, server-function, and leaderboard paths: score-window checks use `submissionWindows`, while video submission checks use `videoSubmissions`. The dedicated leaderboard refactor also routes the online table decision through `leaderboardVariant` and the hidden-until-published default through `optInResultPublishing`; [[apps/wodsmith-start/src/server/competition-leaderboard.ts#getCompetitionLeaderboard]] stayed minimal because GitNexus reports HIGH blast radius. +Server submission gates now consume the registry for the API, server-function, and leaderboard paths: score-window checks use `submissionWindows`, while benchmark status checks use `perpetual` to stay open without seeded window rows. Video submission checks use `videoSubmissions`. The dedicated leaderboard refactor also routes the online table decision through `leaderboardVariant` and the hidden-until-published default through `optInResultPublishing`; [[apps/wodsmith-start/src/server/competition-leaderboard.ts#getCompetitionLeaderboard]] stayed minimal because GitNexus reports HIGH blast radius. The `scoringAlgorithm === "online"` axis remains separate from `competitionType === "online"`. Capability checks must not replace scoring-algorithm branches. ## Capability Truth Table Test -The truth-table test pins every capability and leaderboard variant for in-person and online competitions before call sites are refactored. +The truth-table test pins every capability and leaderboard variant for registered competition types while unknown values fail closed. -[[apps/wodsmith-start/test/lib/competitions/capabilities.test.ts]] verifies each capability for both current types, confirms registry metadata matches type identity, and covers the unknown-type fallback. This is the PR-1 safety net for the M0 competition-type capability registry. +[[apps/wodsmith-start/test/lib/competitions/capabilities.test.ts]] verifies each capability for every registered type, confirms registry metadata matches type identity, and covers the unknown-type fallback. Benchmark is registered but remains out of generic create-picker options. Focused PR-2 server-function tests pin that in-person score saves pass the submission-window gate, online score saves still honor closed windows, and in-person video submissions still reject before writes. PR-3 adds [[apps/wodsmith-start/test/components/leaderboard-page-content.test.tsx]] coverage for standard versus online leaderboard table selection plus [[apps/wodsmith-start/test/server/competition-leaderboard-capability-gates.test.ts]] coverage for opt-in result publishing defaults and the leaderboard video-submission fetch gate. PR-4 adds [[apps/wodsmith-start/test/lib/competitions/scheduling-check-in-gates.test.ts]] coverage for the heat scheduling and day-of check-in gates used by the public schedule, judge rotations, check-in routes, and check-in server functions. PR-5 adds [[apps/wodsmith-start/test/lib/competitions/venue-volunteer-gates.test.ts]] and [[apps/wodsmith-start/test/components/competition-location-card.test.tsx]] coverage for physical venue display and volunteer schedule-tab gates. ### Current Type Matrix -This test verifies every current competition type keeps its existing capability, leaderboard variant, and create-selectability behavior. +This test verifies every registered competition type keeps its expected capability, leaderboard variant, and create-selectability behavior. ### Registry Metadata Alignment @@ -34,13 +34,19 @@ This test verifies registry keys, ids, labels, and capability sets stay aligned ### Unknown Type Fallback -This test verifies unknown competition types fail closed for capabilities, use the standard leaderboard variant, and stay unselectable. +This test verifies unregistered competition types fail closed for capabilities, use the standard leaderboard variant, and stay unselectable. ## Create Picker Selectability Test -The create-picker test pins that selectable competition type options are derived from registry selectability while exposing only current types. +The create-picker test pins that selectable competition type options are derived from registry selectability while benchmark remains hidden. -[[apps/wodsmith-start/test/lib/competitions/capabilities.test.ts]] verifies [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#selectableCompetitionTypes]] returns only in-person and online type definitions, with each entry passing [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableType]] and [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableCompetitionTypeValue]]. [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#selectableCompetitionTypeOptions]] provides the registry-backed label and description text that the generic organizer create and edit form pickers render, while the form and server schemas use the same selectable-value guard. +[[apps/wodsmith-start/test/lib/competitions/capabilities.test.ts]] verifies [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#selectableCompetitionTypes]] returns only in-person and online type definitions, with each entry passing [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableType]] and [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableCompetitionTypeValue]]. [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#selectableCompetitionTypeOptions]] provides the registry-backed label and description text that the generic organizer create and edit form pickers render, while the form and server schemas use the same selectable-value guard that rejects benchmark for this picker. + +## Perpetual Window Status Test + +The window-status test pins benchmark as open without submission-window rows while online competitions still require configured rows. + +[[apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts]] verifies the score window-status API returns open/null-window metadata for benchmark because it has `perpetual`, while online stays closed when no submission-window row exists. ## Scheduling and Check-In Gates Test From ab90aadf2e8eb2cf3ac4b2b407e05bb015a0fc31 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 17:38:51 -0600 Subject: [PATCH 03/51] test: cover benchmark submission gates --- .../src/routes/compete/$slug/index.tsx | 8 +- .../compete/$slug/review/$eventId/index.tsx | 6 +- .../compete/$slug/workouts/$eventId.tsx | 29 ++-- .../routes/compete/$slug/workouts/index.tsx | 11 +- .../events/$eventId/submissions/index.tsx | 6 +- .../events/$eventId/submissions/index.tsx | 6 +- .../api/compete/submission-gates.test.ts | 143 ++++++++++++++++++ .../video-submission-route-gates.test.ts | 31 ++++ .../test/server-fns/athlete-score-fns.test.ts | 95 ++++++++++++ lat.md/competition-type-capabilities.md | 14 +- 10 files changed, 323 insertions(+), 26 deletions(-) create mode 100644 apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts create mode 100644 apps/wodsmith-start/test/routes/compete/video-submission-route-gates.test.ts create mode 100644 apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts diff --git a/apps/wodsmith-start/src/routes/compete/$slug/index.tsx b/apps/wodsmith-start/src/routes/compete/$slug/index.tsx index 8e04057f6..32fbd6e2f 100644 --- a/apps/wodsmith-start/src/routes/compete/$slug/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/$slug/index.tsx @@ -10,6 +10,7 @@ import { } from "@/components/competition-workout-card" import { EventDetailsContent } from "@/components/event-details-content" import { RegistrationSidebar } from "@/components/registration-sidebar" +import { competitionCan } from "@/lib/competitions/capabilities" import { getPublicScheduleDataFn, type PublicScheduleEvent, @@ -53,13 +54,14 @@ export const Route = createFileRoute("/compete/$slug/")({ // Single consolidated call for workouts + division descriptions + // event-division mappings + the viewer's submission statuses (fetched // server-side in the same wave as the descriptions, only for registered - // athletes on online competitions). + // athletes on competitions that support video submissions). const pageData = await getPublicWorkoutsPageDataFn({ data: { competitionId, divisionIds, includeSubmissionStatuses: - competition.competitionType === "online" && !!userRegistration, + competitionCan(competition.competitionType, "videoSubmissions") && + !!userRegistration, }, }) @@ -132,7 +134,7 @@ function CompetitionOverviewPage() { } const showScorePanel = - competition.competitionType === "online" && + competitionCan(competition.competitionType, "videoSubmissions") && isRegistered && !!session && userDivisions.length > 0 && diff --git a/apps/wodsmith-start/src/routes/compete/$slug/review/$eventId/index.tsx b/apps/wodsmith-start/src/routes/compete/$slug/review/$eventId/index.tsx index 3bcf436b8..c6e5a3a8d 100644 --- a/apps/wodsmith-start/src/routes/compete/$slug/review/$eventId/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/$slug/review/$eventId/index.tsx @@ -56,6 +56,7 @@ import { getCompetitionDivisionsForScoreEntryFn, } from "@/server-fns/competition-score-fns" import { getOrganizerSubmissionsFn } from "@/server-fns/video-submission-fns" +import { competitionCan } from "@/lib/competitions/capabilities" import { cn } from "@/utils/cn" import { isSafeUrl } from "@/utils/url" @@ -97,10 +98,9 @@ export const Route = createFileRoute("/compete/$slug/review/$eventId/")({ throw new Error("Competition not found") } - // Only allow for online competitions - if (competition.competitionType !== "online") { + if (!competitionCan(competition.competitionType, "videoSubmissions")) { throw new Error( - "Video submissions are only available for online competitions", + "Video submissions are not available for this competition type", ) } diff --git a/apps/wodsmith-start/src/routes/compete/$slug/workouts/$eventId.tsx b/apps/wodsmith-start/src/routes/compete/$slug/workouts/$eventId.tsx index 30cafe24e..d2f3a0244 100644 --- a/apps/wodsmith-start/src/routes/compete/$slug/workouts/$eventId.tsx +++ b/apps/wodsmith-start/src/routes/compete/$slug/workouts/$eventId.tsx @@ -43,6 +43,7 @@ import { getBatchEventVideoSubmissionsFn, type VideoSubmissionResult, } from "@/server-fns/video-submission-fns" +import { competitionCan } from "@/lib/competitions/capabilities" import { getGoogleMapsUrl, hasAddressData } from "@/utils/address" import { formatTrackOrder } from "@/utils/format-track-order" @@ -70,7 +71,10 @@ export const Route = createFileRoute("/compete/$slug/workouts/$eventId")({ const divisions = parentDivisions ?? [] const divisionIds = divisions.map((d) => d.id) - const isOnline = competition.competitionType === "online" + const supportsVideoSubmissions = competitionCan( + competition.competitionType, + "videoSubmissions", + ) // Athlete's registered division ids — derived from the parent route's // registrations (the same getUserCompetitionRegistrationsFn data the old @@ -136,14 +140,14 @@ export const Route = createFileRoute("/compete/$slug/workouts/$eventId")({ } } - // For online competitions, fetch video submissions in ONE batched call + // For video-submission competitions, fetch submissions in ONE batched call // (instead of one getVideoSubmissionFn call per child event). The target // events (children vs the event itself) and the mapped division only // emerge from the page data, so this chains off the same promise — a // single dependent round-trip. const videoDataPromise: Promise> = (async () => { - if (!isOnline) return {} + if (!supportsVideoSubmissions) return {} const pageData = await pageDataPromise if (!pageData.event) return {} @@ -231,9 +235,11 @@ export const Route = createFileRoute("/compete/$slug/workouts/$eventId")({ } const videoSubmission = - isOnline && !hasChildEvents ? (videoResults[eventId] ?? null) : null + supportsVideoSubmissions && !hasChildEvents + ? (videoResults[eventId] ?? null) + : null const childVideoSubmissions: Record = {} - if (isOnline && hasChildEvents) { + if (supportsVideoSubmissions && hasChildEvents) { for (const child of childEvents) { const result = videoResults[child.id] if (result) { @@ -648,8 +654,11 @@ function EventDetailsPage() { )} - {/* Per-sub-event submission form for online competitions */} - {competition.competitionType === "online" && + {/* Per-sub-event submission form */} + {competitionCan( + competition.competitionType, + "videoSubmissions", + ) && childSubmission && ( - {/* Submission Info Card - For online competitions */} - {competition.competitionType === "online" && + {/* Submission Info Card - For competitions with submission windows */} + {competitionCan(competition.competitionType, "submissionWindows") && sidebarSubmission?.submissionWindow && ( diff --git a/apps/wodsmith-start/src/routes/compete/$slug/workouts/index.tsx b/apps/wodsmith-start/src/routes/compete/$slug/workouts/index.tsx index eabd507c9..0e07dafe2 100644 --- a/apps/wodsmith-start/src/routes/compete/$slug/workouts/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/$slug/workouts/index.tsx @@ -31,6 +31,7 @@ import { getPublicWorkoutsPageDataFn, type PublicWorkoutVenueInfo, } from "@/server-fns/competition-workouts-page-fns" +import { competitionCan } from "@/lib/competitions/capabilities" import { useDeferredSchedule } from "@/utils/use-deferred-schedule" const parentRoute = getRouteApi("/compete/$slug") @@ -78,18 +79,22 @@ export const Route = createFileRoute("/compete/$slug/workouts/")({ parentMatch.loaderData?.userRegistration?.divisionId ?? null const divisionIds = divisions?.map((d) => d.id) ?? [] - const isOnline = competition.competitionType === "online" + const supportsVideoSubmissions = competitionCan( + competition.competitionType, + "videoSubmissions", + ) // Single consolidated call for workouts + division descriptions + // event-division mappings + venues + the viewer's submission statuses // (fetched server-side in the same wave as descriptions/venues, only - // for registered athletes on online competitions). + // for registered athletes on video-submission competitions). const pageData = await getPublicWorkoutsPageDataFn({ data: { competitionId, divisionIds, includeVenues: true, - includeSubmissionStatuses: isOnline && !!athleteRegisteredDivisionId, + includeSubmissionStatuses: + supportsVideoSubmissions && !!athleteRegisteredDivisionId, }, }) diff --git a/apps/wodsmith-start/src/routes/compete/cohost/$competitionId/events/$eventId/submissions/index.tsx b/apps/wodsmith-start/src/routes/compete/cohost/$competitionId/events/$eventId/submissions/index.tsx index decff4d0c..c7c395081 100644 --- a/apps/wodsmith-start/src/routes/compete/cohost/$competitionId/events/$eventId/submissions/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/cohost/$competitionId/events/$eventId/submissions/index.tsx @@ -56,6 +56,7 @@ import { cohostGetOrganizerSubmissionsFn } from "@/server-fns/cohost/cohost-subm import { cn } from "@/utils/cn" import { isSafeUrl } from "@/utils/url" import { getCompetitionByIdFn } from "@/server-fns/competition-detail-fns" +import { competitionCan } from "@/lib/competitions/capabilities" const FLAGGED_THRESHOLD = 3 @@ -97,10 +98,9 @@ export const Route = createFileRoute( const competitionTeamId = competition.competitionTeamId! - // Only allow for online competitions - if (competition.competitionType !== "online") { + if (!competitionCan(competition.competitionType, "videoSubmissions")) { throw new Error( - "Video submissions are only available for online competitions", + "Video submissions are not available for this competition type", ) } diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/events/$eventId/submissions/index.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/events/$eventId/submissions/index.tsx index 72fa1554a..df71c0ea8 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/events/$eventId/submissions/index.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/events/$eventId/submissions/index.tsx @@ -56,6 +56,7 @@ import { getCompetitionByIdFn } from "@/server-fns/competition-detail-fns" import { getCompetitionDivisionsWithCountsFn } from "@/server-fns/competition-divisions-fns" import { getCompetitionEventFn } from "@/server-fns/competition-workouts-fns" import { getOrganizerSubmissionsFn } from "@/server-fns/video-submission-fns" +import { competitionCan } from "@/lib/competitions/capabilities" import { cn } from "@/utils/cn" import { isSafeUrl } from "@/utils/url" @@ -100,10 +101,9 @@ export const Route = createFileRoute( throw new Error("Competition not found") } - // Only allow for online competitions - if (competition.competitionType !== "online") { + if (!competitionCan(competition.competitionType, "videoSubmissions")) { throw new Error( - "Video submissions are only available for online competitions", + "Video submissions are not available for this competition type", ) } diff --git a/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts b/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts new file mode 100644 index 000000000..0e0588cec --- /dev/null +++ b/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockAuthSession = vi.hoisted(() => vi.fn()) +const mockLimit = vi.hoisted(() => vi.fn()) + +const mockDb = vi.hoisted(() => { + const chain: Record = {} + const passthrough = () => chain + + chain.select = vi.fn(passthrough) + chain.from = vi.fn(passthrough) + chain.where = vi.fn(passthrough) + chain.innerJoin = vi.fn(passthrough) + chain.insert = vi.fn(passthrough) + chain.values = vi.fn(passthrough) + chain.update = vi.fn(passthrough) + chain.set = vi.fn(passthrough) + chain.limit = (...args: unknown[]) => mockLimit(...args) + chain.onDuplicateKeyUpdate = vi.fn(() => Promise.resolve()) + chain.then = (resolve: (value: T) => void) => { + resolve(undefined as T) + return Promise.resolve(undefined as T) + } + + return chain +}) + +vi.mock("@/db", () => ({ + getDb: vi.fn(() => mockDb), +})) + +vi.mock("@/utils/bearer-auth", () => ({ + corsHeaders: () => ({}), + getSessionFromBearerOrCookie: mockAuthSession, +})) + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (config: unknown) => config, +})) + +vi.mock("@tanstack/react-start", () => ({ + json: (data: unknown, init?: { status?: number; headers?: HeadersInit }) => + new Response(JSON.stringify(data), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), +})) + +import { Route as ScoreSubmitRoute } from "@/routes/api/compete/scores/submit" +import { Route as VideoSubmitRoute } from "@/routes/api/compete/video/submit" + +const scoreSubmitRoute = ScoreSubmitRoute as unknown as { + server: { + handlers: { + POST: (args: { request: Request }) => Promise + } + } +} + +const videoSubmitRoute = VideoSubmitRoute as unknown as { + server: { + handlers: { + POST: (args: { request: Request }) => Promise + } + } +} + +function postRequest(path: string, body: Record) { + return new Request(`https://wodsmith.test${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + +describe("submission API capability gates", () => { + beforeEach(() => { + vi.clearAllMocks() + mockLimit.mockReset() + mockAuthSession.mockResolvedValue({ userId: "user-1" }) + }) + + it("allows benchmark score submission without submission-window rows", async () => { + // @lat: [[competition-type-capabilities#Perpetual Submission Gate Test]] + mockLimit + .mockResolvedValueOnce([{ id: "reg-1", divisionId: "open" }]) + .mockResolvedValueOnce([{ competitionType: "benchmark" }]) + .mockResolvedValueOnce([{ workoutId: "workout-1", trackId: "track-1" }]) + .mockResolvedValueOnce([ + { + scheme: "time", + scoreType: "min", + tiebreakScheme: null, + timeCap: null, + }, + ]) + .mockResolvedValueOnce([{ ownerTeamId: "team-1" }]) + .mockResolvedValueOnce([{ id: "score-1" }]) + + const response = await scoreSubmitRoute.server.handlers.POST({ + request: postRequest("/api/compete/scores/submit", { + competitionId: "comp-1", + trackWorkoutId: "tw-1", + score: "10:00", + status: "scored", + }), + }) + const data = (await response.json()) as { + success?: boolean + scoreId?: string + error?: string + } + + expect(response.status).toBe(200) + expect(data).toMatchObject({ success: true, scoreId: "score-1" }) + expect(data.error).toBeUndefined() + }) + + it("allows benchmark video submission without submission-window rows", async () => { + mockLimit + .mockResolvedValueOnce([{ id: "reg-1", divisionId: "open" }]) + .mockResolvedValueOnce([{ competitionType: "benchmark" }]) + .mockResolvedValueOnce([]) + + const response = await videoSubmitRoute.server.handlers.POST({ + request: postRequest("/api/compete/video/submit", { + competitionId: "comp-1", + trackWorkoutId: "tw-1", + videoUrl: "https://example.com/video", + }), + }) + const data = (await response.json()) as { + success?: boolean + submissionId?: string + error?: string + } + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.submissionId).toEqual(expect.any(String)) + expect(data.error).toBeUndefined() + }) +}) diff --git a/apps/wodsmith-start/test/routes/compete/video-submission-route-gates.test.ts b/apps/wodsmith-start/test/routes/compete/video-submission-route-gates.test.ts new file mode 100644 index 000000000..b95951a40 --- /dev/null +++ b/apps/wodsmith-start/test/routes/compete/video-submission-route-gates.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" + +const routeFiles = [ + "../../../src/routes/compete/$slug/index.tsx", + "../../../src/routes/compete/$slug/workouts/index.tsx", + "../../../src/routes/compete/$slug/workouts/$eventId.tsx", + "../../../src/routes/compete/$slug/review/$eventId/index.tsx", + "../../../src/routes/compete/organizer/$competitionId/events/$eventId/submissions/index.tsx", + "../../../src/routes/compete/cohost/$competitionId/events/$eventId/submissions/index.tsx", +] + +describe("video submission route gates", () => { + // @lat: [[competition-type-capabilities#Video Submission Route Gates Test]] + it("uses capability gates instead of literal online checks at submission routes", () => { + for (const routeFile of routeFiles) { + const source = readFileSync( + fileURLToPath(new URL(routeFile, import.meta.url)), + "utf8", + ) + + expect(source, routeFile).not.toContain( + 'competition.competitionType === "online"', + ) + expect(source, routeFile).not.toContain( + 'competition.competitionType !== "online"', + ) + } + }) +}) diff --git a/apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts b/apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts new file mode 100644 index 000000000..0c1c5cf44 --- /dev/null +++ b/apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockSession = vi.hoisted(() => vi.fn()) +const mockLimit = vi.hoisted(() => vi.fn()) + +const mockDb = vi.hoisted(() => { + const chain: Record = {} + const passthrough = () => chain + + chain.select = vi.fn(passthrough) + chain.from = vi.fn(passthrough) + chain.where = vi.fn(passthrough) + chain.insert = vi.fn(passthrough) + chain.values = vi.fn(passthrough) + chain.limit = (...args: unknown[]) => mockLimit(...args) + chain.onDuplicateKeyUpdate = vi.fn(() => Promise.resolve()) + chain.then = (resolve: (value: T) => void) => { + resolve(undefined as T) + return Promise.resolve(undefined as T) + } + + return chain +}) + +vi.mock("@/db", () => ({ + getDb: vi.fn(() => mockDb), +})) + +vi.mock("@/utils/auth", () => ({ + getSessionFromCookie: mockSession, +})) + +vi.mock("@/lib/logging", () => ({ + addRequestContextAttribute: vi.fn(), + logEntityUpdated: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + logWarning: vi.fn(), + updateRequestContext: vi.fn(), +})) + +vi.mock("@tanstack/react-start", () => ({ + createServerFn: () => ({ + inputValidator: (validator: (data: unknown) => unknown) => ({ + handler: (fn: (ctx: { data: unknown }) => Promise) => { + return async (ctx: { data: unknown }) => { + const data = validator(ctx.data) + return fn({ data }) + } + }, + }), + }), +})) + +import { submitAthleteScoreFn } from "@/server-fns/athlete-score-fns" + +describe("athlete score submission capability gates", () => { + beforeEach(() => { + vi.clearAllMocks() + mockLimit.mockReset() + mockSession.mockResolvedValue({ userId: "user-1" }) + }) + + it("allows benchmark score submission without submission-window rows", async () => { + mockLimit + .mockResolvedValueOnce([{ id: "reg-1", divisionId: "open" }]) + .mockResolvedValueOnce([{ competitionType: "benchmark" }]) + .mockResolvedValueOnce([{ workoutId: "workout-1", trackId: "track-1" }]) + .mockResolvedValueOnce([ + { + scheme: "time", + scoreType: "min", + tiebreakScheme: null, + timeCap: null, + }, + ]) + .mockResolvedValueOnce([{ ownerTeamId: "team-1" }]) + .mockResolvedValueOnce([{ id: "score-1" }]) + + const result = await submitAthleteScoreFn({ + data: { + competitionId: "comp-1", + trackWorkoutId: "tw-1", + score: "10:00", + status: "scored", + }, + }) + + expect(result).toEqual({ + success: true, + scoreId: "score-1", + message: "Score submitted successfully", + }) + }) +}) diff --git a/lat.md/competition-type-capabilities.md b/lat.md/competition-type-capabilities.md index 4d42731d4..3505e1674 100644 --- a/lat.md/competition-type-capabilities.md +++ b/lat.md/competition-type-capabilities.md @@ -12,7 +12,7 @@ The registry maps each competition type to named capabilities, its leaderboard v [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#COMPETITION_TYPE_REGISTRY]] keeps `competitionType` as the stored discriminator and exposes [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#competitionCan]], [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#leaderboardVariant]], and [[apps/wodsmith-start/src/lib/competitions/capabilities.ts#isSelectableType]] for later call-site refactors. In-person competitions retain heat scheduling, day-of check-in, physical venue, volunteer scheduling, and organizer-entered results. Online competitions retain video submissions, submission windows, opt-in result publishing, and the online leaderboard variant. Benchmark competitions retain video submissions and the online leaderboard visual variant, add the `perpetual` capability, and intentionally skip submission windows and opt-in result publishing. -Server submission gates now consume the registry for the API, server-function, and leaderboard paths: score-window checks use `submissionWindows`, while benchmark status checks use `perpetual` to stay open without seeded window rows. Video submission checks use `videoSubmissions`. The dedicated leaderboard refactor also routes the online table decision through `leaderboardVariant` and the hidden-until-published default through `optInResultPublishing`; [[apps/wodsmith-start/src/server/competition-leaderboard.ts#getCompetitionLeaderboard]] stayed minimal because GitNexus reports HIGH blast radius. +Server submission gates now consume the registry for API, server-function, route, and leaderboard paths: score-window checks use `submissionWindows`, while benchmark status checks use `perpetual` to stay open without seeded window rows. Video submission checks use `videoSubmissions`. The dedicated leaderboard refactor also routes the online table decision through `leaderboardVariant` and the hidden-until-published default through `optInResultPublishing`; [[apps/wodsmith-start/src/server/competition-leaderboard.ts#getCompetitionLeaderboard]] stayed minimal because GitNexus reports HIGH blast radius. The `scoringAlgorithm === "online"` axis remains separate from `competitionType === "online"`. Capability checks must not replace scoring-algorithm branches. @@ -48,6 +48,18 @@ The window-status test pins benchmark as open without submission-window rows whi [[apps/wodsmith-start/test/routes/api/compete/scores/window-status.test.ts]] verifies the score window-status API returns open/null-window metadata for benchmark because it has `perpetual`, while online stays closed when no submission-window row exists. +## Perpetual Submission Gate Test + +The submission-gate tests pin benchmark write entries as open without seeded submission-window rows while preserving existing validation after the gate. + +[[apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts]] verifies the score and video API submit routes accept benchmark competitions through the window gate. [[apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts]] verifies the athlete score server function does the same. + +## Video Submission Route Gates Test + +The route-gate test pins public athlete and review submission routes to the `videoSubmissions` capability instead of literal online checks. + +[[apps/wodsmith-start/test/routes/compete/video-submission-route-gates.test.ts]] verifies the public overview, workout detail, organizer, cohost, and volunteer review submission routes do not reintroduce literal `competitionType === "online"` gates. + ## Scheduling and Check-In Gates Test These tests pin capability helpers that route public schedules, heat scheduling, and day-of check-in without direct type checks. From d9d8f2e6e728b0b63ecadadd469a80a057ca3a15 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 17:47:04 -0600 Subject: [PATCH 04/51] docs: clarify benchmark branding boundary --- ai/research/hillerfit-benchmark-leaderboard/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ai/research/hillerfit-benchmark-leaderboard/README.md b/ai/research/hillerfit-benchmark-leaderboard/README.md index 918e8b740..844405c66 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/README.md +++ b/ai/research/hillerfit-benchmark-leaderboard/README.md @@ -18,4 +18,6 @@ Source narrative: [`../hillerfit-benchmark-leaderboard-guide.md`](../hillerfit-b ## Build Rule -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, or baked-constant Weighted C2B unless the decision sheet is updated first. +The supplied training PDF is a benchmark seed-data source only. Do not build HillerFit-branded pages, routes, product navigation, marketing copy, logos, theme treatments, or any other branded surface; the shipped UI remains WODsmith's generic benchmark board/stat-line experience. + +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. From bd24b45000f979fc4926a45f7d4e9f580359ad56 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 18:03:30 -0600 Subject: [PATCH 05/51] feat: add benchmark schema primitives --- .../requirements.md | 2 +- .../hillerfit-benchmark-leaderboard/tasks.md | 17 +- .../technical-design.md | 9 +- .../test-strategy.md | 4 +- .../traceability.md | 9 +- .../src/server/crew-judge-rotations.server.ts | 4 +- .../src/components/cohost-sidebar.tsx | 4 +- .../compete/scoring-config-form.tsx | 16 + .../src/components/competition-sidebar.tsx | 4 +- .../components/leaderboard-page-content.tsx | 2 +- .../components/organizer-competition-form.tsx | 77 +- .../src/db/schemas/benchmarks.ts | 1 + .../src/lib/scoring/algorithms/index.ts | 6 + .../judges/judge-scheduling-container.tsx | 2 +- .../organizer-competition-edit-form.tsx | 85 +- .../src/schemas/benchmark.schema.ts | 106 + .../src/schemas/scoring.schema.ts | 19 + .../src/server-fns/check-in-fns.ts | 2 +- .../server-fns/competition-server-logic.ts | 6 +- apps/wodsmith-start/src/types/scoring.ts | 2 + .../organizer-competition-edit-form.test.tsx | 127 + .../test/lib/scoring/factory.test.ts | 29 + .../test/schemas/benchmark.test.ts | 103 + .../test/schemas/scoring.test.ts | 56 +- lat.md/competition-type-capabilities.md | 4 + lat.md/domain.md | 10 + .../0003_benchmark-battery.sql | 70 + .../mysql-migrations/meta/0003_snapshot.json | 13277 ++++++++++++++++ .../mysql-migrations/meta/_journal.json | 7 + packages/wodsmith-db/src/schema.ts | 1 + .../wodsmith-db/src/schemas/benchmarks.ts | 186 + packages/wodsmith-db/src/schemas/common.ts | 5 + .../wodsmith-db/src/schemas/competitions.ts | 3 +- .../wodsmith-db/src/schemas/programming.ts | 5 + packages/wodsmith-db/src/schemas/scores.ts | 6 + 35 files changed, 14169 insertions(+), 97 deletions(-) create mode 100644 apps/wodsmith-start/src/db/schemas/benchmarks.ts create mode 100644 apps/wodsmith-start/src/schemas/benchmark.schema.ts create mode 100644 apps/wodsmith-start/test/components/organizer-competition-edit-form.test.tsx create mode 100644 apps/wodsmith-start/test/schemas/benchmark.test.ts create mode 100644 packages/wodsmith-db/mysql-migrations/0003_benchmark-battery.sql create mode 100644 packages/wodsmith-db/mysql-migrations/meta/0003_snapshot.json create mode 100644 packages/wodsmith-db/src/schemas/benchmarks.ts diff --git a/ai/research/hillerfit-benchmark-leaderboard/requirements.md b/ai/research/hillerfit-benchmark-leaderboard/requirements.md index 16e5e7042..b4c58bdba 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/requirements.md +++ b/ai/research/hillerfit-benchmark-leaderboard/requirements.md @@ -53,4 +53,4 @@ v1 is done when a seeded benchmark board can be created, a qualified athlete can ## Current Repository Baseline -As of this packet, `apps/wodsmith-start/src/lib/competitions/capabilities.ts` already defines `competitionCan`, `leaderboardVariant`, selectability helpers, result-entry helpers, and a registry for `in-person` and `online`. The existing test suite intentionally expects `benchmark` to be unknown and fail closed. The first benchmark task updates that baseline deliberately. +As of M0a, `apps/wodsmith-start/src/lib/competitions/capabilities.ts` defines `competitionCan`, `leaderboardVariant`, selectability helpers, result-entry helpers, and registered entries for `in-person`, `online`, and `benchmark`. Benchmark declares `videoSubmissions` and `perpetual`, remains hidden from the generic create picker, and still requires M1 schema/seed work before real benchmark rows can exist. diff --git a/ai/research/hillerfit-benchmark-leaderboard/tasks.md b/ai/research/hillerfit-benchmark-leaderboard/tasks.md index 47e661ec9..bced274ac 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/tasks.md +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -16,9 +16,9 @@ Acceptance criteria: - Benchmark-capable registry entry can be added without reintroducing literal online/in-person checks at those chokepoints. - `scoringAlgorithm === "online"` branches remain untouched. -## M1 Schema And PDF-Derived Seed +## M1a Schema Primitives -Add benchmark type, benchmark tables, shared columns, validation schemas, and the training-PDF-derived seed. +Add benchmark persistence primitives, shared columns, and validation schemas. This slice does not seed PDF-derived benchmark rows. Acceptance criteria: @@ -26,9 +26,20 @@ Acceptance criteria: - `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. +- No task creates HillerFit-branded pages, routes, product navigation, marketing copy, logos, or theme treatments. + +## 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 or companion receipt records extraction assumptions and any intentionally deferred tests. -- No task creates HillerFit-branded pages, routes, marketing copy, logos, or theme treatments. +- Seed validation proves every included test has 10 thresholds per supported variant and a matching tagged `trackWorkout`. +- No task creates HillerFit-branded pages, routes, product navigation, marketing copy, logos, or theme treatments. ## M2 Absolute-Tier Scoring diff --git a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md index 901fad07f..e424ec307 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -18,7 +18,7 @@ This design defines the components and contracts engineers should build for v1. ### New Tables -- `benchmark_batteries`: named product, owner, slug, categories JSON, rating bands JSON, `maxTier`, `scoreMax`, `videoPolicy`, `isOpenJoin`, `variantScalingGroupId`, `competitionId`, status, and non-null `ownerKey` for global slug uniqueness. +- `benchmark_batteries`: named product, 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. @@ -35,10 +35,11 @@ This design defines the components and contracts engineers should build for v1. ## Core Dependencies - M0a registry extension must land before benchmark submission and leaderboard chokepoints. The base registry already exists for current competition types. -- M1 schema/seed must land before M2 integration tests can use real benchmark rows. +- 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. -- M1 seed data depends on extracting tests/thresholds from the local training PDF and recording extraction assumptions. +- M1b seed data depends on extracting tests/thresholds from the local training PDF and recording extraction assumptions. ## Read Flow @@ -73,4 +74,4 @@ This design defines the components and contracts engineers should build for v1. ## Branding Boundary -Benchmark routes, tabs, headings, empty states, and stats components use WODsmith's generic benchmark language. The PDF may influence seed data names, test labels, thresholds, categories, and rating bands, but it must not introduce HillerFit-branded pages, routes, logos, marketing sections, or visual theme. +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, and rating bands, but it must not introduce HillerFit-branded pages, routes, navigation entries, logos, marketing sections, or visual theme. diff --git a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md index c8a406bf0..5ba061bd8 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md +++ b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md @@ -30,7 +30,7 @@ This strategy makes the implementation testable without requiring the full histo - Leaderboard chooses online visual variant for benchmark through `leaderboardVariant` while avoiding publish gating. - Stats tab appears only for `absolute_tier` competitions. - Stats page distinguishes untested, attempted tier 0, unavailable config, pending, verified, adjusted, and invalid/excluded states. -- Route/component assertions confirm benchmark pages use generic WODsmith benchmark language and do not introduce HillerFit-branded pages/routes. +- Route/component/navigation assertions confirm benchmark pages use generic WODsmith benchmark language and do not introduce HillerFit-branded pages, routes, tabs, product navigation entries, or calls to action. - 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. @@ -51,7 +51,7 @@ This strategy makes the implementation testable without requiring the full histo 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, logo, marketing section, or theme treatment was added. +9. Confirm no HillerFit-branded page, route, tab, product navigation entry, logo, marketing section, call to action, or theme treatment was added. ## Required Checks diff --git a/ai/research/hillerfit-benchmark-leaderboard/traceability.md b/ai/research/hillerfit-benchmark-leaderboard/traceability.md index 55e508b47..285064201 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/traceability.md +++ b/ai/research/hillerfit-benchmark-leaderboard/traceability.md @@ -4,13 +4,14 @@ This matrix links implementation artifacts back to the guide and the required te | 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/M1 | Extend current registry, capability truth table, chokepoint characterization | +| 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` M1/M2 | PDF-derived seed validation, derived denominator, deferred tests 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` | -| No HillerFit-branded pages | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `assumptions-and-decisions.md` | Route/component assertions and manual QA | -| PDF source data | Guide §0 D-D, §12.12 | `requirements.md`, `tasks.md` M1, `test-strategy.md` | Extraction receipt and seed validation | +| No HillerFit-branded pages or navigation | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `tasks.md`, `assumptions-and-decisions.md` | Route/component/navigation assertions and manual QA | +| PDF source data | Guide §0 D-D, §12.12 | `requirements.md`, `tasks.md` M1b, `test-strategy.md` | Extraction receipt and seed validation | diff --git a/apps/crew/src/server/crew-judge-rotations.server.ts b/apps/crew/src/server/crew-judge-rotations.server.ts index b2f87761d..5400b1b90 100644 --- a/apps/crew/src/server/crew-judge-rotations.server.ts +++ b/apps/crew/src/server/crew-judge-rotations.server.ts @@ -48,6 +48,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 @@ -71,7 +73,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-start/src/components/cohost-sidebar.tsx b/apps/wodsmith-start/src/components/cohost-sidebar.tsx index 07e332dad..4905daa67 100644 --- a/apps/wodsmith-start/src/components/cohost-sidebar.tsx +++ b/apps/wodsmith-start/src/components/cohost-sidebar.tsx @@ -55,7 +55,7 @@ import { canDisplayPhysicalVenue } from "@/lib/competitions/venue-volunteer-gate interface CohostSidebarProps { competitionId: string competitionName: string - competitionType?: "in-person" | "online" + competitionType?: string permissions: CohostMembershipMetadata children: React.ReactNode } @@ -74,7 +74,7 @@ interface NavGroup { const getNavigation = ( basePath: string, - competitionType?: "in-person" | "online", + competitionType?: string, permissions?: CohostMembershipMetadata, ): { overview: NavItem; groups: NavGroup[] } => { const type = competitionType ?? "" diff --git a/apps/wodsmith-start/src/components/compete/scoring-config-form.tsx b/apps/wodsmith-start/src/components/compete/scoring-config-form.tsx index c6d4f2b36..d4dbe72be 100644 --- a/apps/wodsmith-start/src/components/compete/scoring-config-form.tsx +++ b/apps/wodsmith-start/src/components/compete/scoring-config-form.tsx @@ -58,6 +58,18 @@ function EditablePointsPreview({ ) } + if (!basePoints && algorithm === "absolute_tier") { + return ( +
+

Points Preview

+

+ Absolute-tier scoring uses fixed benchmark thresholds from the + selected benchmark battery. +

+
+ ) + } + // scoringAlgorithm axis, not competitionType. // Online scoring - static display, no customization if (algorithm === "online" || baseTemplate === "online") { @@ -257,6 +269,7 @@ function getBaseTemplateFromAlgorithm( case "custom": case "p_score": case "online": + case "absolute_tier": return "traditional" case "winner_takes_more": return "winner_takes_more" @@ -273,6 +286,7 @@ function getPreviewTemplateFromAlgorithm( case "traditional": case "custom": case "p_score": + case "absolute_tier": return "traditional" case "winner_takes_more": return "winner_takes_more" @@ -477,6 +491,8 @@ export function ScoringConfigForm({ return null // P-Score is dynamic case "online": return null // Online is handled separately in preview + case "absolute_tier": + return null // Benchmark thresholds are displayed by benchmark-specific UI default: return generatePointsTable("traditional", 100) } diff --git a/apps/wodsmith-start/src/components/competition-sidebar.tsx b/apps/wodsmith-start/src/components/competition-sidebar.tsx index ad68c287a..e3226cbc6 100644 --- a/apps/wodsmith-start/src/components/competition-sidebar.tsx +++ b/apps/wodsmith-start/src/components/competition-sidebar.tsx @@ -64,7 +64,7 @@ import { cn } from "@/utils/cn" interface CompetitionSidebarProps { competitionId: string - competitionType?: "in-person" | "online" + competitionType?: string children: React.ReactNode } @@ -82,7 +82,7 @@ interface NavGroup { const getNavigation = ( basePath: string, - competitionType?: "in-person" | "online", + competitionType?: string, ): { overview: NavItem; groups: NavGroup[] } => { const type = competitionType ?? "" diff --git a/apps/wodsmith-start/src/components/leaderboard-page-content.tsx b/apps/wodsmith-start/src/components/leaderboard-page-content.tsx index 825982656..8e8a55faa 100644 --- a/apps/wodsmith-start/src/components/leaderboard-page-content.tsx +++ b/apps/wodsmith-start/src/components/leaderboard-page-content.tsx @@ -49,7 +49,7 @@ interface LeaderboardDivision { interface LeaderboardCompetitionInfo { slug: string - competitionType: "in-person" | "online" + competitionType: string } /** diff --git a/apps/wodsmith-start/src/components/organizer-competition-form.tsx b/apps/wodsmith-start/src/components/organizer-competition-form.tsx index 8db847071..d5a9c1143 100644 --- a/apps/wodsmith-start/src/components/organizer-competition-form.tsx +++ b/apps/wodsmith-start/src/components/organizer-competition-form.tsx @@ -155,6 +155,12 @@ export function OrganizerCompetitionForm({ const navigate = useNavigate() const router = useRouter() const isEditMode = !!competition + const initialCompetitionType: CompetitionTypeId = + isSelectableCompetitionTypeValue(competition?.competitionType) + ? competition.competitionType + : "in-person" + const showCompetitionTypeField = + !isEditMode || isSelectableCompetitionTypeValue(competition?.competitionType) // Determine if existing competition is multi-day (start and end dates differ) const existingIsMultiDay = competition @@ -168,7 +174,7 @@ export function OrganizerCompetitionForm({ teamId: competition?.organizingTeamId ?? selectedTeamId, name: competition?.name ?? "", slug: competition?.slug ?? "", - competitionType: competition?.competitionType ?? "in-person", + competitionType: initialCompetitionType, isMultiDay: existingIsMultiDay, startDate: competition?.startDate ? formatDateForInput(competition.startDate) @@ -442,40 +448,41 @@ export function OrganizerCompetitionForm({ )} /> - {/* Competition Type */} - ( - - Competition Type - - - {field.value === "online" - ? "Athletes submit video recordings of their workouts" - : "Athletes compete at a physical venue"} - - - - )} - /> + {showCompetitionTypeField && ( + ( + + Competition Type + + + {field.value === "online" + ? "Athletes submit video recordings of their workouts" + : "Athletes compete at a physical venue"} + + + + )} + /> + )} {/* Series/Group Selector */} {groups.length > 0 && ( diff --git a/apps/wodsmith-start/src/db/schemas/benchmarks.ts b/apps/wodsmith-start/src/db/schemas/benchmarks.ts new file mode 100644 index 000000000..5d9a85732 --- /dev/null +++ b/apps/wodsmith-start/src/db/schemas/benchmarks.ts @@ -0,0 +1 @@ +export * from "@repo/wodsmith-db/schemas/benchmarks" diff --git a/apps/wodsmith-start/src/lib/scoring/algorithms/index.ts b/apps/wodsmith-start/src/lib/scoring/algorithms/index.ts index 1858ce3a1..359353e85 100644 --- a/apps/wodsmith-start/src/lib/scoring/algorithms/index.ts +++ b/apps/wodsmith-start/src/lib/scoring/algorithms/index.ts @@ -177,6 +177,8 @@ export function calculateEventPoints( return calculateOnlineEventPoints(scores, scheme, config) case "custom": return calculateCustomEventPoints(scores, scheme, config) + case "absolute_tier": + throw new Error("absolute_tier scoring is configured but not implemented") default: { // TypeScript exhaustiveness check const _exhaustive: never = config.algorithm @@ -656,6 +658,8 @@ export function calculatePointsForPlace({ ) case "p_score": return 0 + case "absolute_tier": + throw new Error("absolute_tier place points are not available") default: { const _exhaustive: never = config.algorithm throw new Error(`Unknown scoring algorithm: ${_exhaustive}`) @@ -680,6 +684,8 @@ export function getScoringAlgorithmName( return "Online" case "custom": return "Custom" + case "absolute_tier": + return "Absolute Tier" } } diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/judges/judge-scheduling-container.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/judges/judge-scheduling-container.tsx index 608f9c22f..0e6dee654 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/judges/judge-scheduling-container.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/judges/judge-scheduling-container.tsx @@ -121,7 +121,7 @@ interface JudgeSchedulingContainerProps { competitionId: string competitionSlug: string organizingTeamId: string - competitionType: "in-person" | "online" + competitionType: string events: CompetitionWorkout[] heats: HeatWithAssignments[] judges: JudgeVolunteerInfo[] diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/organizer-competition-edit-form.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/organizer-competition-edit-form.tsx index 2a65ed6fc..4975ee192 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/organizer-competition-edit-form.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/organizer-competition-edit-form.tsx @@ -42,6 +42,7 @@ import { isSelectableCompetitionTypeValue, selectableCompetitionTypeOptions, } from "@/lib/competitions/capabilities" +import { canDisplayPhysicalVenue } from "@/lib/competitions/venue-volunteer-gates" import { updateCompetitionFn } from "@/server-fns/competition-fns" import { COMMON_US_TIMEZONES, DEFAULT_TIMEZONE } from "@/utils/timezone-utils" @@ -194,13 +195,20 @@ export function OrganizerCompetitionEditForm({ const existingIsMultiDay = formatDateForInput(competition.startDate) !== formatDateForInput(competition.endDate) + const initialCompetitionType: CompetitionTypeId = + isSelectableCompetitionTypeValue(competition.competitionType) + ? competition.competitionType + : "in-person" + const showCompetitionTypeField = isSelectableCompetitionTypeValue( + competition.competitionType, + ) const form = useForm({ resolver: standardSchemaResolver(formSchema), defaultValues: { name: competition.name, slug: competition.slug, - competitionType: competition.competitionType ?? "in-person", + competitionType: initialCompetitionType, isMultiDay: existingIsMultiDay, startDate: formatDateForInput(competition.startDate), endDate: formatDateForInput(competition.endDate), @@ -228,6 +236,10 @@ export function OrganizerCompetitionEditForm({ const isMultiDay = form.watch("isMultiDay") const competitionType = form.watch("competitionType") + const effectiveCompetitionType = showCompetitionTypeField + ? competitionType + : competition.competitionType + const showLocationSection = canDisplayPhysicalVenue(effectiveCompetitionType) // Auto-generate slug from name const handleNameChange = (name: string) => { @@ -261,11 +273,13 @@ export function OrganizerCompetitionEditForm({ groupId: data.groupId, visibility: data.visibility, status: data.status, - competitionType: data.competitionType, + ...(showCompetitionTypeField + ? { competitionType: data.competitionType } + : {}), profileImageUrl, bannerImageUrl, timezone: data.timezone, - address: data.address, + address: showLocationSection ? data.address : undefined, }, }) toast.success("Competition updated successfully") @@ -337,39 +351,40 @@ export function OrganizerCompetitionEditForm({ )} /> - {/* Competition Type */} - ( - - Competition Type - - - {field.value === "online" - ? "Athletes submit video recordings of their workouts" - : "Athletes compete at a physical venue"} - - - - )} - /> + {showCompetitionTypeField && ( + ( + + Competition Type + + + {field.value === "online" + ? "Athletes submit video recordings of their workouts" + : "Athletes compete at a physical venue"} + + + + )} + /> + )} - {/* Location Section - Only shown for in-person competitions */} - {competitionType === "in-person" && ( + {/* Location Section - Only shown for competition types with physical venues */} + {showLocationSection && (
diff --git a/apps/wodsmith-start/src/schemas/benchmark.schema.ts b/apps/wodsmith-start/src/schemas/benchmark.schema.ts new file mode 100644 index 000000000..49de73906 --- /dev/null +++ b/apps/wodsmith-start/src/schemas/benchmark.schema.ts @@ -0,0 +1,106 @@ +import { z } from "zod" + +export const benchmarkKeySchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-z0-9_]*$/, "Use lowercase letters, numbers, and underscores") + +export const benchmarkCategorySchema = z + .object({ + key: benchmarkKeySchema, + label: z.string().min(1).max(80), + testCount: z.number().int().nonnegative(), + weight: z.number().positive().default(1), + }) + .strict() + +export const benchmarkCategoriesSchema = z + .array(benchmarkCategorySchema) + .min(1) + .superRefine((categories, ctx) => { + const seen = new Set() + + for (const [index, category] of categories.entries()) { + if (seen.has(category.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate benchmark category key: ${category.key}`, + path: [index, "key"], + }) + } + seen.add(category.key) + } + }) + +export const benchmarkRatingBandSchema = z + .object({ + key: benchmarkKeySchema, + label: z.string().min(1).max(80), + minScore: z.number().min(0), + maxScore: z.number().min(0), + }) + .strict() + .refine((band) => band.maxScore >= band.minScore, { + message: "maxScore must be greater than or equal to minScore", + path: ["maxScore"], + }) + +export const benchmarkRatingBandsSchema = z + .array(benchmarkRatingBandSchema) + .min(1) + +export const benchmarkVideoPolicySchema = z.enum([ + "never", + "for_top_scores", + "always", +]) + +export const benchmarkThresholdValuesSchema = z.array(z.number().int()).length(10) + +export const benchmarkVariantSchema = z.enum(["male", "female"]) + +export interface BenchmarkCategoryCountTest { + categoryKey: string + includedInScoring: boolean +} + +export function getBenchmarkCategoryCountIssues( + categories: ReadonlyArray, + tests: Iterable, +): string[] { + const actualCounts = new Map() + + for (const test of tests) { + if (!test.includedInScoring) continue + actualCounts.set( + test.categoryKey, + (actualCounts.get(test.categoryKey) ?? 0) + 1, + ) + } + + const categoryKeys = new Set(categories.map((category) => category.key)) + const issues: string[] = [] + + for (const category of categories) { + const actualCount = actualCounts.get(category.key) ?? 0 + if (actualCount !== category.testCount) { + issues.push( + `Category ${category.key} declares testCount ${category.testCount} but has ${actualCount} included tests`, + ) + } + } + + for (const categoryKey of actualCounts.keys()) { + if (!categoryKeys.has(categoryKey)) { + issues.push(`Included test references unknown category ${categoryKey}`) + } + } + + return issues +} + +export type BenchmarkCategory = z.infer +export type BenchmarkRatingBand = z.infer +export type BenchmarkVideoPolicy = z.infer +export type BenchmarkVariant = z.infer diff --git a/apps/wodsmith-start/src/schemas/scoring.schema.ts b/apps/wodsmith-start/src/schemas/scoring.schema.ts index 4df11cc03..ac0fe362f 100644 --- a/apps/wodsmith-start/src/schemas/scoring.schema.ts +++ b/apps/wodsmith-start/src/schemas/scoring.schema.ts @@ -16,6 +16,7 @@ import { z } from "zod" * - winner_takes_more: Top positions get disproportionately more points (like Functional Fitness Games) * - online: Place-based scoring (1st=1pt, 2nd=2pts...) - lowest total wins * - custom: User-defined points table with overrides + * - absolute_tier: Fixed benchmark thresholds scored as absolute tiers */ export const scoringAlgorithmSchema = z.enum([ "traditional", @@ -23,6 +24,7 @@ export const scoringAlgorithmSchema = z.enum([ "winner_takes_more", "online", "custom", + "absolute_tier", ]) export type ScoringAlgorithm = z.infer @@ -75,6 +77,12 @@ export const customTableConfigSchema = z.object({ }) export type CustomTableConfig = z.infer +export const absoluteTierConfigSchema = z.object({ + /** Benchmark battery whose category/test/threshold rows define the scoring table */ + batteryId: z.string().min(1), +}) +export type AbsoluteTierConfig = z.infer + /** * Tiebreaker configuration (base schema without default) */ @@ -134,10 +142,21 @@ export const scoringConfigSchema = z.object({ /** Custom points table settings (optional) */ customTable: customTableConfigSchema.optional(), + /** Absolute-tier benchmark settings (required for absolute_tier) */ + absoluteTier: absoluteTierConfigSchema.optional(), + /** Tiebreaker configuration */ tiebreaker: tiebreakerConfigSchema, /** DNF/DNS/Withdrawn handling */ statusHandling: statusHandlingConfigSchema, +}).superRefine((config, ctx) => { + if (config.algorithm === "absolute_tier" && !config.absoluteTier?.batteryId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "absoluteTier.batteryId is required for absolute_tier scoring", + path: ["absoluteTier", "batteryId"], + }) + } }) export type ScoringConfig = z.infer diff --git a/apps/wodsmith-start/src/server-fns/check-in-fns.ts b/apps/wodsmith-start/src/server-fns/check-in-fns.ts index f9e7f4f59..c2aaef845 100644 --- a/apps/wodsmith-start/src/server-fns/check-in-fns.ts +++ b/apps/wodsmith-start/src/server-fns/check-in-fns.ts @@ -51,7 +51,7 @@ async function requireCheckInAccess(competitionId: string): Promise<{ id: string organizingTeamId: string competitionTeamId: string | null - competitionType: "in-person" | "online" + competitionType: string } userId: string }> { diff --git a/apps/wodsmith-start/src/server-fns/competition-server-logic.ts b/apps/wodsmith-start/src/server-fns/competition-server-logic.ts index 2d0ea4bdf..a005da453 100644 --- a/apps/wodsmith-start/src/server-fns/competition-server-logic.ts +++ b/apps/wodsmith-start/src/server-fns/competition-server-logic.ts @@ -18,6 +18,8 @@ import { import { teamTable } from "@/db/schemas/teams" import { generateSlug } from "@/utils/slugify" +type StoredCompetitionType = Competition["competitionType"] + /* -------------------------------------------------------------------------- */ /* Competition Group Functions */ /* -------------------------------------------------------------------------- */ @@ -193,7 +195,7 @@ export async function createCompetition(params: { groupId?: string settings?: string timezone?: string // IANA timezone string (e.g., "America/Denver") - competitionType?: "in-person" | "online" + competitionType?: StoredCompetitionType }): Promise<{ competitionId: string; competitionTeamId: string }> { const db = getDb() @@ -340,7 +342,7 @@ export async function updateCompetition( settings: string | null visibility: "public" | "private" status: "draft" | "published" - competitionType: "in-person" | "online" + competitionType: StoredCompetitionType profileImageUrl: string | null bannerImageUrl: string | null timezone: string // IANA timezone string diff --git a/apps/wodsmith-start/src/types/scoring.ts b/apps/wodsmith-start/src/types/scoring.ts index 2c7f034ae..00f2887ae 100644 --- a/apps/wodsmith-start/src/types/scoring.ts +++ b/apps/wodsmith-start/src/types/scoring.ts @@ -11,6 +11,7 @@ // Re-export all types from the Zod schema file // This provides a single import point for types without needing to import Zod export type { + AbsoluteTierConfig, CustomTableConfig, PScoreConfig, ScoringAlgorithm, @@ -23,6 +24,7 @@ export type { // Re-export schemas for validation use export { + absoluteTierConfigSchema, customTableConfigSchema, pScoreConfigSchema, scoringAlgorithmSchema, diff --git a/apps/wodsmith-start/test/components/organizer-competition-edit-form.test.tsx b/apps/wodsmith-start/test/components/organizer-competition-edit-form.test.tsx new file mode 100644 index 000000000..857bcc6e5 --- /dev/null +++ b/apps/wodsmith-start/test/components/organizer-competition-edit-form.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import type { ComponentProps } from "react" +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { OrganizerCompetitionEditForm } from "@/routes/compete/organizer/$competitionId/-components/organizer-competition-edit-form" + +const updateCompetitionFnMock = vi.hoisted(() => vi.fn()) + +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ navigate: vi.fn(), invalidate: vi.fn() }), +})) + +vi.mock("@tanstack/react-start", () => ({ + useServerFn: (fn: unknown) => fn, +})) + +vi.mock("@/server-fns/competition-fns", () => ({ + updateCompetitionFn: updateCompetitionFnMock, +})) + +vi.mock("@/components/forms/address-fields", () => ({ + AddressFields: () =>
Address fields
, +})) + +vi.mock("@/components/ui/image-upload", () => ({ + ImageUpload: () =>
, +})) + +type EditCompetition = ComponentProps< + typeof OrganizerCompetitionEditForm +>["competition"] + +beforeAll(() => { + globalThis.ResizeObserver ??= class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } +}) + +function createCompetition(competitionType: string): EditCompetition { + return { + id: "comp_test", + organizingTeamId: "team_org", + competitionTeamId: "team_event", + groupId: null, + slug: "benchmark-board", + name: "Benchmark Board", + description: null, + startDate: "2026-01-01", + endDate: "2026-01-01", + registrationOpensAt: null, + registrationClosesAt: null, + timezone: "America/Boise", + settings: null, + defaultRegistrationFeeCents: 0, + platformFeePercentage: 0, + platformFeeFixed: 0, + passStripeFeesToCustomer: false, + passPlatformFeesToCustomer: false, + visibility: "public", + status: "draft", + competitionType, + profileImageUrl: null, + bannerImageUrl: null, + defaultHeatsPerRotation: null, + defaultLaneShiftPattern: null, + defaultMaxSpotsPerDivision: null, + maxTotalRegistrations: null, + primaryAddressId: "addr_test", + primaryAddress: { + name: "Test Venue", + streetLine1: "123 Main St", + streetLine2: null, + city: "Boise", + stateProvince: "ID", + postalCode: "83702", + countryCode: "US", + notes: null, + }, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + updateCounter: 0, + } as EditCompetition +} + +describe("OrganizerCompetitionEditForm", () => { + beforeEach(() => { + updateCompetitionFnMock.mockReset() + updateCompetitionFnMock.mockResolvedValue({}) + }) + + // @lat: [[competition-type-capabilities#Venue and Volunteer Gates Test#Edit Form Physical Venue Gate]] + it("hides venue fields for stored benchmark competitions and omits address updates", async () => { + render( + , + ) + + expect(screen.queryByRole("heading", { name: "Location" })).toBeNull() + expect(screen.queryByTestId("address-fields")).toBeNull() + + fireEvent.click(screen.getByRole("button", { name: "Save changes" })) + + await waitFor(() => { + expect(updateCompetitionFnMock).toHaveBeenCalled() + }) + + const [{ data }] = updateCompetitionFnMock.mock.calls[0] + expect(data.competitionId).toBe("comp_test") + expect(Object.hasOwn(data, "address")).toBe(true) + expect(data.address).toBeUndefined() + }) + + it("keeps venue fields visible for stored in-person competitions", () => { + render( + , + ) + + expect(screen.getByRole("heading", { name: "Location" })).toBeInTheDocument() + expect(screen.getByTestId("address-fields")).toBeInTheDocument() + }) +}) diff --git a/apps/wodsmith-start/test/lib/scoring/factory.test.ts b/apps/wodsmith-start/test/lib/scoring/factory.test.ts index 5ddde51cb..70a59c33c 100644 --- a/apps/wodsmith-start/test/lib/scoring/factory.test.ts +++ b/apps/wodsmith-start/test/lib/scoring/factory.test.ts @@ -246,6 +246,21 @@ describe("Scoring Factory", () => { expect(results.get("c")?.rank).toBe(3) }) }) + + describe("Absolute-tier algorithm", () => { + const config: ScoringConfig = { + algorithm: "absolute_tier", + absoluteTier: { batteryId: "bbat_01JZ0000000000000000000000" }, + tiebreaker: { primary: "none" }, + statusHandling: { dnf: "zero", dns: "zero", withdrawn: "zero" }, + } + + it("fails closed until the benchmark scoring engine is implemented", () => { + expect(() => + calculateEventPoints("e1", baseScores, "time", config), + ).toThrow("absolute_tier scoring is configured but not implemented") + }) + }) }) describe("Utility functions", () => { @@ -256,6 +271,7 @@ describe("Scoring Factory", () => { expect(getScoringAlgorithmName("winner_takes_more")).toBe("Winner Takes More") expect(getScoringAlgorithmName("online")).toBe("Online") expect(getScoringAlgorithmName("custom")).toBe("Custom") + expect(getScoringAlgorithmName("absolute_tier")).toBe("Absolute Tier") }) }) @@ -377,5 +393,18 @@ describe("Scoring Factory", () => { } expect(calculatePointsForPlace({ place: 6, config })).toBe(0) }) + + it("fails closed for absolute_tier until static place points are implemented", () => { + const config: ScoringConfig = { + algorithm: "absolute_tier", + absoluteTier: { batteryId: "bbat_01JZ0000000000000000000000" }, + tiebreaker: { primary: "none" }, + statusHandling: { dnf: "zero", dns: "zero", withdrawn: "zero" }, + } + + expect(() => calculatePointsForPlace({ place: 1, config })).toThrow( + "absolute_tier place points are not available", + ) + }) }) }) diff --git a/apps/wodsmith-start/test/schemas/benchmark.test.ts b/apps/wodsmith-start/test/schemas/benchmark.test.ts new file mode 100644 index 000000000..cd14490a1 --- /dev/null +++ b/apps/wodsmith-start/test/schemas/benchmark.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" +import { + benchmarkCategoriesSchema, + benchmarkVariantSchema, + getBenchmarkCategoryCountIssues, + benchmarkRatingBandsSchema, + benchmarkThresholdValuesSchema, + benchmarkVideoPolicySchema, +} from "@/schemas/benchmark.schema" + +describe("Benchmark schema", () => { + it("accepts categories with validated test counts and default weights", () => { + const result = benchmarkCategoriesSchema.safeParse([ + { key: "strength", label: "Strength", testCount: 12 }, + { key: "gymnastics", label: "Gymnastics", testCount: 15, weight: 2 }, + ]) + + expect(result.success).toBe(true) + expect(result.data?.[0]?.weight).toBe(1) + expect(result.data?.[1]?.testCount).toBe(15) + }) + + it("rejects duplicate category keys", () => { + const result = benchmarkCategoriesSchema.safeParse([ + { key: "strength", label: "Strength", testCount: 12 }, + { key: "strength", label: "Strength Again", testCount: 3 }, + ]) + + expect(result.success).toBe(false) + }) + + it("rejects malformed category cache values", () => { + const result = benchmarkCategoriesSchema.safeParse([ + { key: "Upper Body", label: "Upper Body", testCount: -1 }, + ]) + + expect(result.success).toBe(false) + }) + + it("accepts only profile-gender variants for v1 scoring", () => { + expect(benchmarkVariantSchema.safeParse("male").success).toBe(true) + expect(benchmarkVariantSchema.safeParse("female").success).toBe(true) + expect(benchmarkVariantSchema.safeParse("masters").success).toBe(false) + }) + + it("validates cached category counts against included tests", () => { + const categories = benchmarkCategoriesSchema.parse([ + { key: "strength", label: "Strength", testCount: 2 }, + { key: "engine", label: "Engine", testCount: 1 }, + ]) + + expect( + getBenchmarkCategoryCountIssues(categories, [ + { categoryKey: "strength", includedInScoring: true }, + { categoryKey: "strength", includedInScoring: true }, + { categoryKey: "engine", includedInScoring: true }, + { categoryKey: "engine", includedInScoring: false }, + ]), + ).toEqual([]) + }) + + it("reports stale category counts and unknown included-test categories", () => { + const categories = benchmarkCategoriesSchema.parse([ + { key: "strength", label: "Strength", testCount: 2 }, + ]) + + expect( + getBenchmarkCategoryCountIssues(categories, [ + { categoryKey: "strength", includedInScoring: true }, + { categoryKey: "engine", includedInScoring: true }, + ]), + ).toEqual([ + "Category strength declares testCount 2 but has 1 included tests", + "Included test references unknown category engine", + ]) + }) + + it("requires ten threshold values for a standard tier table", () => { + expect( + benchmarkThresholdValuesSchema.safeParse([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + .success, + ).toBe(true) + expect(benchmarkThresholdValuesSchema.safeParse([1, 2, 3]).success).toBe( + false, + ) + }) + + it("accepts benchmark video policies", () => { + expect(benchmarkVideoPolicySchema.safeParse("never").success).toBe(true) + expect(benchmarkVideoPolicySchema.safeParse("for_top_scores").success).toBe( + true, + ) + expect(benchmarkVideoPolicySchema.safeParse("sometimes").success).toBe(false) + }) + + it("rejects rating bands with inverted score ranges", () => { + const result = benchmarkRatingBandsSchema.safeParse([ + { key: "advanced", label: "Advanced", minScore: 80, maxScore: 70 }, + ]) + + expect(result.success).toBe(false) + }) +}) diff --git a/apps/wodsmith-start/test/schemas/scoring.test.ts b/apps/wodsmith-start/test/schemas/scoring.test.ts index 3ba42c532..f48cef310 100644 --- a/apps/wodsmith-start/test/schemas/scoring.test.ts +++ b/apps/wodsmith-start/test/schemas/scoring.test.ts @@ -13,6 +13,7 @@ import { traditionalConfigSchema, pScoreConfigSchema, customTableConfigSchema, + absoluteTierConfigSchema, tiebreakerConfigSchema, statusHandlingConfigSchema, type ScoringConfig, @@ -39,6 +40,12 @@ describe("ScoringAlgorithm Schema", () => { expect(result.data).toBe("custom") }) + it("accepts 'absolute_tier' algorithm", () => { + const result = scoringAlgorithmSchema.safeParse("absolute_tier") + expect(result.success).toBe(true) + expect(result.data).toBe("absolute_tier") + }) + it("rejects invalid algorithm", () => { const result = scoringAlgorithmSchema.safeParse("invalid_algo") expect(result.success).toBe(false) @@ -178,6 +185,23 @@ describe("CustomTableConfig Schema", () => { }) }) +describe("AbsoluteTierConfig Schema", () => { + it("accepts valid absolute-tier config", () => { + const result = absoluteTierConfigSchema.safeParse({ + batteryId: "bbat_01JZ0000000000000000000000", + }) + + expect(result.success).toBe(true) + expect(result.data?.batteryId).toBe("bbat_01JZ0000000000000000000000") + }) + + it("rejects missing battery id", () => { + const result = absoluteTierConfigSchema.safeParse({}) + + expect(result.success).toBe(false) + }) +}) + describe("TiebreakerConfig Schema", () => { it("accepts valid tiebreaker config with primary only", () => { const result = tiebreakerConfigSchema.safeParse({ @@ -347,6 +371,34 @@ describe("ScoringConfig Schema", () => { expect(result.data?.customTable?.baseTemplate).toBe("winner_takes_more") }) + it("accepts absolute_tier config with battery id", () => { + const config: ScoringConfig = { + algorithm: "absolute_tier", + absoluteTier: { batteryId: "bbat_01JZ0000000000000000000000" }, + tiebreaker: { primary: "none" }, + statusHandling: { + dnf: "zero", + dns: "zero", + withdrawn: "zero", + }, + } + const result = scoringConfigSchema.safeParse(config) + expect(result.success).toBe(true) + expect(result.data?.absoluteTier?.batteryId).toBe( + "bbat_01JZ0000000000000000000000", + ) + }) + + it("rejects absolute_tier config without battery id", () => { + const result = scoringConfigSchema.safeParse({ + algorithm: "absolute_tier", + tiebreaker: { primary: "none" }, + statusHandling: { dnf: "zero", dns: "zero", withdrawn: "zero" }, + }) + + expect(result.success).toBe(false) + }) + it("rejects missing algorithm", () => { const result = scoringConfigSchema.safeParse({ tiebreaker: { primary: "countback" }, @@ -382,8 +434,8 @@ describe("ScoringConfig Schema", () => { describe("Type inference", () => { it("infers correct type for ScoringAlgorithm", () => { - const algo: ScoringAlgorithm = "traditional" - expect(["traditional", "p_score", "custom"]).toContain(algo) + const algo: ScoringAlgorithm = "absolute_tier" + expect(["traditional", "p_score", "custom", "absolute_tier"]).toContain(algo) }) it("infers correct type for TiebreakerMethod", () => { diff --git a/lat.md/competition-type-capabilities.md b/lat.md/competition-type-capabilities.md index 3505e1674..a5ac35346 100644 --- a/lat.md/competition-type-capabilities.md +++ b/lat.md/competition-type-capabilities.md @@ -92,6 +92,10 @@ These tests pin the capability helpers that control physical venue display and v This test verifies physical venue display remains available only for in-person competitions. +### Edit Form Physical Venue Gate + +This test verifies stored benchmark competitions do not render or submit generic edit-form venue fields through the in-person picker fallback. + ### Volunteer Scheduling Availability This test verifies volunteer scheduling remains available only for in-person competitions. diff --git a/lat.md/domain.md b/lat.md/domain.md index d253b5d42..443ffbe74 100644 --- a/lat.md/domain.md +++ b/lat.md/domain.md @@ -154,6 +154,16 @@ All write paths that touch multi-round `time-with-cap` scores now thread `capped The organizer review page at [[apps/wodsmith-start/src/routes/compete/organizer/$competitionId/events/$eventId/submissions/$submissionId.tsx]] can apply a penalty to a selected subset of rounds on a multi-round submission. The penalty form renders a checkbox + before/after line per round (all rounds checked by default) and, on submit, scales each selected round's encoded value through `calculatePenaltyScore` with a forced `"scored"` status (per-round time values always grow under a penalty, even if the round hit the cap — the parent `"cap"` status is irrelevant at the round level). The resulting round values are decoded to display strings and sent as `adjustedRoundScores[]` alongside `penaltyType`/`penaltyPercentage` via `verifySubmissionScoreFn`, so the server re-derives per-round status, parent aggregate, `cappedRoundCount`, and `sortKey` just like a round-level adjust. The single-value "reps-at-cap" preview and the direct-override textbox are only shown for single-round submissions, where `secondaryValue` actually carries the penalizable reps count. +## Benchmark Batteries + +Benchmark batteries define reusable threshold tables for generic benchmark competitions. + +[[packages/wodsmith-db/src/schemas/benchmarks.ts#benchmarkBatteriesTable]] stores the battery metadata, category cache, rating bands, scoring scale, video policy, optional open-join flag, variant scaling group, and linked competition. [[packages/wodsmith-db/src/schemas/benchmarks.ts#benchmarkTestsTable]] stores the ordered benchmark tests inside a battery, including category, score scheme, score type, input unit, included/deferred state, and nullable v2 hybrid metadata. [[packages/wodsmith-db/src/schemas/benchmarks.ts#benchmarkTierThresholdsTable]] stores one encoded threshold per test, variant, and tier while preserving the source display value for extraction receipts. + +Benchmark competitions reuse the normal competition shell. [[packages/wodsmith-db/src/schemas/programming.ts#trackWorkoutsTable]] carries nullable `benchmarkTestId` and `benchmarkCategory` columns so leaderboard reads can bind each competition event to its source test and category. [[packages/wodsmith-db/src/schemas/scores.ts#scoresTable]] carries nullable `benchmarkVariant` so each score snapshots the athlete's scoring variant at submission time. + +Benchmark configuration validates before write/read code consumes it. [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkCategoriesSchema]] rejects duplicate or malformed category caches, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#getBenchmarkCategoryCountIssues]] compares cached `testCount` values to included tests, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkVariantSchema]] limits v1 score variants to the profile gender values, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkThresholdValuesSchema]] requires ten tier thresholds per standard row, and [[apps/wodsmith-start/src/schemas/scoring.schema.ts#scoringConfigSchema]] requires `absoluteTier.batteryId` whenever `algorithm` is `absolute_tier`. Until the M2 scoring engine lands, [[apps/wodsmith-start/src/lib/scoring/algorithms/index.ts#calculateEventPoints]] fails closed for `absolute_tier` instead of returning placeholder points. + ## Volunteers Volunteers are competition staff assigned roles like judge, scorekeeper, medical, and emcee. diff --git a/packages/wodsmith-db/mysql-migrations/0003_benchmark-battery.sql b/packages/wodsmith-db/mysql-migrations/0003_benchmark-battery.sql new file mode 100644 index 000000000..8ac180aed --- /dev/null +++ b/packages/wodsmith-db/mysql-migrations/0003_benchmark-battery.sql @@ -0,0 +1,70 @@ +CREATE TABLE `benchmark_batteries` ( + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `update_counter` int DEFAULT 0, + `id` varchar(255) NOT NULL, + `owner_team_id` varchar(255), + `owner_key` varchar(255) NOT NULL, + `slug` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `categories` text NOT NULL, + `rating_bands` text NOT NULL, + `max_tier` int NOT NULL DEFAULT 10, + `score_max` int NOT NULL DEFAULT 100, + `video_policy` varchar(20) NOT NULL DEFAULT 'never', + `is_open_join` boolean NOT NULL DEFAULT false, + `variant_scaling_group_id` varchar(255), + `competition_id` varchar(255), + `status` varchar(20) NOT NULL DEFAULT 'draft', + CONSTRAINT `benchmark_batteries_id` PRIMARY KEY(`id`), + CONSTRAINT `benchmark_batteries_owner_key_unique` UNIQUE(`owner_key`), + CONSTRAINT `benchmark_batteries_competition_unique` UNIQUE(`competition_id`) +); +--> statement-breakpoint +CREATE TABLE `benchmark_tests` ( + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `update_counter` int DEFAULT 0, + `id` varchar(255) NOT NULL, + `battery_id` varchar(255) NOT NULL, + `category_key` varchar(64) NOT NULL, + `name` varchar(255) NOT NULL, + `position` int NOT NULL, + `scheme` varchar(255) NOT NULL, + `score_type` varchar(255) NOT NULL, + `input_unit` varchar(64) NOT NULL, + `included_in_scoring` boolean NOT NULL DEFAULT true, + `time_cap_ms` int, + `score_model` varchar(20) NOT NULL DEFAULT 'standard', + `hybrid_flip_tier` int, + `hybrid_scale` text, + CONSTRAINT `benchmark_tests_id` PRIMARY KEY(`id`), + CONSTRAINT `benchmark_tests_battery_position_unique` UNIQUE(`battery_id`,`position`) +); +--> statement-breakpoint +CREATE TABLE `benchmark_tier_thresholds` ( + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `update_counter` int DEFAULT 0, + `id` varchar(255) NOT NULL, + `test_id` varchar(255) NOT NULL, + `variant` varchar(64) NOT NULL, + `tier` int NOT NULL, + `threshold_value` int NOT NULL, + `raw_value` varchar(255) NOT NULL, + CONSTRAINT `benchmark_tier_thresholds_id` PRIMARY KEY(`id`), + CONSTRAINT `benchmark_thresholds_test_variant_tier_unique` UNIQUE(`test_id`,`variant`,`tier`) +); +--> statement-breakpoint +ALTER TABLE `track_workouts` ADD `benchmark_test_id` varchar(255);--> statement-breakpoint +ALTER TABLE `track_workouts` ADD `benchmark_category` varchar(64);--> statement-breakpoint +ALTER TABLE `scores` ADD `benchmark_variant` varchar(64);--> statement-breakpoint +CREATE INDEX `benchmark_batteries_owner_team_idx` ON `benchmark_batteries` (`owner_team_id`);--> statement-breakpoint +CREATE INDEX `benchmark_batteries_status_idx` ON `benchmark_batteries` (`status`);--> statement-breakpoint +CREATE INDEX `benchmark_tests_battery_idx` ON `benchmark_tests` (`battery_id`);--> statement-breakpoint +CREATE INDEX `benchmark_tests_category_idx` ON `benchmark_tests` (`battery_id`,`category_key`);--> statement-breakpoint +CREATE INDEX `benchmark_thresholds_test_variant_idx` ON `benchmark_tier_thresholds` (`test_id`,`variant`);--> statement-breakpoint +CREATE INDEX `track_workout_benchmark_test_idx` ON `track_workouts` (`benchmark_test_id`);--> statement-breakpoint +CREATE INDEX `track_workout_benchmark_category_idx` ON `track_workouts` (`benchmark_category`);--> statement-breakpoint +CREATE INDEX `idx_scores_benchmark_variant` ON `scores` (`competition_event_id`,`benchmark_variant`); diff --git a/packages/wodsmith-db/mysql-migrations/meta/0003_snapshot.json b/packages/wodsmith-db/mysql-migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..db9bc8d39 --- /dev/null +++ b/packages/wodsmith-db/mysql-migrations/meta/0003_snapshot.json @@ -0,0 +1,13277 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2214f2bf-814c-414a-9c2b-5add225588f3", + "prevId": "0bf9aa44-63e3-4b9e-a6c3-78e64ee4e39e", + "tables": { + "addresses": { + "name": "addresses", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_type": { + "name": "address_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street_line1": { + "name": "street_line1", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street_line2": { + "name": "street_line2", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_province": { + "name": "state_province", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "addresses_id": { + "name": "addresses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "product_coupon_redemptions": { + "name": "product_coupon_redemptions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coupon_id": { + "name": "coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchase_id": { + "name": "purchase_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_off_cents": { + "name": "amount_off_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "product_coupon_redemptions_coupon_idx": { + "name": "product_coupon_redemptions_coupon_idx", + "columns": [ + "coupon_id" + ], + "isUnique": false + }, + "product_coupon_redemptions_user_idx": { + "name": "product_coupon_redemptions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "product_coupon_redemptions_purchase_idx": { + "name": "product_coupon_redemptions_purchase_idx", + "columns": [ + "purchase_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "product_coupon_redemptions_id": { + "name": "product_coupon_redemptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "product_coupons": { + "name": "product_coupons", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'competition'" + }, + "product_id": { + "name": "product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_off_cents": { + "name": "amount_off_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_redemptions": { + "name": "current_redemptions", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "tinyint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "product_coupons_code_idx": { + "name": "product_coupons_code_idx", + "columns": [ + "code" + ], + "isUnique": true + }, + "product_coupons_team_product_idx": { + "name": "product_coupons_team_product_idx", + "columns": [ + "team_id", + "product_id" + ], + "isUnique": false + }, + "product_coupons_product_active_idx": { + "name": "product_coupons_product_active_idx", + "columns": [ + "product_id", + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "product_coupons_id": { + "name": "product_coupons_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "affiliates": { + "name": "affiliates", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_status": { + "name": "verification_status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unverified'" + }, + "owner_team_id": { + "name": "owner_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "affiliates_name_idx": { + "name": "affiliates_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "affiliates_owner_team_idx": { + "name": "affiliates_owner_team_idx", + "columns": [ + "owner_team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "affiliates_id": { + "name": "affiliates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "affiliates_name_unique": { + "name": "affiliates_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "benchmark_batteries": { + "name": "benchmark_batteries", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_team_id": { + "name": "owner_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_key": { + "name": "owner_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_bands": { + "name": "rating_bands", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_tier": { + "name": "max_tier", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "score_max": { + "name": "score_max", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 100 + }, + "video_policy": { + "name": "video_policy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "is_open_join": { + "name": "is_open_join", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "variant_scaling_group_id": { + "name": "variant_scaling_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + } + }, + "indexes": { + "benchmark_batteries_owner_key_unique": { + "name": "benchmark_batteries_owner_key_unique", + "columns": [ + "owner_key" + ], + "isUnique": true + }, + "benchmark_batteries_competition_unique": { + "name": "benchmark_batteries_competition_unique", + "columns": [ + "competition_id" + ], + "isUnique": true + }, + "benchmark_batteries_owner_team_idx": { + "name": "benchmark_batteries_owner_team_idx", + "columns": [ + "owner_team_id" + ], + "isUnique": false + }, + "benchmark_batteries_status_idx": { + "name": "benchmark_batteries_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_batteries_id": { + "name": "benchmark_batteries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark_tests": { + "name": "benchmark_tests", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_id": { + "name": "battery_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_key": { + "name": "category_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheme": { + "name": "scheme", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score_type": { + "name": "score_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_unit": { + "name": "input_unit", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "included_in_scoring": { + "name": "included_in_scoring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "time_cap_ms": { + "name": "time_cap_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score_model": { + "name": "score_model", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "hybrid_flip_tier": { + "name": "hybrid_flip_tier", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hybrid_scale": { + "name": "hybrid_scale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "benchmark_tests_battery_position_unique": { + "name": "benchmark_tests_battery_position_unique", + "columns": [ + "battery_id", + "position" + ], + "isUnique": true + }, + "benchmark_tests_battery_idx": { + "name": "benchmark_tests_battery_idx", + "columns": [ + "battery_id" + ], + "isUnique": false + }, + "benchmark_tests_category_idx": { + "name": "benchmark_tests_category_idx", + "columns": [ + "battery_id", + "category_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_tests_id": { + "name": "benchmark_tests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark_tier_thresholds": { + "name": "benchmark_tier_thresholds", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "test_id": { + "name": "test_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variant": { + "name": "variant", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_value": { + "name": "threshold_value", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_value": { + "name": "raw_value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "benchmark_thresholds_test_variant_tier_unique": { + "name": "benchmark_thresholds_test_variant_tier_unique", + "columns": [ + "test_id", + "variant", + "tier" + ], + "isUnique": true + }, + "benchmark_thresholds_test_variant_idx": { + "name": "benchmark_thresholds_test_variant_idx", + "columns": [ + "test_id", + "variant" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_tier_thresholds_id": { + "name": "benchmark_tier_thresholds_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_broadcast_recipients": { + "name": "competition_broadcast_recipients", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "broadcast_id": { + "name": "broadcast_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitation_id": { + "name": "invitation_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + } + }, + "indexes": { + "broadcast_recipients_broadcast_user_idx": { + "name": "broadcast_recipients_broadcast_user_idx", + "columns": [ + "broadcast_id", + "user_id" + ], + "isUnique": true + }, + "broadcast_recipients_broadcast_invite_idx": { + "name": "broadcast_recipients_broadcast_invite_idx", + "columns": [ + "broadcast_id", + "invitation_id" + ], + "isUnique": true + }, + "broadcast_recipients_broadcast_idx": { + "name": "broadcast_recipients_broadcast_idx", + "columns": [ + "broadcast_id" + ], + "isUnique": false + }, + "broadcast_recipients_user_idx": { + "name": "broadcast_recipients_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "broadcast_recipients_invitation_idx": { + "name": "broadcast_recipients_invitation_idx", + "columns": [ + "invitation_id" + ], + "isUnique": false + }, + "broadcast_recipients_status_idx": { + "name": "broadcast_recipients_status_idx", + "columns": [ + "email_delivery_status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_broadcast_recipients_id": { + "name": "competition_broadcast_recipients_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_broadcasts": { + "name": "competition_broadcasts", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audience_filter": { + "name": "audience_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_count": { + "name": "recipient_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "competition_broadcasts_competition_idx": { + "name": "competition_broadcasts_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "competition_broadcasts_team_idx": { + "name": "competition_broadcasts_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "competition_broadcasts_status_idx": { + "name": "competition_broadcasts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_broadcasts_id": { + "name": "competition_broadcasts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credit_transactions": { + "name": "credit_transactions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining_amount": { + "name": "remaining_amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiration_date": { + "name": "expiration_date", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiration_date_processed_at": { + "name": "expiration_date_processed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "credit_transaction_user_id_idx": { + "name": "credit_transaction_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "credit_transaction_type_idx": { + "name": "credit_transaction_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "credit_transaction_created_at_idx": { + "name": "credit_transaction_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "credit_transaction_expiration_date_idx": { + "name": "credit_transaction_expiration_date_idx", + "columns": [ + "expiration_date" + ], + "isUnique": false + }, + "credit_transaction_payment_intent_id_idx": { + "name": "credit_transaction_payment_intent_id_idx", + "columns": [ + "payment_intent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "credit_transactions_id": { + "name": "credit_transactions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "purchased_items": { + "name": "purchased_items", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_type": { + "name": "item_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchased_at": { + "name": "purchased_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "purchased_item_user_id_idx": { + "name": "purchased_item_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "purchased_item_type_idx": { + "name": "purchased_item_type_idx", + "columns": [ + "item_type" + ], + "isUnique": false + }, + "purchased_item_user_item_idx": { + "name": "purchased_item_user_item_idx", + "columns": [ + "user_id", + "item_type", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "purchased_items_id": { + "name": "purchased_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "commerce_products": { + "name": "commerce_products", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "commerce_product_resource_idx": { + "name": "commerce_product_resource_idx", + "columns": [ + "type", + "resource_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "commerce_products_id": { + "name": "commerce_products_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "commerce_purchases": { + "name": "commerce_purchases", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_id": { + "name": "product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cents": { + "name": "total_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_fee_cents": { + "name": "platform_fee_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_fee_cents": { + "name": "stripe_fee_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizer_net_cents": { + "name": "organizer_net_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_checkout_session_id": { + "name": "stripe_checkout_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "commerce_purchase_user_idx": { + "name": "commerce_purchase_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "commerce_purchase_product_idx": { + "name": "commerce_purchase_product_idx", + "columns": [ + "product_id" + ], + "isUnique": false + }, + "commerce_purchase_status_idx": { + "name": "commerce_purchase_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "commerce_purchase_stripe_session_idx": { + "name": "commerce_purchase_stripe_session_idx", + "columns": [ + "stripe_checkout_session_id" + ], + "isUnique": false + }, + "commerce_purchase_competition_idx": { + "name": "commerce_purchase_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "commerce_purchase_stripe_pi_idx": { + "name": "commerce_purchase_stripe_pi_idx", + "columns": [ + "stripe_payment_intent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "commerce_purchases_id": { + "name": "commerce_purchases_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_divisions": { + "name": "competition_divisions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fee_cents": { + "name": "fee_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_spots": { + "name": "max_spots", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_divisions_unique_idx": { + "name": "competition_divisions_unique_idx", + "columns": [ + "competition_id", + "division_id" + ], + "isUnique": true + }, + "competition_divisions_competition_idx": { + "name": "competition_divisions_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_divisions_id": { + "name": "competition_divisions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "purchase_transfers": { + "name": "purchase_transfers", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchase_id": { + "name": "purchase_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_email": { + "name": "target_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_email": { + "name": "accepted_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transfer_state": { + "name": "transfer_state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'INITIATED'" + }, + "initiated_by": { + "name": "initiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "purchase_transfers_purchase_idx": { + "name": "purchase_transfers_purchase_idx", + "columns": [ + "purchase_id" + ], + "isUnique": false + }, + "purchase_transfers_source_idx": { + "name": "purchase_transfers_source_idx", + "columns": [ + "source_user_id" + ], + "isUnique": false + }, + "purchase_transfers_target_email_idx": { + "name": "purchase_transfers_target_email_idx", + "columns": [ + "target_email" + ], + "isUnique": false + }, + "purchase_transfers_state_idx": { + "name": "purchase_transfers_state_idx", + "columns": [ + "transfer_state" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "purchase_transfers_id": { + "name": "purchase_transfers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_invite_source_division_allocations": { + "name": "competition_invite_source_division_allocations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "championship_division_id": { + "name": "championship_division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spots": { + "name": "spots", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "competition_invite_source_division_allocations_source_div_unique": { + "name": "competition_invite_source_division_allocations_source_div_unique", + "columns": [ + "source_id", + "championship_division_id" + ], + "isUnique": true + }, + "competition_invite_source_division_allocations_div_idx": { + "name": "competition_invite_source_division_allocations_div_idx", + "columns": [ + "championship_division_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_invite_source_division_allocations_id": { + "name": "competition_invite_source_division_allocations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_invite_sources": { + "name": "competition_invite_sources", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "championship_competition_id": { + "name": "championship_competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_competition_id": { + "name": "source_competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_group_id": { + "name": "source_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "global_spots": { + "name": "global_spots", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "division_mappings": { + "name": "division_mappings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_invite_sources_championship_sort_idx": { + "name": "competition_invite_sources_championship_sort_idx", + "columns": [ + "championship_competition_id", + "sort_order" + ], + "isUnique": false + }, + "competition_invite_sources_source_competition_idx": { + "name": "competition_invite_sources_source_competition_idx", + "columns": [ + "source_competition_id" + ], + "isUnique": false + }, + "competition_invite_sources_source_group_idx": { + "name": "competition_invite_sources_source_group_idx", + "columns": [ + "source_group_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_invite_sources_id": { + "name": "competition_invite_sources_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_invites": { + "name": "competition_invites", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "championship_competition_id": { + "name": "championship_competition_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "round_id": { + "name": "round_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "origin": { + "name": "origin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_competition_id": { + "name": "source_competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_placement": { + "name": "source_placement", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_placement_label": { + "name": "source_placement_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bespoke_reason": { + "name": "bespoke_reason", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "championship_division_id": { + "name": "championship_division_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitee_first_name": { + "name": "invitee_first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitee_last_name": { + "name": "invitee_last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "send_attempt": { + "name": "send_attempt", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "declined_at": { + "name": "declined_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_registration_id": { + "name": "claimed_registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "email_last_error": { + "name": "email_last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_marker": { + "name": "active_marker", + "type": "varchar(8)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_invites_active_invite_idx": { + "name": "competition_invites_active_invite_idx", + "columns": [ + "championship_competition_id", + "email", + "championship_division_id", + "active_marker" + ], + "isUnique": true + }, + "competition_invites_claim_token_idx": { + "name": "competition_invites_claim_token_idx", + "columns": [ + "claim_token" + ], + "isUnique": true + }, + "competition_invites_round_idx": { + "name": "competition_invites_round_idx", + "columns": [ + "round_id" + ], + "isUnique": false + }, + "competition_invites_source_idx": { + "name": "competition_invites_source_idx", + "columns": [ + "source_id" + ], + "isUnique": false + }, + "competition_invites_origin_idx": { + "name": "competition_invites_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "competition_invites_status_idx": { + "name": "competition_invites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "competition_invites_email_idx": { + "name": "competition_invites_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "competition_invites_championship_idx": { + "name": "competition_invites_championship_idx", + "columns": [ + "championship_competition_id" + ], + "isUnique": false + }, + "competition_invites_user_idx": { + "name": "competition_invites_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_invites_id": { + "name": "competition_invites_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_events": { + "name": "competition_events", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submission_opens_at": { + "name": "submission_opens_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submission_closes_at": { + "name": "submission_closes_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_events_competition_idx": { + "name": "competition_events_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "competition_events_workout_idx": { + "name": "competition_events_workout_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "competition_events_comp_workout_idx": { + "name": "competition_events_comp_workout_idx", + "columns": [ + "competition_id", + "track_workout_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_events_id": { + "name": "competition_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_groups": { + "name": "competition_groups", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizing_team_id": { + "name": "organizing_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_groups_org_slug_idx": { + "name": "competition_groups_org_slug_idx", + "columns": [ + "organizing_team_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_groups_id": { + "name": "competition_groups_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_heat_assignments": { + "name": "competition_heat_assignments", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heat_id": { + "name": "heat_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lane_number": { + "name": "lane_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "competition_heat_assignments_heat_idx": { + "name": "competition_heat_assignments_heat_idx", + "columns": [ + "heat_id" + ], + "isUnique": false + }, + "competition_heat_assignments_reg_idx": { + "name": "competition_heat_assignments_reg_idx", + "columns": [ + "heat_id", + "registration_id" + ], + "isUnique": true + }, + "competition_heat_assignments_lane_idx": { + "name": "competition_heat_assignments_lane_idx", + "columns": [ + "heat_id", + "lane_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_heat_assignments_id": { + "name": "competition_heat_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_heats": { + "name": "competition_heats", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "venue_id": { + "name": "venue_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heat_number": { + "name": "heat_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_time": { + "name": "scheduled_time", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_published_at": { + "name": "schedule_published_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_heats_competition_idx": { + "name": "competition_heats_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "competition_heats_workout_idx": { + "name": "competition_heats_workout_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "competition_heats_time_idx": { + "name": "competition_heats_time_idx", + "columns": [ + "scheduled_time" + ], + "isUnique": false + }, + "competition_heats_workout_number_idx": { + "name": "competition_heats_workout_number_idx", + "columns": [ + "track_workout_id", + "heat_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_heats_id": { + "name": "competition_heats_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_registration_answers": { + "name": "competition_registration_answers", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comp_reg_answers_question_idx": { + "name": "comp_reg_answers_question_idx", + "columns": [ + "question_id" + ], + "isUnique": false + }, + "comp_reg_answers_registration_idx": { + "name": "comp_reg_answers_registration_idx", + "columns": [ + "registration_id" + ], + "isUnique": false + }, + "comp_reg_answers_user_idx": { + "name": "comp_reg_answers_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "comp_reg_answers_unique_idx": { + "name": "comp_reg_answers_unique_idx", + "columns": [ + "question_id", + "registration_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_registration_answers_id": { + "name": "competition_registration_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_registration_questions": { + "name": "competition_registration_questions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "help_text": { + "name": "help_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "for_teammates": { + "name": "for_teammates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "question_target": { + "name": "question_target", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'athlete'" + } + }, + "indexes": { + "comp_reg_questions_competition_idx": { + "name": "comp_reg_questions_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "comp_reg_questions_sort_idx": { + "name": "comp_reg_questions_sort_idx", + "columns": [ + "competition_id", + "sort_order" + ], + "isUnique": false + }, + "comp_reg_questions_group_idx": { + "name": "comp_reg_questions_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + }, + "comp_reg_questions_target_idx": { + "name": "comp_reg_questions_target_idx", + "columns": [ + "question_target" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_registration_questions_id": { + "name": "competition_registration_questions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_registrations": { + "name": "competition_registrations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_member_id": { + "name": "team_member_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "team_name": { + "name": "team_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "captain_user_id": { + "name": "captain_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "athlete_team_id": { + "name": "athlete_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_teammates": { + "name": "pending_teammates", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commerce_purchase_id": { + "name": "commerce_purchase_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_status": { + "name": "payment_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_in_by": { + "name": "checked_in_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_registrations_event_user_division_idx": { + "name": "competition_registrations_event_user_division_idx", + "columns": [ + "event_id", + "user_id", + "division_id" + ], + "isUnique": true + }, + "competition_registrations_user_idx": { + "name": "competition_registrations_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "competition_registrations_event_idx": { + "name": "competition_registrations_event_idx", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "competition_registrations_division_idx": { + "name": "competition_registrations_division_idx", + "columns": [ + "division_id" + ], + "isUnique": false + }, + "competition_registrations_captain_idx": { + "name": "competition_registrations_captain_idx", + "columns": [ + "captain_user_id" + ], + "isUnique": false + }, + "competition_registrations_athlete_team_idx": { + "name": "competition_registrations_athlete_team_idx", + "columns": [ + "athlete_team_id" + ], + "isUnique": false + }, + "competition_registrations_purchase_idx": { + "name": "competition_registrations_purchase_idx", + "columns": [ + "commerce_purchase_id" + ], + "isUnique": false + }, + "competition_registrations_status_idx": { + "name": "competition_registrations_status_idx", + "columns": [ + "event_id", + "status" + ], + "isUnique": false + }, + "competition_registrations_checked_in_idx": { + "name": "competition_registrations_checked_in_idx", + "columns": [ + "event_id", + "checked_in_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_registrations_id": { + "name": "competition_registrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_venues": { + "name": "competition_venues", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lane_count": { + "name": "lane_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "transition_minutes": { + "name": "transition_minutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "address_id": { + "name": "address_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_venues_competition_idx": { + "name": "competition_venues_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "competition_venues_sort_idx": { + "name": "competition_venues_sort_idx", + "columns": [ + "competition_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_venues_id": { + "name": "competition_venues_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competitions": { + "name": "competitions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizing_team_id": { + "name": "organizing_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_team_id": { + "name": "competition_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_opens_at": { + "name": "registration_opens_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_closes_at": { + "name": "registration_closes_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'America/Denver'" + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_registration_fee_cents": { + "name": "default_registration_fee_cents", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "platform_fee_percentage": { + "name": "platform_fee_percentage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_fee_fixed": { + "name": "platform_fee_fixed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pass_stripe_fees_to_customer": { + "name": "pass_stripe_fees_to_customer", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pass_platform_fees_to_customer": { + "name": "pass_platform_fees_to_customer", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "visibility": { + "name": "visibility", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "competition_type": { + "name": "competition_type", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in-person'" + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner_image_url": { + "name": "banner_image_url", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_heats_per_rotation": { + "name": "default_heats_per_rotation", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 4 + }, + "default_lane_shift_pattern": { + "name": "default_lane_shift_pattern", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'shift_right'" + }, + "default_max_spots_per_division": { + "name": "default_max_spots_per_division", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_total_registrations": { + "name": "max_total_registrations", + "type": "int unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_address_id": { + "name": "primary_address_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competitions_organizing_team_idx": { + "name": "competitions_organizing_team_idx", + "columns": [ + "organizing_team_id" + ], + "isUnique": false + }, + "competitions_competition_team_idx": { + "name": "competitions_competition_team_idx", + "columns": [ + "competition_team_id" + ], + "isUnique": false + }, + "competitions_group_idx": { + "name": "competitions_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + }, + "competitions_start_date_idx": { + "name": "competitions_start_date_idx", + "columns": [ + "start_date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competitions_id": { + "name": "competitions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "competitions_slug_unique": { + "name": "competitions_slug_unique", + "columns": [ + "slug" + ] + } + }, + "checkConstraint": {} + }, + "volunteer_registration_answers": { + "name": "volunteer_registration_answers", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitation_id": { + "name": "invitation_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "vol_reg_answers_question_idx": { + "name": "vol_reg_answers_question_idx", + "columns": [ + "question_id" + ], + "isUnique": false + }, + "vol_reg_answers_invitation_idx": { + "name": "vol_reg_answers_invitation_idx", + "columns": [ + "invitation_id" + ], + "isUnique": false + }, + "vol_reg_answers_unique_idx": { + "name": "vol_reg_answers_unique_idx", + "columns": [ + "question_id", + "invitation_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "volunteer_registration_answers_id": { + "name": "volunteer_registration_answers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_event_settings": { + "name": "crew_event_settings", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "crew_only": { + "name": "crew_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "source_platform": { + "name": "source_platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_event_url": { + "name": "source_event_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_registration_url": { + "name": "external_registration_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "concierge_status": { + "name": "concierge_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "crew_plan": { + "name": "crew_plan", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_serve'" + }, + "full_platform_credit_cents": { + "name": "full_platform_credit_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "acquisition_source": { + "name": "acquisition_source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "crew_event_settings_competition_unique_idx": { + "name": "crew_event_settings_competition_unique_idx", + "columns": [ + "competition_id" + ], + "isUnique": true + }, + "crew_event_settings_lifecycle_idx": { + "name": "crew_event_settings_lifecycle_idx", + "columns": [ + "lifecycle" + ], + "isUnique": false + }, + "crew_event_settings_plan_idx": { + "name": "crew_event_settings_plan_idx", + "columns": [ + "crew_plan" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_event_settings_id": { + "name": "crew_event_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_assignment_confirmations": { + "name": "crew_assignment_confirmations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignment_type": { + "name": "assignment_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignment_id": { + "name": "assignment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_id": { + "name": "membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitation_id": { + "name": "invitation_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_invite_id": { + "name": "competition_invite_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "sent_at": { + "name": "sent_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "responded_at": { + "name": "responded_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminder_at": { + "name": "last_reminder_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reminder_count": { + "name": "reminder_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "crew_assignment_confirmations_competition_idx": { + "name": "crew_assignment_confirmations_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "crew_assignment_confirmations_assignment_idx": { + "name": "crew_assignment_confirmations_assignment_idx", + "columns": [ + "assignment_type", + "assignment_id" + ], + "isUnique": false + }, + "crew_assignment_confirmations_status_idx": { + "name": "crew_assignment_confirmations_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "crew_assignment_confirmations_membership_idx": { + "name": "crew_assignment_confirmations_membership_idx", + "columns": [ + "membership_id" + ], + "isUnique": false + }, + "crew_assignment_confirmations_invitation_idx": { + "name": "crew_assignment_confirmations_invitation_idx", + "columns": [ + "invitation_id" + ], + "isUnique": false + }, + "crew_assignment_confirmations_competition_invite_idx": { + "name": "crew_assignment_confirmations_competition_invite_idx", + "columns": [ + "competition_invite_id" + ], + "isUnique": false + }, + "crew_assignment_confirmations_email_idx": { + "name": "crew_assignment_confirmations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "crew_assignment_confirmations_expires_idx": { + "name": "crew_assignment_confirmations_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "crew_assignment_confirmations_token_hash_unique_idx": { + "name": "crew_assignment_confirmations_token_hash_unique_idx", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_assignment_confirmations_id": { + "name": "crew_assignment_confirmations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_import_rows": { + "name": "crew_import_rows", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_id": { + "name": "import_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_number": { + "name": "row_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_row": { + "name": "raw_row", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalized_row": { + "name": "normalized_row", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "warnings": { + "name": "warnings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errors": { + "name": "errors", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "crew_import_rows_import_idx": { + "name": "crew_import_rows_import_idx", + "columns": [ + "import_id" + ], + "isUnique": false + }, + "crew_import_rows_import_row_unique_idx": { + "name": "crew_import_rows_import_row_unique_idx", + "columns": [ + "import_id", + "row_number" + ], + "isUnique": true + }, + "crew_import_rows_target_idx": { + "name": "crew_import_rows_target_idx", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": false + }, + "crew_import_rows_action_idx": { + "name": "crew_import_rows_action_idx", + "columns": [ + "action" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_import_rows_id": { + "name": "crew_import_rows_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_imports": { + "name": "crew_imports", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "source_platform": { + "name": "source_platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_filename": { + "name": "original_filename", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploaded'" + }, + "parser_version": { + "name": "parser_version", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "column_mapping": { + "name": "column_mapping", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "warning_count": { + "name": "warning_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "row_count": { + "name": "row_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_count": { + "name": "created_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_count": { + "name": "updated_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_at": { + "name": "applied_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applied_by": { + "name": "applied_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "crew_imports_competition_idx": { + "name": "crew_imports_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "crew_imports_status_idx": { + "name": "crew_imports_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "crew_imports_kind_idx": { + "name": "crew_imports_kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "crew_imports_uploaded_by_idx": { + "name": "crew_imports_uploaded_by_idx", + "columns": [ + "uploaded_by" + ], + "isUnique": false + }, + "crew_imports_applied_by_idx": { + "name": "crew_imports_applied_by_idx", + "columns": [ + "applied_by" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_imports_id": { + "name": "crew_imports_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_department_leads": { + "name": "crew_department_leads", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_id": { + "name": "membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitation_id": { + "name": "invitation_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_type": { + "name": "role_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "venue_id": { + "name": "venue_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invited'" + }, + "assigned_by": { + "name": "assigned_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "crew_department_leads_competition_idx": { + "name": "crew_department_leads_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "crew_department_leads_team_idx": { + "name": "crew_department_leads_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "crew_department_leads_membership_idx": { + "name": "crew_department_leads_membership_idx", + "columns": [ + "membership_id" + ], + "isUnique": false + }, + "crew_department_leads_invitation_idx": { + "name": "crew_department_leads_invitation_idx", + "columns": [ + "invitation_id" + ], + "isUnique": false + }, + "crew_department_leads_email_idx": { + "name": "crew_department_leads_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "crew_department_leads_role_idx": { + "name": "crew_department_leads_role_idx", + "columns": [ + "role_type" + ], + "isUnique": false + }, + "crew_department_leads_venue_idx": { + "name": "crew_department_leads_venue_idx", + "columns": [ + "venue_id" + ], + "isUnique": false + }, + "crew_department_leads_status_idx": { + "name": "crew_department_leads_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "crew_department_leads_time_idx": { + "name": "crew_department_leads_time_idx", + "columns": [ + "starts_at", + "ends_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_department_leads_id": { + "name": "crew_department_leads_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_import_mapping_presets": { + "name": "crew_import_mapping_presets", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_platform": { + "name": "source_platform", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'csv'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header_fingerprint": { + "name": "header_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "column_mapping": { + "name": "column_mapping", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parser_version": { + "name": "parser_version", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "crew_import_mapping_presets_lookup_unique_idx": { + "name": "crew_import_mapping_presets_lookup_unique_idx", + "columns": [ + "team_id", + "source_platform", + "kind", + "header_fingerprint" + ], + "isUnique": true + }, + "crew_import_mapping_presets_team_kind_idx": { + "name": "crew_import_mapping_presets_team_kind_idx", + "columns": [ + "team_id", + "kind" + ], + "isUnique": false + }, + "crew_import_mapping_presets_competition_idx": { + "name": "crew_import_mapping_presets_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "crew_import_mapping_presets_last_used_idx": { + "name": "crew_import_mapping_presets_last_used_idx", + "columns": [ + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_import_mapping_presets_id": { + "name": "crew_import_mapping_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "crew_template_presets": { + "name": "crew_template_presets", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preset_data": { + "name": "preset_data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "crew_template_presets_team_kind_idx": { + "name": "crew_template_presets_team_kind_idx", + "columns": [ + "team_id", + "kind" + ], + "isUnique": false + }, + "crew_template_presets_competition_idx": { + "name": "crew_template_presets_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "crew_template_presets_created_by_idx": { + "name": "crew_template_presets_created_by_idx", + "columns": [ + "created_by" + ], + "isUnique": false + }, + "crew_template_presets_archived_idx": { + "name": "crew_template_presets_archived_idx", + "columns": [ + "is_archived" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "crew_template_presets_id": { + "name": "crew_template_presets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "entitlements": { + "name": "entitlements", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entitlement_type_id": { + "name": "entitlement_type_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entitlement_user_id_idx": { + "name": "entitlement_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "entitlement_team_id_idx": { + "name": "entitlement_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "entitlement_type_idx": { + "name": "entitlement_type_idx", + "columns": [ + "entitlement_type_id" + ], + "isUnique": false + }, + "entitlement_source_idx": { + "name": "entitlement_source_idx", + "columns": [ + "source_type", + "source_id" + ], + "isUnique": false + }, + "entitlement_deleted_at_idx": { + "name": "entitlement_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "entitlements_id": { + "name": "entitlements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "entitlement_types": { + "name": "entitlement_types", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "entitlement_types_id": { + "name": "entitlement_types_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "entitlement_types_name_unique": { + "name": "entitlement_types_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "features": { + "name": "features", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "features_id": { + "name": "features_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "features_key_unique": { + "name": "features_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "limits": { + "name": "limits", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset_period": { + "name": "reset_period", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'never'" + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "limits_id": { + "name": "limits_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "limits_key_unique": { + "name": "limits_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "plan_features": { + "name": "plan_features", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feature_id": { + "name": "feature_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plan_feature_plan_id_idx": { + "name": "plan_feature_plan_id_idx", + "columns": [ + "plan_id" + ], + "isUnique": false + }, + "plan_feature_feature_id_idx": { + "name": "plan_feature_feature_id_idx", + "columns": [ + "feature_id" + ], + "isUnique": false + }, + "plan_feature_unique_idx": { + "name": "plan_feature_unique_idx", + "columns": [ + "plan_id", + "feature_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plan_features_id": { + "name": "plan_features_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plan_limits": { + "name": "plan_limits", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "limit_id": { + "name": "limit_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plan_limit_plan_id_idx": { + "name": "plan_limit_plan_id_idx", + "columns": [ + "plan_id" + ], + "isUnique": false + }, + "plan_limit_limit_id_idx": { + "name": "plan_limit_limit_id_idx", + "columns": [ + "limit_id" + ], + "isUnique": false + }, + "plan_limit_unique_idx": { + "name": "plan_limit_unique_idx", + "columns": [ + "plan_id", + "limit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plan_limits_id": { + "name": "plan_limits_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plans": { + "name": "plans", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "is_public": { + "name": "is_public", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "entitlements": { + "name": "entitlements", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plans_id": { + "name": "plans_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_addons": { + "name": "team_addons", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addon_id": { + "name": "addon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_item_id": { + "name": "stripe_subscription_item_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_addon_team_id_idx": { + "name": "team_addon_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_addon_status_idx": { + "name": "team_addon_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_addons_id": { + "name": "team_addons_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_entitlement_overrides": { + "name": "team_entitlement_overrides", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_entitlement_override_team_id_idx": { + "name": "team_entitlement_override_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_entitlement_override_type_idx": { + "name": "team_entitlement_override_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "team_entitlement_override_team_type_key_unique": { + "name": "team_entitlement_override_team_type_key_unique", + "columns": [ + "team_id", + "type", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_entitlement_overrides_id": { + "name": "team_entitlement_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_feature_entitlements": { + "name": "team_feature_entitlements", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feature_id": { + "name": "feature_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'plan'" + }, + "source_plan_id": { + "name": "source_plan_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "team_feature_entitlement_team_id_idx": { + "name": "team_feature_entitlement_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_feature_entitlement_feature_id_idx": { + "name": "team_feature_entitlement_feature_id_idx", + "columns": [ + "feature_id" + ], + "isUnique": false + }, + "team_feature_entitlement_team_feature_unique": { + "name": "team_feature_entitlement_team_feature_unique", + "columns": [ + "team_id", + "feature_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_feature_entitlements_id": { + "name": "team_feature_entitlements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_limit_entitlements": { + "name": "team_limit_entitlements", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "limit_id": { + "name": "limit_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'plan'" + }, + "source_plan_id": { + "name": "source_plan_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "team_limit_entitlement_team_id_idx": { + "name": "team_limit_entitlement_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_limit_entitlement_limit_id_idx": { + "name": "team_limit_entitlement_limit_id_idx", + "columns": [ + "limit_id" + ], + "isUnique": false + }, + "team_limit_entitlement_team_limit_unique": { + "name": "team_limit_entitlement_team_limit_unique", + "columns": [ + "team_id", + "limit_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_limit_entitlements_id": { + "name": "team_limit_entitlements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_subscriptions": { + "name": "team_subscriptions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "trial_start": { + "name": "trial_start", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trial_end": { + "name": "trial_end", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_subscription_team_id_idx": { + "name": "team_subscription_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_subscription_status_idx": { + "name": "team_subscription_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_subscriptions_id": { + "name": "team_subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_usages": { + "name": "team_usages", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "limit_key": { + "name": "limit_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_value": { + "name": "current_value", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "period_start": { + "name": "period_start", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_usage_team_id_idx": { + "name": "team_usage_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_usage_limit_key_idx": { + "name": "team_usage_limit_key_idx", + "columns": [ + "limit_key" + ], + "isUnique": false + }, + "team_usage_unique_idx": { + "name": "team_usage_unique_idx", + "columns": [ + "team_id", + "limit_key", + "period_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_usages_id": { + "name": "team_usages_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "event_division_mappings": { + "name": "event_division_mappings", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "edm_comp_event_div_idx": { + "name": "edm_comp_event_div_idx", + "columns": [ + "competition_id", + "track_workout_id", + "division_id" + ], + "isUnique": true + }, + "edm_competition_idx": { + "name": "edm_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "edm_event_idx": { + "name": "edm_event_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "edm_division_idx": { + "name": "edm_division_idx", + "columns": [ + "division_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_division_mappings_id": { + "name": "event_division_mappings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "event_resources": { + "name": "event_resources", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "event_resources_event_idx": { + "name": "event_resources_event_idx", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_resources_event_order_idx": { + "name": "event_resources_event_order_idx", + "columns": [ + "event_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_resources_id": { + "name": "event_resources_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "financial_events": { + "name": "financial_events", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchase_id": { + "name": "purchase_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_refund_id": { + "name": "stripe_refund_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_dispute_id": { + "name": "stripe_dispute_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_event_timestamp": { + "name": "stripe_event_timestamp", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "financial_event_purchase_idx": { + "name": "financial_event_purchase_idx", + "columns": [ + "purchase_id" + ], + "isUnique": false + }, + "financial_event_team_idx": { + "name": "financial_event_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "financial_event_type_idx": { + "name": "financial_event_type_idx", + "columns": [ + "event_type" + ], + "isUnique": false + }, + "financial_event_stripe_pi_idx": { + "name": "financial_event_stripe_pi_idx", + "columns": [ + "stripe_payment_intent_id" + ], + "isUnique": false + }, + "financial_event_stripe_refund_idx": { + "name": "financial_event_stripe_refund_idx", + "columns": [ + "stripe_refund_id" + ], + "isUnique": false + }, + "financial_event_stripe_dispute_idx": { + "name": "financial_event_stripe_dispute_idx", + "columns": [ + "stripe_dispute_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "financial_events_id": { + "name": "financial_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "event_judging_sheets": { + "name": "event_judging_sheets", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "varchar(600)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(600)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_filename": { + "name": "original_filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "event_judging_sheets_competition_idx": { + "name": "event_judging_sheets_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "event_judging_sheets_event_idx": { + "name": "event_judging_sheets_event_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "event_judging_sheets_uploaded_by_idx": { + "name": "event_judging_sheets_uploaded_by_idx", + "columns": [ + "uploaded_by" + ], + "isUnique": false + }, + "event_judging_sheets_sort_idx": { + "name": "event_judging_sheets_sort_idx", + "columns": [ + "track_workout_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_judging_sheets_id": { + "name": "event_judging_sheets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "submission_window_notifications": { + "name": "submission_window_notifications", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_event_id": { + "name": "competition_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sent_to_email": { + "name": "sent_to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "submission_window_notif_competition_idx": { + "name": "submission_window_notif_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "submission_window_notif_event_idx": { + "name": "submission_window_notif_event_idx", + "columns": [ + "competition_event_id" + ], + "isUnique": false + }, + "submission_window_notif_user_idx": { + "name": "submission_window_notif_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "submission_window_notif_unique_idx": { + "name": "submission_window_notif_unique_idx", + "columns": [ + "competition_event_id", + "registration_id", + "type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "submission_window_notifications_id": { + "name": "submission_window_notifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizer_requests": { + "name": "organizer_requests", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "admin_notes": { + "name": "admin_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizer_request_team_idx": { + "name": "organizer_request_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "organizer_request_user_idx": { + "name": "organizer_request_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "organizer_request_status_idx": { + "name": "organizer_request_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizer_requests_id": { + "name": "organizer_requests_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "programming_tracks": { + "name": "programming_tracks", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_team_id": { + "name": "owner_team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scaling_group_id": { + "name": "scaling_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "programming_track_type_idx": { + "name": "programming_track_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "programming_track_owner_idx": { + "name": "programming_track_owner_idx", + "columns": [ + "owner_team_id" + ], + "isUnique": false + }, + "programming_track_scaling_idx": { + "name": "programming_track_scaling_idx", + "columns": [ + "scaling_group_id" + ], + "isUnique": false + }, + "programming_track_competition_idx": { + "name": "programming_track_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "programming_tracks_id": { + "name": "programming_tracks_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scheduled_workout_instances": { + "name": "scheduled_workout_instances", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_specific_notes": { + "name": "team_specific_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scaling_guidance_for_day": { + "name": "scaling_guidance_for_day", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "class_times": { + "name": "class_times", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "scheduled_workout_instance_team_idx": { + "name": "scheduled_workout_instance_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "scheduled_workout_instance_date_idx": { + "name": "scheduled_workout_instance_date_idx", + "columns": [ + "scheduled_date" + ], + "isUnique": false + }, + "scheduled_workout_instance_workout_idx": { + "name": "scheduled_workout_instance_workout_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scheduled_workout_instances_id": { + "name": "scheduled_workout_instances_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_programming_tracks": { + "name": "team_programming_tracks", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_id": { + "name": "track_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_day_offset": { + "name": "start_day_offset", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "team_programming_track_active_idx": { + "name": "team_programming_track_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + }, + "team_programming_track_team_idx": { + "name": "team_programming_track_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_programming_tracks_team_id_track_id_pk": { + "name": "team_programming_tracks_team_id_track_id_pk", + "columns": [ + "team_id", + "track_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "track_workouts": { + "name": "track_workouts", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_id": { + "name": "track_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_event_id": { + "name": "parent_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "track_order": { + "name": "track_order", + "type": "decimal(6,2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "points_multiplier": { + "name": "points_multiplier", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 100 + }, + "heat_status": { + "name": "heat_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "event_status": { + "name": "event_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "sponsor_id": { + "name": "sponsor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_heats_count": { + "name": "default_heats_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_lane_shift_pattern": { + "name": "default_lane_shift_pattern", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_heat_buffer": { + "name": "min_heat_buffer", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2 + }, + "benchmark_test_id": { + "name": "benchmark_test_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "benchmark_category": { + "name": "benchmark_category", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "track_workout_track_idx": { + "name": "track_workout_track_idx", + "columns": [ + "track_id" + ], + "isUnique": false + }, + "track_workout_order_idx": { + "name": "track_workout_order_idx", + "columns": [ + "track_order" + ], + "isUnique": false + }, + "track_workout_parent_idx": { + "name": "track_workout_parent_idx", + "columns": [ + "parent_event_id" + ], + "isUnique": false + }, + "track_workout_workoutid_idx": { + "name": "track_workout_workoutid_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + }, + "track_workout_unique_idx": { + "name": "track_workout_unique_idx", + "columns": [ + "track_id", + "workout_id", + "track_order" + ], + "isUnique": false + }, + "track_workout_sponsor_idx": { + "name": "track_workout_sponsor_idx", + "columns": [ + "sponsor_id" + ], + "isUnique": false + }, + "track_workout_benchmark_test_idx": { + "name": "track_workout_benchmark_test_idx", + "columns": [ + "benchmark_test_id" + ], + "isUnique": false + }, + "track_workout_benchmark_category_idx": { + "name": "track_workout_benchmark_category_idx", + "columns": [ + "benchmark_category" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "track_workouts_id": { + "name": "track_workouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "route_doc_routes": { + "name": "route_doc_routes", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "doc_id": { + "name": "doc_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_id": { + "name": "route_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "route_doc_routes_route_idx": { + "name": "route_doc_routes_route_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_doc_routes_doc_route_unique": { + "name": "route_doc_routes_doc_route_unique", + "columns": [ + "doc_id", + "route_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "route_doc_routes_id": { + "name": "route_doc_routes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "route_doc_versions": { + "name": "route_doc_versions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "doc_id": { + "name": "doc_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('markdown','video','link')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_url": { + "name": "video_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_url": { + "name": "link_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_doc_versions_doc_idx": { + "name": "route_doc_versions_doc_idx", + "columns": [ + "doc_id" + ], + "isUnique": false + }, + "route_doc_versions_doc_version_unique": { + "name": "route_doc_versions_doc_version_unique", + "columns": [ + "doc_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "route_doc_versions_id": { + "name": "route_doc_versions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "route_docs": { + "name": "route_docs", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('markdown','video','link')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'markdown'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_url": { + "name": "video_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_url": { + "name": "link_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "route_docs_published_idx": { + "name": "route_docs_published_idx", + "columns": [ + "is_published" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "route_docs_id": { + "name": "route_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scaling_groups": { + "name": "scaling_groups", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "scaling_groups_team_idx": { + "name": "scaling_groups_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "scaling_groups_default_idx": { + "name": "scaling_groups_default_idx", + "columns": [ + "is_default" + ], + "isUnique": false + }, + "scaling_groups_system_idx": { + "name": "scaling_groups_system_idx", + "columns": [ + "is_system" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scaling_groups_id": { + "name": "scaling_groups_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scaling_levels": { + "name": "scaling_levels", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scaling_group_id": { + "name": "scaling_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_size": { + "name": "team_size", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "scaling_levels_group_idx": { + "name": "scaling_levels_group_idx", + "columns": [ + "scaling_group_id" + ], + "isUnique": false + }, + "scaling_levels_position_idx": { + "name": "scaling_levels_position_idx", + "columns": [ + "scaling_group_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scaling_levels_id": { + "name": "scaling_levels_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workout_scaling_descriptions": { + "name": "workout_scaling_descriptions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scaling_level_id": { + "name": "scaling_level_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workout_scaling_desc_workout_idx": { + "name": "workout_scaling_desc_workout_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + }, + "workout_scaling_desc_unique_idx": { + "name": "workout_scaling_desc_unique_idx", + "columns": [ + "workout_id", + "scaling_level_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workout_scaling_descriptions_id": { + "name": "workout_scaling_descriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "series_division_mappings": { + "name": "series_division_mappings", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_division_id": { + "name": "competition_division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "series_division_id": { + "name": "series_division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sdm_group_comp_div_idx": { + "name": "sdm_group_comp_div_idx", + "columns": [ + "group_id", + "competition_id", + "competition_division_id" + ], + "isUnique": true + }, + "sdm_group_idx": { + "name": "sdm_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + }, + "sdm_competition_idx": { + "name": "sdm_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_division_mappings_id": { + "name": "series_division_mappings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "series_event_mappings": { + "name": "series_event_mappings", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_event_id": { + "name": "competition_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_event_id": { + "name": "template_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sem_group_comp_event_idx": { + "name": "sem_group_comp_event_idx", + "columns": [ + "group_id", + "competition_id", + "competition_event_id" + ], + "isUnique": true + }, + "sem_group_template_idx": { + "name": "sem_group_template_idx", + "columns": [ + "group_id", + "template_event_id" + ], + "isUnique": false + }, + "sem_competition_idx": { + "name": "sem_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_event_mappings_id": { + "name": "series_event_mappings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "series_template_divisions": { + "name": "series_template_divisions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "division_id": { + "name": "division_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fee_cents": { + "name": "fee_cents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_spots": { + "name": "max_spots", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "std_group_division_idx": { + "name": "std_group_division_idx", + "columns": [ + "group_id", + "division_id" + ], + "isUnique": true + }, + "std_group_idx": { + "name": "std_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_template_divisions_id": { + "name": "series_template_divisions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_catalogs": { + "name": "class_catalogs", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "max_participants": { + "name": "max_participants", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "class_catalogs_id": { + "name": "class_catalogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "class_catalog_to_skills": { + "name": "class_catalog_to_skills", + "columns": { + "class_catalog_id": { + "name": "class_catalog_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "class_catalog_to_skills_class_catalog_id_skill_id_pk": { + "name": "class_catalog_to_skills_class_catalog_id_skill_id_pk", + "columns": [ + "class_catalog_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "coach_blackout_dates": { + "name": "coach_blackout_dates", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coach_id": { + "name": "coach_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coach_blackout_dates_id": { + "name": "coach_blackout_dates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "coach_recurring_unavailability": { + "name": "coach_recurring_unavailability", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coach_id": { + "name": "coach_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coach_recurring_unavailability_id": { + "name": "coach_recurring_unavailability_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "coach_to_skills": { + "name": "coach_to_skills", + "columns": { + "coach_id": { + "name": "coach_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coach_to_skills_coach_id_skill_id_pk": { + "name": "coach_to_skills_coach_id_skill_id_pk", + "columns": [ + "coach_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "coaches": { + "name": "coaches", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weekly_class_limit": { + "name": "weekly_class_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduling_preference": { + "name": "scheduling_preference", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduling_notes": { + "name": "scheduling_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "coach_user_team_unique_idx": { + "name": "coach_user_team_unique_idx", + "columns": [ + "user_id", + "team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coaches_id": { + "name": "coaches_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "generated_schedules": { + "name": "generated_schedules", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "week_start_date": { + "name": "week_start_date", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "generated_schedules_id": { + "name": "generated_schedules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "locations": { + "name": "locations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 20 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "locations_id": { + "name": "locations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "schedule_template_class_required_skills": { + "name": "schedule_template_class_required_skills", + "columns": { + "template_class_id": { + "name": "template_class_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sched_class_skills_pk": { + "name": "sched_class_skills_pk", + "columns": [ + "template_class_id", + "skill_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "schedule_template_classes": { + "name": "schedule_template_classes", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_coaches": { + "name": "required_coaches", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schedule_template_classes_id": { + "name": "schedule_template_classes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "schedule_templates": { + "name": "schedule_templates", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_catalog_id": { + "name": "class_catalog_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "schedule_templates_id": { + "name": "schedule_templates_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scheduled_classes": { + "name": "scheduled_classes", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "coach_id": { + "name": "coach_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "class_catalog_id": { + "name": "class_catalog_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scheduled_classes_id": { + "name": "scheduled_classes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skills": { + "name": "skills", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skills_id": { + "name": "skills_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "score_rounds": { + "name": "score_rounds", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score_id": { + "name": "score_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "round_number": { + "name": "round_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheme_override": { + "name": "scheme_override", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secondary_value": { + "name": "secondary_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_score_rounds_score": { + "name": "idx_score_rounds_score", + "columns": [ + "score_id", + "round_number" + ], + "isUnique": false + }, + "idx_score_rounds_unique": { + "name": "idx_score_rounds_unique", + "columns": [ + "score_id", + "round_number" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "score_rounds_id": { + "name": "score_rounds_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "score_verification_logs": { + "name": "score_verification_logs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score_id": { + "name": "score_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "athlete_user_id": { + "name": "athlete_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_score_value": { + "name": "original_score_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_status": { + "name": "original_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_secondary_value": { + "name": "original_secondary_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_tiebreak_value": { + "name": "original_tiebreak_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_score_value": { + "name": "new_score_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_status": { + "name": "new_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_secondary_value": { + "name": "new_secondary_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_tiebreak_value": { + "name": "new_tiebreak_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "penalty_type": { + "name": "penalty_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "penalty_percentage": { + "name": "penalty_percentage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "no_rep_count": { + "name": "no_rep_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "performed_by_user_id": { + "name": "performed_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performed_at": { + "name": "performed_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_svlog_score": { + "name": "idx_svlog_score", + "columns": [ + "score_id" + ], + "isUnique": false + }, + "idx_svlog_performer": { + "name": "idx_svlog_performer", + "columns": [ + "performed_by_user_id", + "performed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "score_verification_logs_id": { + "name": "score_verification_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "scores": { + "name": "scores", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_event_id": { + "name": "competition_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_workout_instance_id": { + "name": "scheduled_workout_instance_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheme": { + "name": "scheme", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score_type": { + "name": "score_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'max'" + }, + "score_value": { + "name": "score_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tiebreak_scheme": { + "name": "tiebreak_scheme", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tiebreak_value": { + "name": "tiebreak_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_cap_ms": { + "name": "time_cap_ms", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secondary_value": { + "name": "secondary_value", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scored'" + }, + "status_order": { + "name": "status_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_key": { + "name": "sort_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scaling_level_id": { + "name": "scaling_level_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "as_rx": { + "name": "as_rx", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "benchmark_variant": { + "name": "benchmark_variant", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verification_status": { + "name": "verification_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "penalty_type": { + "name": "penalty_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "penalty_percentage": { + "name": "penalty_percentage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "no_rep_count": { + "name": "no_rep_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_scores_user": { + "name": "idx_scores_user", + "columns": [ + "user_id", + "recorded_at" + ], + "isUnique": false + }, + "idx_scores_workout": { + "name": "idx_scores_workout", + "columns": [ + "workout_id", + "team_id", + "status_order", + "sort_key" + ], + "isUnique": false + }, + "idx_scores_competition": { + "name": "idx_scores_competition", + "columns": [ + "competition_event_id", + "status_order", + "sort_key" + ], + "isUnique": false + }, + "idx_scores_scheduled": { + "name": "idx_scores_scheduled", + "columns": [ + "scheduled_workout_instance_id" + ], + "isUnique": false + }, + "idx_scores_competition_user_unique": { + "name": "idx_scores_competition_user_unique", + "columns": [ + "competition_event_id", + "user_id", + "scaling_level_id" + ], + "isUnique": true + }, + "idx_scores_benchmark_variant": { + "name": "idx_scores_benchmark_variant", + "columns": [ + "competition_event_id", + "benchmark_variant" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "scores_id": { + "name": "scores_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sponsor_groups": { + "name": "sponsor_groups", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "sponsor_groups_competition_idx": { + "name": "sponsor_groups_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "sponsor_groups_order_idx": { + "name": "sponsor_groups_order_idx", + "columns": [ + "competition_id", + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sponsor_groups_id": { + "name": "sponsor_groups_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sponsors": { + "name": "sponsors", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_order": { + "name": "display_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "sponsors_competition_idx": { + "name": "sponsors_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "sponsors_user_idx": { + "name": "sponsors_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sponsors_group_idx": { + "name": "sponsors_group_idx", + "columns": [ + "group_id" + ], + "isUnique": false + }, + "sponsors_competition_order_idx": { + "name": "sponsors_competition_order_idx", + "columns": [ + "competition_id", + "group_id", + "display_order" + ], + "isUnique": false + }, + "sponsors_user_order_idx": { + "name": "sponsors_user_order_idx", + "columns": [ + "user_id", + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sponsors_id": { + "name": "sponsors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_invitations": { + "name": "team_invitations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_invitation_team_id_idx": { + "name": "team_invitation_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_invitation_email_idx": { + "name": "team_invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "team_invitation_token_idx": { + "name": "team_invitation_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_invitations_id": { + "name": "team_invitations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "team_invitations_token_unique": { + "name": "team_invitations_token_unique", + "columns": [ + "token" + ] + } + }, + "checkConstraint": {} + }, + "team_memberships": { + "name": "team_memberships", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_system_role": { + "name": "is_system_role", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "invited_by": { + "name": "invited_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invited_at": { + "name": "invited_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_membership_team_id_idx": { + "name": "team_membership_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_membership_user_id_idx": { + "name": "team_membership_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "team_membership_unique_idx": { + "name": "team_membership_unique_idx", + "columns": [ + "team_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_memberships_id": { + "name": "team_memberships_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_roles": { + "name": "team_roles", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_editable": { + "name": "is_editable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "team_role_team_id_idx": { + "name": "team_role_team_id_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_role_name_unique_idx": { + "name": "team_role_name_unique_idx", + "columns": [ + "team_id", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_roles_id": { + "name": "team_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "teams": { + "name": "teams", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_email": { + "name": "billing_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_plan_id": { + "name": "current_plan_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_track_id": { + "name": "default_track_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_scaling_group_id": { + "name": "default_scaling_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_personal_team": { + "name": "is_personal_team", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "personal_team_owner_id": { + "name": "personal_team_owner_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'gym'" + }, + "parent_organization_id": { + "name": "parent_organization_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_metadata": { + "name": "competition_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_account_status": { + "name": "stripe_account_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_account_type": { + "name": "stripe_account_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_onboarding_completed_at": { + "name": "stripe_onboarding_completed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizer_fee_percentage": { + "name": "organizer_fee_percentage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizer_fee_fixed": { + "name": "organizer_fee_fixed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "team_slug_idx": { + "name": "team_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "team_personal_owner_idx": { + "name": "team_personal_owner_idx", + "columns": [ + "personal_team_owner_id" + ], + "isUnique": false + }, + "team_default_scaling_idx": { + "name": "team_default_scaling_idx", + "columns": [ + "default_scaling_group_id" + ], + "isUnique": false + }, + "team_type_idx": { + "name": "team_type_idx", + "columns": [ + "type" + ], + "isUnique": false + }, + "team_parent_org_idx": { + "name": "team_parent_org_idx", + "columns": [ + "parent_organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "teams_id": { + "name": "teams_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "columns": [ + "slug" + ] + } + }, + "checkConstraint": {} + }, + "passkey_credentials": { + "name": "passkey_credentials", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_public_key": { + "name": "credential_public_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "credential_id_idx": { + "name": "credential_id_idx", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "passkey_credentials_id": { + "name": "passkey_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "passkey_credentials_credentialId_unique": { + "name": "passkey_credentials_credentialId_unique", + "columns": [ + "credential_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "email_verified": { + "name": "email_verified", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sign_up_ip_address": { + "name": "sign_up_ip_address", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_account_id": { + "name": "google_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "varchar(600)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_credits": { + "name": "current_credits", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_credit_refresh_at": { + "name": "last_credit_refresh_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliate_name": { + "name": "affiliate_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "athlete_profile": { + "name": "athlete_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "google_account_id_idx": { + "name": "google_account_id_idx", + "columns": [ + "google_account_id" + ], + "isUnique": false + }, + "role_idx": { + "name": "role_idx", + "columns": [ + "role" + ], + "isUnique": false + }, + "user_gender_idx": { + "name": "user_gender_idx", + "columns": [ + "gender" + ], + "isUnique": false + }, + "user_dob_idx": { + "name": "user_dob_idx", + "columns": [ + "date_of_birth" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "review_notes": { + "name": "review_notes", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_submission_id": { + "name": "video_submission_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "enum('general','no-rep')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp_seconds": { + "name": "timestamp_seconds", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movement_id": { + "name": "movement_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "review_notes_submission_idx": { + "name": "review_notes_submission_idx", + "columns": [ + "video_submission_id" + ], + "isUnique": false + }, + "review_notes_user_idx": { + "name": "review_notes_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "review_notes_team_idx": { + "name": "review_notes_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "review_notes_id": { + "name": "review_notes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_submissions": { + "name": "video_submissions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_index": { + "name": "video_index", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_url": { + "name": "video_url", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_status": { + "name": "review_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewer_notes": { + "name": "reviewer_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_submissions_reg_event_idx": { + "name": "video_submissions_reg_event_idx", + "columns": [ + "registration_id", + "track_workout_id", + "video_index" + ], + "isUnique": true + }, + "video_submissions_user_idx": { + "name": "video_submissions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "video_submissions_event_idx": { + "name": "video_submissions_event_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "video_submissions_registration_idx": { + "name": "video_submissions_registration_idx", + "columns": [ + "registration_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_submissions_id": { + "name": "video_submissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_votes": { + "name": "video_votes", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_submission_id": { + "name": "video_submission_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vote_type": { + "name": "vote_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason_detail": { + "name": "reason_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voted_at": { + "name": "voted_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "video_votes_user_submission_idx": { + "name": "video_votes_user_submission_idx", + "columns": [ + "user_id", + "video_submission_id" + ], + "isUnique": true + }, + "video_votes_submission_idx": { + "name": "video_votes_submission_idx", + "columns": [ + "video_submission_id" + ], + "isUnique": false + }, + "video_votes_user_idx": { + "name": "video_votes_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_votes_id": { + "name": "video_votes_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "competition_judge_rotations": { + "name": "competition_judge_rotations", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_id": { + "name": "membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starting_heat": { + "name": "starting_heat", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starting_lane": { + "name": "starting_lane", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heats_count": { + "name": "heats_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lane_shift_pattern": { + "name": "lane_shift_pattern", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stay'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "competition_judge_rotations_competition_idx": { + "name": "competition_judge_rotations_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "competition_judge_rotations_workout_idx": { + "name": "competition_judge_rotations_workout_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "competition_judge_rotations_membership_idx": { + "name": "competition_judge_rotations_membership_idx", + "columns": [ + "membership_id" + ], + "isUnique": false + }, + "competition_judge_rotations_event_heat_idx": { + "name": "competition_judge_rotations_event_heat_idx", + "columns": [ + "track_workout_id", + "starting_heat" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "competition_judge_rotations_id": { + "name": "competition_judge_rotations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "judge_assignment_versions": { + "name": "judge_assignment_versions", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "track_workout_id": { + "name": "track_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "judge_assignment_versions_workout_idx": { + "name": "judge_assignment_versions_workout_idx", + "columns": [ + "track_workout_id" + ], + "isUnique": false + }, + "judge_assignment_versions_active_idx": { + "name": "judge_assignment_versions_active_idx", + "columns": [ + "track_workout_id", + "is_active" + ], + "isUnique": false + }, + "judge_assignment_versions_unique_idx": { + "name": "judge_assignment_versions_unique_idx", + "columns": [ + "track_workout_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "judge_assignment_versions_id": { + "name": "judge_assignment_versions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "judge_heat_assignments": { + "name": "judge_heat_assignments", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heat_id": { + "name": "heat_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_id": { + "name": "membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotation_id": { + "name": "rotation_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_id": { + "name": "version_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lane_number": { + "name": "lane_number", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual_override": { + "name": "is_manual_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "judge_heat_assignments_heat_idx": { + "name": "judge_heat_assignments_heat_idx", + "columns": [ + "heat_id" + ], + "isUnique": false + }, + "judge_heat_assignments_membership_idx": { + "name": "judge_heat_assignments_membership_idx", + "columns": [ + "membership_id" + ], + "isUnique": false + }, + "judge_heat_assignments_rotation_idx": { + "name": "judge_heat_assignments_rotation_idx", + "columns": [ + "rotation_id" + ], + "isUnique": false + }, + "judge_heat_assignments_version_idx": { + "name": "judge_heat_assignments_version_idx", + "columns": [ + "version_id" + ], + "isUnique": false + }, + "judge_heat_assignments_unique_idx": { + "name": "judge_heat_assignments_unique_idx", + "columns": [ + "heat_id", + "membership_id", + "version_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "judge_heat_assignments_id": { + "name": "judge_heat_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "volunteer_shift_assignments": { + "name": "volunteer_shift_assignments", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shift_id": { + "name": "shift_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_id": { + "name": "membership_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "volunteer_shift_assignments_shift_idx": { + "name": "volunteer_shift_assignments_shift_idx", + "columns": [ + "shift_id" + ], + "isUnique": false + }, + "volunteer_shift_assignments_membership_idx": { + "name": "volunteer_shift_assignments_membership_idx", + "columns": [ + "membership_id" + ], + "isUnique": false + }, + "volunteer_shift_assignments_unique_idx": { + "name": "volunteer_shift_assignments_unique_idx", + "columns": [ + "shift_id", + "membership_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "volunteer_shift_assignments_id": { + "name": "volunteer_shift_assignments_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "volunteer_shifts": { + "name": "volunteer_shifts", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_type": { + "name": "role_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capacity": { + "name": "capacity", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "volunteer_shifts_competition_idx": { + "name": "volunteer_shifts_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "volunteer_shifts_start_time_idx": { + "name": "volunteer_shifts_start_time_idx", + "columns": [ + "start_time" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "volunteer_shifts_id": { + "name": "volunteer_shifts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "waiver_signatures": { + "name": "waiver_signatures", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "waiver_id": { + "name": "waiver_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registration_id": { + "name": "registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "waiver_signatures_waiver_idx": { + "name": "waiver_signatures_waiver_idx", + "columns": [ + "waiver_id" + ], + "isUnique": false + }, + "waiver_signatures_user_idx": { + "name": "waiver_signatures_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "waiver_signatures_registration_idx": { + "name": "waiver_signatures_registration_idx", + "columns": [ + "registration_id" + ], + "isUnique": false + }, + "waiver_signatures_waiver_user_unique": { + "name": "waiver_signatures_waiver_user_unique", + "columns": [ + "waiver_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "waiver_signatures_id": { + "name": "waiver_signatures_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "waivers": { + "name": "waivers", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "competition_id": { + "name": "competition_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "required_for_volunteers": { + "name": "required_for_volunteers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "position": { + "name": "position", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "waivers_competition_idx": { + "name": "waivers_competition_idx", + "columns": [ + "competition_id" + ], + "isUnique": false + }, + "waivers_position_idx": { + "name": "waivers_position_idx", + "columns": [ + "competition_id", + "position" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "waivers_id": { + "name": "waivers_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "movements": { + "name": "movements", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "movements_id": { + "name": "movements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "results": { + "name": "results", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "programming_track_id": { + "name": "programming_track_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_workout_instance_id": { + "name": "scheduled_workout_instance_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scale": { + "name": "scale", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scaling_level_id": { + "name": "scaling_level_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "as_rx": { + "name": "as_rx", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "wod_score": { + "name": "wod_score", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "set_count": { + "name": "set_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distance": { + "name": "distance", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_event_id": { + "name": "competition_event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition_registration_id": { + "name": "competition_registration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score_status": { + "name": "score_status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tie_break_score": { + "name": "tie_break_score", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secondary_score": { + "name": "secondary_score", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entered_by": { + "name": "entered_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "results_scaling_level_idx": { + "name": "results_scaling_level_idx", + "columns": [ + "scaling_level_id" + ], + "isUnique": false + }, + "results_workout_scaling_idx": { + "name": "results_workout_scaling_idx", + "columns": [ + "workout_id", + "scaling_level_id" + ], + "isUnique": false + }, + "results_leaderboard_idx": { + "name": "results_leaderboard_idx", + "columns": [ + "workout_id", + "scaling_level_id", + "wod_score" + ], + "isUnique": false + }, + "results_user_idx": { + "name": "results_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "results_date_idx": { + "name": "results_date_idx", + "columns": [ + "date" + ], + "isUnique": false + }, + "results_workout_idx": { + "name": "results_workout_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + }, + "results_competition_event_idx": { + "name": "results_competition_event_idx", + "columns": [ + "competition_event_id", + "scaling_level_id" + ], + "isUnique": false + }, + "results_competition_unique_idx": { + "name": "results_competition_unique_idx", + "columns": [ + "competition_event_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "results_id": { + "name": "results_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sets": { + "name": "sets", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result_id": { + "name": "result_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_number": { + "name": "set_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reps": { + "name": "reps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distance": { + "name": "distance", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time": { + "name": "time", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sets_id": { + "name": "sets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spicy_tags": { + "name": "spicy_tags", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spicy_tags_id": { + "name": "spicy_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "spicy_tags_name_unique": { + "name": "spicy_tags_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "workout_movements": { + "name": "workout_movements", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movement_id": { + "name": "movement_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workout_movements_workout_idx": { + "name": "workout_movements_workout_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + }, + "workout_movements_movement_idx": { + "name": "workout_movements_movement_idx", + "columns": [ + "movement_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workout_movements_id": { + "name": "workout_movements_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workout_tags": { + "name": "workout_tags", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workout_id": { + "name": "workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workout_tags_workout_idx": { + "name": "workout_tags_workout_idx", + "columns": [ + "workout_id" + ], + "isUnique": false + }, + "workout_tags_tag_idx": { + "name": "workout_tags_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workout_tags_id": { + "name": "workout_tags_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workouts": { + "name": "workouts", + "columns": { + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "update_counter": { + "name": "update_counter", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "scheme": { + "name": "scheme", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score_type": { + "name": "score_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reps_per_round": { + "name": "reps_per_round", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rounds_to_score": { + "name": "rounds_to_score", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "team_id": { + "name": "team_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sugar_id": { + "name": "sugar_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tiebreak_scheme": { + "name": "tiebreak_scheme", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_cap": { + "name": "time_cap", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_track_id": { + "name": "source_track_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_workout_id": { + "name": "source_workout_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scaling_group_id": { + "name": "scaling_group_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workouts_scaling_group_idx": { + "name": "workouts_scaling_group_idx", + "columns": [ + "scaling_group_id" + ], + "isUnique": false + }, + "workouts_team_idx": { + "name": "workouts_team_idx", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "workouts_source_track_idx": { + "name": "workouts_source_track_idx", + "columns": [ + "source_track_id" + ], + "isUnique": false + }, + "workouts_source_workout_idx": { + "name": "workouts_source_workout_idx", + "columns": [ + "source_workout_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workouts_id": { + "name": "workouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/wodsmith-db/mysql-migrations/meta/_journal.json b/packages/wodsmith-db/mysql-migrations/meta/_journal.json index 44b3e05b6..104a621c5 100644 --- a/packages/wodsmith-db/mysql-migrations/meta/_journal.json +++ b/packages/wodsmith-db/mysql-migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781971335691, "tag": "0002_crew-self-serve-preset-schema", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1781999669189, + "tag": "0003_benchmark-battery", + "breakpoints": true } ] } diff --git a/packages/wodsmith-db/src/schema.ts b/packages/wodsmith-db/src/schema.ts index 1f1c3ea95..c1d99ac15 100644 --- a/packages/wodsmith-db/src/schema.ts +++ b/packages/wodsmith-db/src/schema.ts @@ -3,6 +3,7 @@ export * from "./schemas/addresses" export * from "./schemas/coupons" export * from "./schemas/affiliates" +export * from "./schemas/benchmarks" export * from "./schemas/broadcasts" export * from "./schemas/billing" export * from "./schemas/commerce" diff --git a/packages/wodsmith-db/src/schemas/benchmarks.ts b/packages/wodsmith-db/src/schemas/benchmarks.ts new file mode 100644 index 000000000..239b7c478 --- /dev/null +++ b/packages/wodsmith-db/src/schemas/benchmarks.ts @@ -0,0 +1,186 @@ +import type { InferSelectModel } from "drizzle-orm" +import { relations } from "drizzle-orm" +import { + boolean, + index, + int, + mysqlTable, + text, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core" +import { + commonColumns, + createBenchmarkBatteryId, + createBenchmarkTestId, + createBenchmarkTierThresholdId, +} from "./common" +import { competitionsTable } from "./competitions" +import { scalingGroupsTable } from "./scaling" + +export const BENCHMARK_VIDEO_POLICIES = [ + "never", + "for_top_scores", + "always", +] as const +export type BenchmarkVideoPolicy = (typeof BENCHMARK_VIDEO_POLICIES)[number] + +export const BENCHMARK_BATTERY_STATUS = { + DRAFT: "draft", + PUBLISHED: "published", + ARCHIVED: "archived", +} as const +export type BenchmarkBatteryStatus = + (typeof BENCHMARK_BATTERY_STATUS)[keyof typeof BENCHMARK_BATTERY_STATUS] + +export const BENCHMARK_SCORE_MODELS = ["standard", "hybrid"] as const +export type BenchmarkScoreModel = (typeof BENCHMARK_SCORE_MODELS)[number] + +// @lat: [[domain#Domain Model#Benchmark Batteries]] +export const benchmarkBatteriesTable = mysqlTable( + "benchmark_batteries", + { + ...commonColumns, + id: varchar({ length: 255 }) + .primaryKey() + .$defaultFn(() => createBenchmarkBatteryId()) + .notNull(), + ownerTeamId: varchar({ length: 255 }), + ownerKey: varchar({ length: 255 }).notNull(), + slug: varchar({ length: 255 }).notNull(), + name: varchar({ length: 255 }).notNull(), + description: text(), + categories: text().notNull(), + ratingBands: text().notNull(), + maxTier: int().default(10).notNull(), + scoreMax: int().default(100).notNull(), + videoPolicy: varchar({ length: 20 }) + .$type() + .default("never") + .notNull(), + isOpenJoin: boolean().default(false).notNull(), + variantScalingGroupId: varchar({ length: 255 }), + competitionId: varchar({ length: 255 }), + status: varchar({ length: 20 }) + .$type() + .default("draft") + .notNull(), + }, + (table) => [ + uniqueIndex("benchmark_batteries_owner_key_unique").on(table.ownerKey), + uniqueIndex("benchmark_batteries_competition_unique").on( + table.competitionId, + ), + index("benchmark_batteries_owner_team_idx").on(table.ownerTeamId), + index("benchmark_batteries_status_idx").on(table.status), + ], +) + +export const benchmarkTestsTable = mysqlTable( + "benchmark_tests", + { + ...commonColumns, + id: varchar({ length: 255 }) + .primaryKey() + .$defaultFn(() => createBenchmarkTestId()) + .notNull(), + batteryId: varchar({ length: 255 }).notNull(), + categoryKey: varchar({ length: 64 }).notNull(), + name: varchar({ length: 255 }).notNull(), + position: int().notNull(), + scheme: varchar({ length: 255 }).notNull(), + scoreType: varchar({ length: 255 }).notNull(), + inputUnit: varchar({ length: 64 }).notNull(), + includedInScoring: boolean().default(true).notNull(), + timeCapMs: int(), + scoreModel: varchar({ length: 20 }) + .$type() + .default("standard") + .notNull(), + hybridFlipTier: int(), + hybridScale: text(), + }, + (table) => [ + uniqueIndex("benchmark_tests_battery_position_unique").on( + table.batteryId, + table.position, + ), + index("benchmark_tests_battery_idx").on(table.batteryId), + index("benchmark_tests_category_idx").on(table.batteryId, table.categoryKey), + ], +) + +export const benchmarkTierThresholdsTable = mysqlTable( + "benchmark_tier_thresholds", + { + ...commonColumns, + id: varchar({ length: 255 }) + .primaryKey() + .$defaultFn(() => createBenchmarkTierThresholdId()) + .notNull(), + testId: varchar({ length: 255 }).notNull(), + variant: varchar({ length: 64 }).notNull(), + tier: int().notNull(), + thresholdValue: int().notNull(), + rawValue: varchar({ length: 255 }).notNull(), + }, + (table) => [ + uniqueIndex("benchmark_thresholds_test_variant_tier_unique").on( + table.testId, + table.variant, + table.tier, + ), + index("benchmark_thresholds_test_variant_idx").on( + table.testId, + table.variant, + ), + ], +) + +export const benchmarkBatteriesRelations = relations( + benchmarkBatteriesTable, + ({ one, many }) => ({ + competition: one(competitionsTable, { + fields: [benchmarkBatteriesTable.competitionId], + references: [competitionsTable.id], + }), + variantScalingGroup: one(scalingGroupsTable, { + fields: [benchmarkBatteriesTable.variantScalingGroupId], + references: [scalingGroupsTable.id], + }), + tests: many(benchmarkTestsTable), + }), +) + +export const benchmarkTestsRelations = relations( + benchmarkTestsTable, + ({ one, many }) => ({ + battery: one(benchmarkBatteriesTable, { + fields: [benchmarkTestsTable.batteryId], + references: [benchmarkBatteriesTable.id], + }), + thresholds: many(benchmarkTierThresholdsTable), + }), +) + +export const benchmarkTierThresholdsRelations = relations( + benchmarkTierThresholdsTable, + ({ one }) => ({ + test: one(benchmarkTestsTable, { + fields: [benchmarkTierThresholdsTable.testId], + references: [benchmarkTestsTable.id], + }), + }), +) + +export type BenchmarkBattery = InferSelectModel< + typeof benchmarkBatteriesTable +> +export type BenchmarkBatteryInsert = typeof benchmarkBatteriesTable.$inferInsert +export type BenchmarkTest = InferSelectModel +export type BenchmarkTestInsert = typeof benchmarkTestsTable.$inferInsert +export type BenchmarkTierThreshold = InferSelectModel< + typeof benchmarkTierThresholdsTable +> +export type BenchmarkTierThresholdInsert = + typeof benchmarkTierThresholdsTable.$inferInsert diff --git a/packages/wodsmith-db/src/schemas/common.ts b/packages/wodsmith-db/src/schemas/common.ts index d680fb1eb..01e5ed35f 100644 --- a/packages/wodsmith-db/src/schemas/common.ts +++ b/packages/wodsmith-db/src/schemas/common.ts @@ -148,6 +148,11 @@ export const createRouteDocId = () => `rdoc_${ulid()}` export const createRouteDocRouteId = () => `rdocrt_${ulid()}` export const createRouteDocVersionId = () => `rdocver_${ulid()}` +// Benchmark battery ID generators +export const createBenchmarkBatteryId = () => `bbat_${ulid()}` +export const createBenchmarkTestId = () => `btst_${ulid()}` +export const createBenchmarkTierThresholdId = () => `bthr_${ulid()}` + // Crew event settings ID generators export const createCrewEventSettingsId = () => `crewset_${ulid()}` diff --git a/packages/wodsmith-db/src/schemas/competitions.ts b/packages/wodsmith-db/src/schemas/competitions.ts index 044082676..4da5fe1a8 100644 --- a/packages/wodsmith-db/src/schemas/competitions.ts +++ b/packages/wodsmith-db/src/schemas/competitions.ts @@ -116,7 +116,7 @@ export const competitionsTable = mysqlTable( .notNull(), // Competition type: in-person = traditional venue-based, online = virtual/remote with video submissions competitionType: varchar({ length: 15 }) - .$type<"in-person" | "online">() + .$type<"in-person" | "online" | "benchmark">() .default("in-person") .notNull(), // Competition branding images @@ -486,6 +486,7 @@ export type CompetitionVisibility = export const COMPETITION_TYPES = { IN_PERSON: "in-person", ONLINE: "online", + BENCHMARK: "benchmark", } as const export type CompetitionType = diff --git a/packages/wodsmith-db/src/schemas/programming.ts b/packages/wodsmith-db/src/schemas/programming.ts index c2b0d64c6..269e81f1a 100644 --- a/packages/wodsmith-db/src/schemas/programming.ts +++ b/packages/wodsmith-db/src/schemas/programming.ts @@ -120,6 +120,9 @@ export const trackWorkoutsTable = mysqlTable( // Minimum number of heats a judge must rest between rotations // Nullable = inherit from competition default or system default of 2 minHeatBuffer: int().default(2), + // Benchmark batteries bind competition events to source test definitions. + benchmarkTestId: varchar({ length: 255 }), + benchmarkCategory: varchar({ length: 64 }), }, (table) => [ index("track_workout_track_idx").on(table.trackId), @@ -132,6 +135,8 @@ export const trackWorkoutsTable = mysqlTable( table.trackOrder, ), index("track_workout_sponsor_idx").on(table.sponsorId), + index("track_workout_benchmark_test_idx").on(table.benchmarkTestId), + index("track_workout_benchmark_category_idx").on(table.benchmarkCategory), ], ) diff --git a/packages/wodsmith-db/src/schemas/scores.ts b/packages/wodsmith-db/src/schemas/scores.ts index 67fa3111f..2b0d18dfa 100644 --- a/packages/wodsmith-db/src/schemas/scores.ts +++ b/packages/wodsmith-db/src/schemas/scores.ts @@ -80,6 +80,8 @@ export const scoresTable = mysqlTable( // Scaling scalingLevelId: varchar({ length: 255 }), asRx: boolean().notNull().default(false), + // Benchmark score variant snapshot, usually the athlete profile gender at submission. + benchmarkVariant: varchar({ length: 64 }), // Metadata notes: text(), @@ -128,6 +130,10 @@ export const scoresTable = mysqlTable( table.userId, table.scalingLevelId, ), + index("idx_scores_benchmark_variant").on( + table.competitionEventId, + table.benchmarkVariant, + ), ], ) From b3b7e1b97a84840380c0f0db19ae193a3ff99e4c Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 18:48:37 -0600 Subject: [PATCH 06/51] feat: seed benchmark training guide battery --- .../assumptions-and-decisions.md | 2 + .../hillerfit-benchmark-leaderboard/tasks.md | 1 + .../technical-design.md | 1 + apps/wodsmith-start/scripts/seed/cleanup.ts | 3 + .../seed/data/benchmark-training-guide.ts | 2627 +++++++++++++++++ apps/wodsmith-start/scripts/seed/index.ts | 2 + .../scripts/seed/seeders/23-benchmark.ts | 26 + .../test/scripts/benchmark-seed-data.test.ts | 150 + lat.md/domain.md | 2 + 9 files changed, 2814 insertions(+) create mode 100644 apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts create mode 100644 apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts create mode 100644 apps/wodsmith-start/test/scripts/benchmark-seed-data.test.ts diff --git a/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md index 7138ec192..a3123820c 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md +++ b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md @@ -17,6 +17,7 @@ This file records what implementers should treat as settled, what still requires - 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. - Do not build HillerFit-branded pages, routes, marketing surfaces, logos, or theme treatments. +- The first seed sets `videoPolicy: "never"` and `isOpenJoin: true`; M3 must enforce the guarded open-join checks before athlete writes. ## Source Data Check @@ -33,6 +34,7 @@ Before completing the first seed, extract tests, categories, units, and threshol - Reusing `competitionType: "online"` for benchmark. - HillerFit-branded pages or product navigation. +- 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. diff --git a/ai/research/hillerfit-benchmark-leaderboard/tasks.md b/ai/research/hillerfit-benchmark-leaderboard/tasks.md index bced274ac..e4af7fb0b 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/tasks.md +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -37,6 +37,7 @@ Create the first generic benchmark battery from the local training PDF and recor 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: true`, 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`. - No task creates HillerFit-branded pages, routes, product navigation, marketing copy, logos, or theme treatments. diff --git a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md index e424ec307..3ac376599 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -40,6 +40,7 @@ This design defines the components and contracts engineers should build for v1. - 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: true` as seed defaults; the later M3 write path is responsible for published/visible, profile, waiver, idempotency, and rate-limit guards before open-join submissions. ## Read Flow 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..2c12b68d3 --- /dev/null +++ b/apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts @@ -0,0 +1,2627 @@ +import { encodeScore } from "../../../src/lib/scoring/encode" +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: "/Users/zacjones/Downloads/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: 55, + deferredTestCount: 3, + assumptions: [ + "The PDF is source data only; seeded app-facing names and descriptions stay generic.", + "Weighted C2B Pull Up is deferred because v1 has no bodyweight-plus-added-load scoring contract.", + "Open 16.2 and Open 18.4 are deferred because v1 excludes hybrid reps/time scoring.", + "Deferred tests are seeded as benchmark_tests with includedInScoring=false and no tier threshold rows.", + "The v1 seed uses videoPolicy \"never\" and isOpenJoin true so the first demo board is self-serve; M3 must add the guarded open-join checks before athlete writes.", + ], +} as const + +export const BENCHMARK_CATEGORIES = [ + { key: "strength", label: "Strength", testCount: 15, weight: 1 }, + { key: "gymnastics", label: "Gymnastics", testCount: 13, weight: 1 }, + { key: "engine", label: "Engine", testCount: 14, weight: 1 }, + { + key: "benchmark_workout", + label: "Benchmark Workouts", + testCount: 13, + 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 + 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": false, + "scoreModel": "standard", + "hybridFlipTier": null, + "deferReason": "Requires bodyweight-plus-added-load scoring that is intentionally deferred to v2." + }, + { + "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:01:30" + ], + "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:01:30" + ] + }, + "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": "feet", + "scoreType": "max", + "inputUnit": "in", + "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": false, + "scoreModel": "hybrid", + "hybridFlipTier": 9, + "deferReason": "Requires hybrid reps-then-time scoring that is intentionally deferred to v2." + }, + { + "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": false, + "scoreModel": "hybrid", + "hybridFlipTier": 7, + "deferReason": "Requires hybrid reps-then-time scoring that is intentionally deferred to v2." + } +] 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): number { + 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 { + 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), + raw_value: rawValue, + ...commonColumns(ts), + } + }), + ) + }) +} + +const benchmarkScoringConfig = { + algorithm: "absolute_tier", + absoluteTier: { batteryId: BENCHMARK_SEED_IDS.batteryId }, + 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.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: 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", + event_status: test.includedInScoring ? "published" : "draft", + 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: true, + 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: null, + score_model: test.scoreModel, + hybrid_flip_tier: test.hybridFlipTier, + hybrid_scale: test.scoreModel === "hybrid" ? test.deferReason : null, + ...commonColumns(ts), + })), + benchmarkTierThresholds: buildBenchmarkThresholdRows(ts), + } +} 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/23-benchmark.ts b/apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts new file mode 100644 index 000000000..c4e3dc9b1 --- /dev/null +++ b/apps/wodsmith-start/scripts/seed/seeders/23-benchmark.ts @@ -0,0 +1,26 @@ +import type { Connection } from "mysql2/promise" +import { 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 rows = buildBenchmarkSeedRows(now()) + + 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, + ) +} diff --git a/apps/wodsmith-start/test/scripts/benchmark-seed-data.test.ts b/apps/wodsmith-start/test/scripts/benchmark-seed-data.test.ts new file mode 100644 index 000000000..f64761310 --- /dev/null +++ b/apps/wodsmith-start/test/scripts/benchmark-seed-data.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest" +import { + benchmarkCategoriesSchema, + benchmarkRatingBandsSchema, + getBenchmarkCategoryCountIssues, +} from "@/schemas/benchmark.schema" +import { scoringConfigSchema } from "@/schemas/scoring.schema" +import { + BENCHMARK_CATEGORIES, + BENCHMARK_SEED_IDS, + BENCHMARK_SEED_TESTS, + BENCHMARK_SOURCE_RECEIPT, + buildBenchmarkSeedRows, +} from "../../scripts/seed/data/benchmark-training-guide" + +const TEST_TS = "2026-01-01 00:00:00" + +describe("benchmark seed data", () => { + it("records the PDF provenance and v1 included/deferred split", () => { + expect(BENCHMARK_SOURCE_RECEIPT.sourceArtifact.sha256).toBe( + "a80c7ab33874ff4fb8a4eea6a044df83511d164e55588a94ec455145a8f3cc38", + ) + expect(BENCHMARK_SOURCE_RECEIPT.designedTestCount).toBe(58) + expect(BENCHMARK_SOURCE_RECEIPT.includedTestCount).toBe(55) + expect(BENCHMARK_SOURCE_RECEIPT.deferredTestCount).toBe(3) + + const included = BENCHMARK_SEED_TESTS.filter( + (test) => test.includedInScoring, + ) + const deferred = BENCHMARK_SEED_TESTS.filter( + (test) => !test.includedInScoring, + ) + + expect(BENCHMARK_SEED_TESTS).toHaveLength(58) + expect(included).toHaveLength(55) + expect(deferred.map((test) => test.name)).toEqual([ + "Weighted C2B Pull Up (lb)", + "Open 16.2 (time/reps)", + "Open 18.4 (reps/time)", + ]) + expect(deferred.every((test) => test.deferReason)).toBe(true) + }) + + it("keeps category counts derived from included tests", () => { + const categories = benchmarkCategoriesSchema.parse(BENCHMARK_CATEGORIES) + + expect(categories.map(({ key, testCount }) => [key, testCount])).toEqual([ + ["strength", 15], + ["gymnastics", 13], + ["engine", 14], + ["benchmark_workout", 13], + ]) + expect( + getBenchmarkCategoryCountIssues(categories, BENCHMARK_SEED_TESTS), + ).toEqual([]) + }) + + it("pre-encodes ten male and female thresholds for every included test only", () => { + const rows = buildBenchmarkSeedRows(TEST_TS) + const includedIds = new Set( + BENCHMARK_SEED_TESTS.filter((test) => test.includedInScoring).map( + (test) => test.id, + ), + ) + const deferredIds = new Set( + BENCHMARK_SEED_TESTS.filter((test) => !test.includedInScoring).map( + (test) => test.id, + ), + ) + + expect(rows.benchmarkTierThresholds).toHaveLength(55 * 2 * 10) + + for (const testId of includedIds) { + for (const variant of ["male", "female"]) { + expect( + rows.benchmarkTierThresholds.filter( + (row) => row.test_id === testId && row.variant === variant, + ), + ).toHaveLength(10) + } + } + + expect( + rows.benchmarkTierThresholds.some((row) => deferredIds.has(row.test_id)), + ).toBe(false) + expect( + rows.benchmarkTierThresholds.find( + (row) => + row.test_id === "btst_training_guide_strict_press" && + row.variant === "male" && + row.tier === 1, + )?.threshold_value, + ).toBe(52163) + expect( + rows.benchmarkTierThresholds.find( + (row) => + row.test_id === "btst_training_guide_vertical_jump_in" && + row.variant === "male" && + row.tier === 1, + )?.threshold_value, + ).toBe(508) + }) + + it("builds a generic benchmark competition without submission-window rows", () => { + const rows = buildBenchmarkSeedRows(TEST_TS) + const [competition] = rows.competitions + const [battery] = rows.benchmarkBatteries + const settings = JSON.parse(competition.settings) + + expect(competition.competition_type).toBe("benchmark") + expect(settings.boardMode).toBe("perpetual") + expect(settings.divisions.scalingGroupId).toBe( + BENCHMARK_SEED_IDS.scalingGroupId, + ) + expect(scoringConfigSchema.safeParse(settings.scoringConfig).success).toBe( + true, + ) + expect(rows.scalingLevels).toEqual([ + expect.objectContaining({ label: "Open", team_size: 1 }), + ]) + expect(rows.trackWorkouts).toHaveLength(58) + expect(rows.trackWorkouts.every((row) => row.benchmark_test_id)).toBe(true) + expect(rows.competitionEvents).toEqual([]) + expect(battery.video_policy).toBe("never") + expect(battery.is_open_join).toBe(true) + }) + + it("validates battery JSON blobs and keeps seeded product strings unbranded", () => { + const rows = buildBenchmarkSeedRows(TEST_TS) + const [battery] = rows.benchmarkBatteries + + expect(benchmarkCategoriesSchema.safeParse(JSON.parse(battery.categories)).success) + .toBe(true) + expect( + benchmarkRatingBandsSchema.safeParse(JSON.parse(battery.rating_bands)) + .success, + ).toBe(true) + + const productRows = { + teams: rows.teams, + competitions: rows.competitions, + programmingTracks: rows.programmingTracks, + workouts: rows.workouts, + trackWorkouts: rows.trackWorkouts, + benchmarkBatteries: rows.benchmarkBatteries, + } + + expect(JSON.stringify(productRows)).not.toMatch(/hillerfit/i) + }) +}) diff --git a/lat.md/domain.md b/lat.md/domain.md index 443ffbe74..ee69ce1a7 100644 --- a/lat.md/domain.md +++ b/lat.md/domain.md @@ -164,6 +164,8 @@ Benchmark competitions reuse the normal competition shell. [[packages/wodsmith-d Benchmark configuration validates before write/read code consumes it. [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkCategoriesSchema]] rejects duplicate or malformed category caches, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#getBenchmarkCategoryCountIssues]] compares cached `testCount` values to included tests, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkVariantSchema]] limits v1 score variants to the profile gender values, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkThresholdValuesSchema]] requires ten tier thresholds per standard row, and [[apps/wodsmith-start/src/schemas/scoring.schema.ts#scoringConfigSchema]] requires `absoluteTier.batteryId` whenever `algorithm` is `absolute_tier`. Until the M2 scoring engine lands, [[apps/wodsmith-start/src/lib/scoring/algorithms/index.ts#calculateEventPoints]] fails closed for `absolute_tier` instead of returning placeholder points. +The first benchmark seed treats the source PDF as provenance data, not a branded product surface. [[apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts#buildBenchmarkSeedRows]] creates generic competition, battery, workout, and track-workout rows from 58 source tests, marks three unsupported tests as `includedInScoring=false`, emits tier thresholds only for the 55 included tests, and leaves `competition_events` empty so the board stays perpetual without submission windows. + ## Volunteers Volunteers are competition staff assigned roles like judge, scorekeeper, medical, and emcee. From 95d9913e040fd5c2a109d22906a3ee8076bbb4e2 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 19:18:06 -0600 Subject: [PATCH 07/51] feat: add benchmark absolute tier scoring --- .../hillerfit-benchmark-leaderboard-guide.md | 22 +-- .../hillerfit-benchmark-leaderboard/README.md | 4 +- .../assumptions-and-decisions.md | 6 +- .../requirements.md | 8 +- .../reviewer-alignment.md | 4 +- .../hillerfit-benchmark-leaderboard/tasks.md | 16 +- .../technical-design.md | 8 +- .../test-strategy.md | 5 +- .../traceability.md | 4 +- .../competition-leaderboard-table.tsx | 82 ++++----- .../online-competition-leaderboard-table.tsx | 153 +++++++++-------- .../lib/scoring/algorithms/absolute-tier.ts | 128 +++++++++++++++ .../src/lib/scoring/algorithms/index.ts | 28 +++- .../src/lib/scoring/category-aggregation.ts | 83 ++++++++++ .../src/lib/scoring/format/index.ts | 1 + .../src/lib/scoring/format/points.ts | 23 +++ apps/wodsmith-start/src/lib/scoring/index.ts | 1 + .../src/lib/scoring/tiebreakers.ts | 90 +++++++++- .../src/schemas/scoring.schema.ts | 73 +++++---- .../test/lib/scoring/absolute-tier.test.ts | 155 ++++++++++++++++++ .../lib/scoring/category-aggregation.test.ts | 45 +++++ .../test/lib/scoring/factory.test.ts | 53 +++++- .../test/lib/scoring/format-points.test.ts | 15 ++ .../test/lib/scoring/tiebreakers.test.ts | 36 ++++ .../test/schemas/scoring.test.ts | 11 ++ lat.md/domain.md | 4 +- 26 files changed, 851 insertions(+), 207 deletions(-) create mode 100644 apps/wodsmith-start/src/lib/scoring/algorithms/absolute-tier.ts create mode 100644 apps/wodsmith-start/src/lib/scoring/category-aggregation.ts create mode 100644 apps/wodsmith-start/src/lib/scoring/format/points.ts create mode 100644 apps/wodsmith-start/test/lib/scoring/absolute-tier.test.ts create mode 100644 apps/wodsmith-start/test/lib/scoring/category-aggregation.test.ts create mode 100644 apps/wodsmith-start/test/lib/scoring/format-points.test.ts diff --git a/ai/research/hillerfit-benchmark-leaderboard-guide.md b/ai/research/hillerfit-benchmark-leaderboard-guide.md index 3047638b6..343cc5125 100644 --- a/ai/research/hillerfit-benchmark-leaderboard-guide.md +++ b/ai/research/hillerfit-benchmark-leaderboard-guide.md @@ -1,7 +1,7 @@ -# 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 benchmark feature code has been written; the existing capability-registry base is already present for current competition types. This is the document Zac reviews *before* implementation begins. -> **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. **We are not building HillerFit-branded pages, routes, marketing surfaces, or theme treatments.** The user-facing product remains WODsmith's generic benchmark experience. +> **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: the competition capability registry already exists for `"in-person"`/`"online"` and intentionally fails closed for `"benchmark"`; benchmark work extends that registry instead of rebuilding M0 from scratch. @@ -22,14 +22,14 @@ ## 0. Decision sheet — confirm before M1 -**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. +**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) ✅ 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 and branding boundary | **Use `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf` as the benchmark data source; do not build HillerFit-branded pages** | 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 surface is out of scope. | +| **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. | **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.) @@ -46,7 +46,7 @@ ## 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"). 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, not HillerFit-branded pages. 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):** @@ -247,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) | --- @@ -257,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) @@ -886,7 +886,7 @@ Series (`series-leaderboard.ts`) aggregates the **same workout across different > **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). -- **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 HillerFit-branded pages. +- **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. @@ -923,7 +923,7 @@ The training-guide-specific content is **data, not code or branded UI**: **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). @@ -933,7 +933,7 @@ Each milestone is independently mergeable. **DB workflow:** `pnpm db:push` to ap |---|-----------|---------------|------------|--------| | **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 + 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. | `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 | +| **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 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 | @@ -1026,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. **✅ 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 HillerFit-branded pages, routes, marketing surfaces, or theme treatments. If PDF extraction reveals missing or ambiguous threshold data, ask the owner about the data gap rather than inventing a branded product fork. +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. --- diff --git a/ai/research/hillerfit-benchmark-leaderboard/README.md b/ai/research/hillerfit-benchmark-leaderboard/README.md index 844405c66..93e499620 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/README.md +++ b/ai/research/hillerfit-benchmark-leaderboard/README.md @@ -1,4 +1,4 @@ -# HillerFit Benchmark Leaderboard Implementation Packet +# Generic Benchmark Leaderboard Implementation Packet This packet turns the benchmark leaderboard guide into implementation-ready contracts. @@ -18,6 +18,6 @@ Source narrative: [`../hillerfit-benchmark-leaderboard-guide.md`](../hillerfit-b ## Build Rule -The supplied training PDF is a benchmark seed-data source only. Do not build HillerFit-branded pages, routes, product navigation, marketing copy, logos, theme treatments, or any other branded surface; the shipped UI remains WODsmith's generic benchmark board/stat-line experience. +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. 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/assumptions-and-decisions.md b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md index a3123820c..14f110a79 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md +++ b/ai/research/hillerfit-benchmark-leaderboard/assumptions-and-decisions.md @@ -16,12 +16,12 @@ This file records what implementers should treat as settled, what still requires - 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. -- Do not build HillerFit-branded pages, routes, marketing surfaces, logos, or theme treatments. +- 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: true`; M3 must enforce the guarded open-join checks before athlete writes. ## 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. +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 @@ -33,7 +33,7 @@ Before completing the first seed, extract tests, categories, units, and threshol ## Rejected Alternatives - Reusing `competitionType: "online"` for benchmark. -- HillerFit-branded pages or product navigation. +- 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. diff --git a/ai/research/hillerfit-benchmark-leaderboard/requirements.md b/ai/research/hillerfit-benchmark-leaderboard/requirements.md index b4c58bdba..757f470aa 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/requirements.md +++ b/ai/research/hillerfit-benchmark-leaderboard/requirements.md @@ -8,7 +8,7 @@ These requirements define the v1 benchmark leaderboard product and record assump - 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`. -- We are not building HillerFit-branded pages, routes, marketing surfaces, logos, or theme treatments. The UI remains WODsmith's generic benchmark board/stat-line experience. +- 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`. @@ -22,7 +22,7 @@ The local PDF is the source of truth for the first seed. Before completing M1: - 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 or branded route as a substitute for missing seed data. +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. ## V1 User Requirements @@ -45,11 +45,11 @@ Do not create a HillerFit-branded landing page or branded route as a substitute - 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, marketing copy, logos, visual theme, or product navigation. +- 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, 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, and the focused automated/manual tests in `test-strategy.md` pass. +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 diff --git a/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md index 39cd67484..d22728e94 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md +++ b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md @@ -36,7 +36,7 @@ Dependencies: - M2 algorithm before M3 keep-best-on-write. - M3 submission before M4 public demo verification. - First seed derived from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. -- No HillerFit-branded pages or routes. +- No HillerFit-branded customer-facing pages, routes, stats pages, navigation, marketing copy, logos, calls to action, or theme treatments. Definition of done: @@ -44,4 +44,4 @@ Definition of done: ## Convergence Result -The reviewers materially agree on components, data model, dependencies, and definition of done. The one substantive divergence risk was M0a current-state drift, now resolved by making the packet current-state-aware. The owner clarified that the build uses the training PDF as benchmark source data and does not include HillerFit-branded pages. +The reviewers materially agree on components, data model, dependencies, and definition of done. The one substantive divergence risk was M0a current-state drift, now resolved by making the packet current-state-aware. The owner clarified that the build uses the training PDF as benchmark source data only and does not include HillerFit-branded customer-facing surfaces. diff --git a/ai/research/hillerfit-benchmark-leaderboard/tasks.md b/ai/research/hillerfit-benchmark-leaderboard/tasks.md index e4af7fb0b..ea0dfeae5 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/tasks.md +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -2,6 +2,13 @@ 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. @@ -28,7 +35,7 @@ Acceptance criteria: - 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. -- No task creates HillerFit-branded pages, routes, product navigation, marketing copy, logos, or theme treatments. +- Global branding acceptance criteria pass for this schema slice. ## M1b PDF-Derived Seed And Receipt @@ -40,7 +47,7 @@ Acceptance criteria: - Seed defaults are explicit: `videoPolicy: "never"`, `isOpenJoin: true`, 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`. -- No task creates HillerFit-branded pages, routes, product navigation, marketing copy, logos, or theme treatments. +- Global branding acceptance criteria pass for this seed slice. ## M2 Absolute-Tier Scoring @@ -55,6 +62,7 @@ Acceptance criteria: - 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 @@ -68,6 +76,7 @@ Acceptance criteria: - 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 @@ -82,7 +91,8 @@ Acceptance criteria: - 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. - 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 copy. +- 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 diff --git a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md index 3ac376599..0505aa7f5 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -12,13 +12,13 @@ This design defines the components and contracts engineers should build for v1. - 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`. +- 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`: named product, 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_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. @@ -71,8 +71,8 @@ This design defines the components and contracts engineers should build for v1. - Configuration-unavailable cells are rendered separately from athlete tier 0 attempts. - 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 or changing the generic product scope. +- 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, and rating bands, but it must not introduce HillerFit-branded pages, routes, navigation entries, logos, marketing sections, or visual theme. +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 index 5ba061bd8..0099fe033 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md +++ b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md @@ -30,7 +30,8 @@ This strategy makes the implementation testable without requiring the full histo - Leaderboard chooses online visual variant for benchmark through `leaderboardVariant` while avoiding publish gating. - Stats tab appears only for `absolute_tier` competitions. - 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, product navigation entries, or calls to action. +- 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. @@ -51,7 +52,7 @@ This strategy makes the implementation testable without requiring the full histo 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, product navigation entry, logo, marketing section, call to action, or theme treatment was added. +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 diff --git a/ai/research/hillerfit-benchmark-leaderboard/traceability.md b/ai/research/hillerfit-benchmark-leaderboard/traceability.md index 285064201..58027f14f 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/traceability.md +++ b/ai/research/hillerfit-benchmark-leaderboard/traceability.md @@ -13,5 +13,5 @@ This matrix links implementation artifacts back to the guide and the required te | 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` | -| No HillerFit-branded pages or navigation | Guide §0 D-D, §12.12 | `requirements.md`, `technical-design.md`, `tasks.md`, `assumptions-and-decisions.md` | Route/component/navigation assertions and manual QA | -| PDF source data | Guide §0 D-D, §12.12 | `requirements.md`, `tasks.md` M1b, `test-strategy.md` | Extraction receipt and seed validation | +| 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/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx b/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx index ab7d99a3f..a191c5bf8 100644 --- a/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/competition-leaderboard-table.tsx @@ -46,7 +46,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { getSortDirection } from "@/lib/scoring" +import { formatLeaderboardPoints, getSortDirection } from "@/lib/scoring" import type { WorkoutScheme } from "@/lib/scoring/types" import { cn } from "@/lib/utils" import type { @@ -121,25 +121,6 @@ function RankCell({ rank, points }: { rank: number; points?: number }) { ) } -/** - * Format points display based on scoring algorithm - * - Online scoring: show raw points (lower is better) - * - P-Score: show raw points (can be negative) - * - Other algorithms: show "+points" for positive (higher is better) - */ -function formatPoints(points: number, algorithm: ScoringAlgorithm): string { - // Online and p_score show raw points - // scoringAlgorithm axis, not competitionType. - if (algorithm === "online" || algorithm === "p_score") { - return String(points) - } - // Don't add + to negative numbers - if (points < 0) { - return String(points) - } - return `+${points}` -} - /** * Badge showing how many individual rounds were capped for a multi-round score. * Hidden for single-round scores and for scores with zero capped rounds. @@ -174,8 +155,8 @@ function CappedRoundsIndicator({

{label}

- At least one round hit the per-round time cap. The summed total - above includes penalty time from each capped round. + At least one round hit the per-round time cap. The summed total above + includes penalty time from each capped round.

@@ -246,7 +227,7 @@ function EventResultCell({ #{result.rank} · - {formatPoints(result.points, scoringAlgorithm)} + {formatLeaderboardPoints(result.points, scoringAlgorithm)}
) @@ -439,30 +420,33 @@ function MobileLeaderboardRow({
)}
- - {event.name} - - {result && result.rank > 0 ? ( -
- - {result.formattedScore} - - - {result.formattedTiebreak && ( - - (TB: {result.formattedTiebreak}) - - )} - - - #{result.rank}{" "} - {formatPoints(result.points, scoringAlgorithm)} - -
- ) : ( - - )} -
+ + {event.name} + + {result && result.rank > 0 ? ( +
+ + {result.formattedScore} + + + {result.formattedTiebreak && ( + + (TB: {result.formattedTiebreak}) + + )} + + + #{result.rank}{" "} + {formatLeaderboardPoints( + result.points, + scoringAlgorithm, + )} + +
+ ) : ( + + )} + ) })} @@ -723,9 +707,7 @@ export function CompetitionLeaderboardTable({ const parentGroupSpans = useMemo(() => { if (selectedEventId) return [] // No group headers in single-event view - const sortedEvents = [...events].sort( - (a, b) => a.trackOrder - b.trackOrder, - ) + const sortedEvents = [...events].sort((a, b) => a.trackOrder - b.trackOrder) // Count leading non-event columns (rank, athlete, optionally affiliate) const leadingCols = hasAffiliates ? 3 : 2 const hasAnyParent = sortedEvents.some((e) => e.parentEventId) diff --git a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx index 24a9267c4..1afeb8c63 100644 --- a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx @@ -1,9 +1,11 @@ "use client" import { Link, useLocation } from "@tanstack/react-router" +import { useServerFn } from "@tanstack/react-start" import { type CellContext, type ColumnDef, + type ExpandedState, flexRender, getCoreRowModel, getExpandedRowModel, @@ -12,9 +14,7 @@ import { type Row, type SortingState, useReactTable, - type ExpandedState, } from "@tanstack/react-table" -import { useServerFn } from "@tanstack/react-start" import { AlertTriangle, ArrowDownNarrowWide, @@ -27,6 +27,8 @@ import { Video, } from "lucide-react" import { Fragment, useEffect, useMemo, useState } from "react" +import { getStatusConfig } from "@/components/compete/submission-status-badge" +import { VideoVoteButtons } from "@/components/compete/video-vote-buttons" import { Button } from "@/components/ui/button" import { Collapsible, @@ -50,19 +52,17 @@ import { } from "@/components/ui/table" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { VideoEmbed } from "@/components/video-embed" -import { getStatusConfig } from "@/components/compete/submission-status-badge" -import { VideoVoteButtons } from "@/components/compete/video-vote-buttons" -import { getLeaderboardVideosFn } from "@/server-fns/video-submission-fns" -import { getVideoVoteCountsFn } from "@/server-fns/video-vote-fns" -import { useSession } from "@/utils/auth-client" -import { getSortDirection } from "@/lib/scoring" +import { formatLeaderboardPoints, getSortDirection } from "@/lib/scoring" import type { WorkoutScheme } from "@/lib/scoring/types" import { cn } from "@/lib/utils" import type { CompetitionLeaderboardEntry, TeamMemberInfo, } from "@/server-fns/leaderboard-fns" +import { getLeaderboardVideosFn } from "@/server-fns/video-submission-fns" +import { getVideoVoteCountsFn } from "@/server-fns/video-vote-fns" import type { ScoringAlgorithm } from "@/types/scoring" +import { useSession } from "@/utils/auth-client" // Type aliases for cleaner column definitions type LeaderboardCellContext = CellContext @@ -170,17 +170,6 @@ function RankCell({ rank, points }: { rank: number; points?: number }) { ) } -function formatPoints(points: number, algorithm: ScoringAlgorithm): string { - // scoringAlgorithm axis, not competitionType. - if (algorithm === "online" || algorithm === "p_score") { - return String(points) - } - if (points < 0) { - return String(points) - } - return `+${points}` -} - function formatMemberName(member: TeamMemberInfo): string { const name = `${member.firstName || ""} ${member.lastName || ""}`.trim() || "Unknown" @@ -231,11 +220,9 @@ function CappedRoundsIndicator({ function ReviewStatusIndicator({ summary, }: { - summary: - | NonNullable< - CompetitionLeaderboardEntry["eventResults"][number]["reviewSummary"] - > - | null + summary: NonNullable< + CompetitionLeaderboardEntry["eventResults"][number]["reviewSummary"] + > | null }) { if (!summary) return null @@ -552,10 +539,7 @@ function ExpandedVideoContent({ isOwnSubmission: boolean isLoggedIn: boolean }) { - const { videos, loading } = useTeamVideos( - result.videoSubmissionId, - isTeam, - ) + const { videos, loading } = useTeamVideos(result.videoSubmissionId, isTeam) // Team with multiple videos — show tabs if (isTeam && videos.length > 1) { @@ -573,7 +557,11 @@ function ExpandedVideoContent({ ))} {videos.map((v) => ( - + )}
- - {event.name} - - {result && result.rank > 0 ? ( - -
- - {result.formattedScore} - - {linkToSubmission && ( - + + {event.name} + + {result && result.rank > 0 ? ( + +
+ + {result.formattedScore} + + {linkToSubmission && ( + + )} + {result.formattedTiebreak && ( + + (TB: {result.formattedTiebreak}) + + )} + + + #{result.rank}{" "} + {formatLeaderboardPoints( + result.points, + scoringAlgorithm, + )} + + {result.penaltyType && ( + + + {result.penaltyType === "major" + ? "Major" + : "Minor"}{" "} + Penalty + {result.penaltyPercentage != null && + ` · ${result.penaltyPercentage}% deduction`} + )} - {result.formattedTiebreak && ( - - (TB: {result.formattedTiebreak}) + {!result.penaltyType && result.isDirectlyModified && ( + + + Score adjusted by organizer )} - - - #{result.rank}{" "} - {formatPoints(result.points, scoringAlgorithm)} - - {result.penaltyType && ( - - - {result.penaltyType === "major" ? "Major" : "Minor"}{" "} - Penalty - {result.penaltyPercentage != null && - ` · ${result.penaltyPercentage}% deduction`} - - )} - {!result.penaltyType && result.isDirectlyModified && ( - - - Score adjusted by organizer - - )} -
-
- ) : ( - - )} -
+
+ + ) : ( + + )} + ) })} @@ -1157,7 +1150,9 @@ export function OnlineCompetitionLeaderboardTable({
#{result.rank} · - {formatPoints(result.points, scoringAlgorithm)} + + {formatLeaderboardPoints(result.points, scoringAlgorithm)} + {result.videoUrl &&
{hasSubmitted && ( @@ -1236,7 +1266,9 @@ export function VideoSubmissionForm({ <> {videoSlots.map((slot, index) => (
- + + // ============================================================================ // Helper Functions // ============================================================================ @@ -120,6 +133,7 @@ async function checkSubmissionWindow( reason?: string opensAt?: Date closesAt?: Date + competitionType?: "in-person" | "online" | "benchmark" }> { // Competition type and event window are independent lookups — fetch them // in parallel. Each branch needs its own getDb() connection: getDb() @@ -164,21 +178,22 @@ async function checkSubmissionWindow( return { allowed: false, reason: "Video submissions are only for online competitions", + competitionType: competition.competitionType, } } if (competitionCan(competition.competitionType, "perpetual")) { - return { allowed: true } + return { allowed: true, competitionType: competition.competitionType } } // If no event record exists, allow submission (backward compatibility) if (!event) { - return { allowed: true } + return { allowed: true, competitionType: competition.competitionType } } // If no submission window is configured, allow submission if (!event.submissionOpensAt || !event.submissionClosesAt) { - return { allowed: true } + return { allowed: true, competitionType: competition.competitionType } } // Parse dates @@ -192,6 +207,7 @@ async function checkSubmissionWindow( reason: "Submission window has not opened yet", opensAt, closesAt, + competitionType: competition.competitionType, } } @@ -201,10 +217,16 @@ async function checkSubmissionWindow( reason: "Submission window has closed", opensAt, closesAt, + competitionType: competition.competitionType, } } - return { allowed: true, opensAt, closesAt } + return { + allowed: true, + opensAt, + closesAt, + competitionType: competition.competitionType, + } } /** @@ -341,6 +363,360 @@ async function getWorkoutDetails(trackWorkoutId: string) { return result ?? null } +interface SubmissionWorkoutSource { + workoutId: string + name: string + scheme: WorkoutScheme + scoreType: ScoreType | null + timeCap: number | null + tiebreakScheme: string | null + repsPerRound: number | null + roundsToScore: number | null +} + +function toSubmissionWorkout(workout: SubmissionWorkoutSource | null) { + return workout + ? { + workoutId: workout.workoutId, + name: workout.name, + scheme: workout.scheme as WorkoutScheme, + scoreType: workout.scoreType as ScoreType | null, + timeCap: workout.timeCap, + tiebreakScheme: workout.tiebreakScheme, + repsPerRound: workout.repsPerRound, + roundsToScore: workout.roundsToScore, + } + : null +} + +async function getBenchmarkOpenJoinSubmissionState({ + competitionId, + trackWorkoutId, + userId, +}: { + competitionId: string + trackWorkoutId: string + userId: string +}) { + const context = await getBenchmarkSubmissionContext( + competitionId, + trackWorkoutId, + ) + + if (!context?.isOpenJoin) return null + + const workout = await getWorkoutDetails(trackWorkoutId) + let canSubmit = true + let reason: string | undefined + + if ( + context.batteryStatus !== "published" || + context.competitionStatus !== "published" || + context.competitionVisibility !== "public" + ) { + canSubmit = false + reason = "This benchmark board is not open for submissions" + } else if (context.openDivisionTeamSize !== 1) { + canSubmit = false + reason = "Benchmark submissions are individual-only" + } else { + try { + await getBenchmarkProfileVariant(userId) + } catch (error) { + canSubmit = false + reason = + error instanceof Error + ? error.message + : "Complete your athlete profile gender before submitting" + } + } + + return { + submissions: [], + teamSize: 1, + isCaptain: true, + canSubmit, + reason, + isRegistered: false, + isBenchmarkOpenJoin: true, + videoRequired: context.videoPolicy === "always", + submissionWindow: null, + workout: toSubmissionWorkout(workout), + existingScore: null, + } +} + +async function submitBenchmarkVideoScore({ + db, + data, + registration, + context, + variant, + existingSubmission, + userId, + now, +}: { + db: Database + data: SubmitVideoInput + registration: BenchmarkRegistration + context: BenchmarkSubmissionContext + variant: BenchmarkVariant + existingSubmission: { id: string } | undefined + userId: string + now: Date +}) { + if (data.videoIndex !== 0) { + throw new Error("Benchmark submissions are individual-only") + } + + if (context.openDivisionTeamSize !== 1 || registration.athleteTeamId) { + throw new Error("Benchmark submissions are individual-only") + } + + const hasRoundScores = data.roundScores && data.roundScores.length > 0 + const hasScore = data.score || hasRoundScores + + if (!hasScore) { + throw new Error("A score is required when submitting") + } + + const workout = await getWorkoutDetails(data.trackWorkoutId) + + if (!workout) { + throw new Error("Workout not found") + } + + const scheme = workout.scheme as WorkoutScheme + const scoreType = + (workout.scoreType as ScoreType) || getDefaultScoreType(scheme) + + let encodedValue: number | null = null + let encodedRounds: number[] = [] + + if (hasRoundScores && data.roundScores) { + for (const rs of data.roundScores) { + const roundResult = parseScore(rs.score, scheme) + if (!roundResult.isValid) { + throw new Error( + `Invalid round score: ${roundResult.error || "Please check your entry"}`, + ) + } + } + const roundInputs = data.roundScores.map((rs) => ({ raw: rs.score })) + const roundsResult = encodeRounds(roundInputs, scheme, scoreType) + encodedValue = roundsResult.aggregated + encodedRounds = roundsResult.rounds + } else if (data.score) { + const parseResult = parseScore(data.score, scheme) + if (!parseResult.isValid) { + throw new Error( + `Invalid score format: ${parseResult.error || "Please check your entry"}`, + ) + } + encodedValue = encodeScore(data.score, scheme) + } + + let status: "scored" | "cap" = "scored" + let secondaryValue: number | null = null + const roundStatuses: Array<"scored" | "cap"> = [] + let cappedRoundCount = 0 + + if (scheme === "time-with-cap" && workout.timeCap && encodedValue !== null) { + const capMs = workout.timeCap * 1000 + + if (hasRoundScores && encodedRounds.length > 0) { + for (const roundValue of encodedRounds) { + const isRoundCapped = roundValue >= capMs + roundStatuses.push(isRoundCapped ? "cap" : "scored") + if (isRoundCapped) cappedRoundCount++ + } + if (cappedRoundCount > 0) { + status = "cap" + } + } else if (encodedValue >= capMs) { + status = "cap" + encodedValue = capMs + + if (data.secondaryScore) { + const trimmed = data.secondaryScore.trim() + if (trimmed) { + const parsed = Number.parseInt(trimmed, 10) + if (!Number.isNaN(parsed) && parsed >= 0) { + secondaryValue = parsed + } + } + } + } + } + + let tiebreakValue: number | null = null + if (data.tiebreakScore && workout.tiebreakScheme) { + tiebreakValue = encodeScore( + data.tiebreakScore, + workout.tiebreakScheme as WorkoutScheme, + ) + if (tiebreakValue === null) { + throw new Error( + `Invalid tiebreak score format: "${data.tiebreakScore}". Please check your entry.`, + ) + } + } + + const timeCapMs = workout.timeCap ? workout.timeCap * 1000 : null + const sortKey = + encodedValue !== null + ? computeSortKey({ + value: encodedValue, + status, + scheme, + scoreType, + cappedRoundCount, + timeCap: + status === "cap" && secondaryValue !== null + ? { ms: timeCapMs ?? 0, secondaryValue } + : undefined, + tiebreak: + tiebreakValue !== null && workout.tiebreakScheme + ? { + scheme: workout.tiebreakScheme as "time" | "reps", + value: tiebreakValue, + } + : undefined, + }) + : null + + const [track] = await db + .select({ + ownerTeamId: programmingTracksTable.ownerTeamId, + }) + .from(programmingTracksTable) + .where(eq(programmingTracksTable.id, workout.trackId)) + .limit(1) + + if (!track?.ownerTeamId) { + throw new Error("Could not determine team ownership") + } + + const scoreWriteInput = { + userId, + teamId: track.ownerTeamId, + workoutId: workout.workoutId, + competitionEventId: data.trackWorkoutId, + scheme, + scoreType, + scoreValue: encodedValue, + status, + statusOrder: getStatusOrder(status), + sortKey: sortKey ? sortKeyToString(sortKey) : null, + tiebreakScheme: (workout.tiebreakScheme as TiebreakScheme) ?? null, + tiebreakValue, + timeCapMs, + secondaryValue, + recordedAt: now, + } + + if ( + !data.videoUrl && + (await isBenchmarkVideoEvidenceRequired({ + context, + variant, + score: scoreWriteInput, + })) + ) { + throw new Error("A video URL is required for this benchmark submission") + } + + const { writeResult, submissionId, isUpdate } = await db.transaction( + async (tx) => { + const writeResult = await saveBenchmarkScoreInTransaction({ + db: tx, + context, + variant, + now, + score: scoreWriteInput, + }) + + let submissionId: string | null = existingSubmission?.id ?? null + let isUpdate = !!existingSubmission + + if (writeResult.written) { + if (data.videoUrl) { + if (existingSubmission) { + await tx + .update(videoSubmissionsTable) + .set({ + videoUrl: data.videoUrl, + notes: data.notes ?? null, + submittedAt: now, + updatedAt: now, + }) + .where(eq(videoSubmissionsTable.id, existingSubmission.id)) + } else { + const id = createVideoSubmissionId() + await tx.insert(videoSubmissionsTable).values({ + id, + registrationId: registration.id, + trackWorkoutId: data.trackWorkoutId, + videoIndex: data.videoIndex, + userId, + videoUrl: data.videoUrl, + notes: data.notes ?? null, + submittedAt: now, + }) + submissionId = id + isUpdate = false + } + } + + if (submissionId) { + await resetBenchmarkVideoReviewState(submissionId, now, tx) + } + + if (encodedRounds.length > 0) { + const scoreId = + writeResult.scoreId ?? + ( + await tx + .select({ id: scoresTable.id }) + .from(scoresTable) + .where( + and( + eq(scoresTable.competitionEventId, data.trackWorkoutId), + eq(scoresTable.userId, userId), + eq(scoresTable.scalingLevelId, context.openDivisionId), + ), + ) + .limit(1) + )[0]?.id + + if (scoreId) { + await tx + .delete(scoreRoundsTable) + .where(eq(scoreRoundsTable.scoreId, scoreId)) + await tx.insert(scoreRoundsTable).values( + encodedRounds.map((value, index) => ({ + scoreId, + roundNumber: index + 1, + value, + status: roundStatuses[index] ?? null, + })), + ) + } + } + } + + return { writeResult, submissionId, isUpdate } + }, + ) + + return { + success: true, + submissionId: submissionId ?? undefined, + isUpdate, + retainedCurrentBest: writeResult.retainedCurrentBest, + } +} + // ============================================================================ // Server Functions // ============================================================================ @@ -362,6 +738,8 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) canSubmit: false, reason: "Not authenticated", isRegistered: false, + isBenchmarkOpenJoin: false, + videoRequired: true, workout: null, existingScore: null, } @@ -375,6 +753,13 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) ) if (!registration) { + const benchmarkOpenJoin = await getBenchmarkOpenJoinSubmissionState({ + competitionId: data.competitionId, + trackWorkoutId: data.trackWorkoutId, + userId: session.userId, + }) + if (benchmarkOpenJoin) return benchmarkOpenJoin + return { submissions: [], teamSize: 1, @@ -382,6 +767,8 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) canSubmit: false, reason: "You must be registered for this competition to submit a video", isRegistered: false, + isBenchmarkOpenJoin: false, + videoRequired: true, workout: null, existingScore: null, } @@ -460,6 +847,13 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) const reason = !registration.isCaptain ? "Only the team captain can submit videos and scores" : windowCheck.reason + const benchmarkContext = + windowCheck.competitionType === "benchmark" + ? await getBenchmarkSubmissionContext( + data.competitionId, + data.trackWorkoutId, + ) + : null let existingScore: { scoreValue: number | null @@ -543,6 +937,10 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) canSubmit, reason, isRegistered: true, + isBenchmarkOpenJoin: false, + videoRequired: benchmarkContext + ? benchmarkContext.videoPolicy === "always" + : true, submissionWindow: windowCheck.opensAt && windowCheck.closesAt ? { @@ -550,18 +948,7 @@ export const getVideoSubmissionFn = createServerFn({ method: "GET" }) closesAt: windowCheck.closesAt.toISOString(), } : null, - workout: workout - ? { - workoutId: workout.workoutId, - name: workout.name, - scheme: workout.scheme as WorkoutScheme, - scoreType: workout.scoreType as ScoreType | null, - timeCap: workout.timeCap, - tiebreakScheme: workout.tiebreakScheme, - repsPerRound: workout.repsPerRound, - roundsToScore: workout.roundsToScore, - } - : null, + workout: toSubmissionWorkout(workout), existingScore, } }) @@ -722,6 +1109,8 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ canSubmit: false, reason: "Not authenticated", isRegistered: false, + isBenchmarkOpenJoin: false, + videoRequired: true, workout: null, existingScore: null, } @@ -741,7 +1130,12 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ if (!registration) { const results: Record = {} for (const twId of data.trackWorkoutIds) { - results[twId] = { + const benchmarkOpenJoin = await getBenchmarkOpenJoinSubmissionState({ + competitionId: data.competitionId, + trackWorkoutId: twId, + userId: session.userId, + }) + results[twId] = benchmarkOpenJoin ?? { submissions: [], teamSize: 1, isCaptain: true, @@ -749,6 +1143,8 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ reason: "You must be registered for this competition to submit a video", isRegistered: false, + isBenchmarkOpenJoin: false, + videoRequired: true, workout: null, existingScore: null, } @@ -968,6 +1364,10 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ const workout = workoutMap.get(twId) ?? null const score = scoreMap.get(twId) + const benchmarkContext = + competition?.competitionType === "benchmark" + ? await getBenchmarkSubmissionContext(data.competitionId, twId) + : null let existingScore: { scoreValue: number | null @@ -1037,6 +1437,10 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ canSubmit, reason, isRegistered: true, + isBenchmarkOpenJoin: false, + videoRequired: benchmarkContext + ? benchmarkContext.videoPolicy === "always" + : true, submissionWindow: opensAt && closesAt ? { @@ -1044,18 +1448,7 @@ export const getBatchEventVideoSubmissionsFn = createServerFn({ closesAt: closesAt.toISOString(), } : null, - workout: workout - ? { - workoutId: workout.workoutId, - name: workout.name, - scheme: workout.scheme as WorkoutScheme, - scoreType: workout.scoreType as ScoreType | null, - timeCap: workout.timeCap, - tiebreakScheme: workout.tiebreakScheme, - repsPerRound: workout.repsPerRound, - roundsToScore: workout.roundsToScore, - } - : null, + workout: toSubmissionWorkout(workout), existingScore, } } @@ -1291,35 +1684,64 @@ export const submitVideoFn = createServerFn({ method: "POST" }) const db = getDb() - // Check if user is registered for this competition (scoped to division when provided) - const registration = await getAthleteRegistration( + let registration = await getAthleteRegistration( data.competitionId, session.userId, data.divisionId, ) + let benchmarkContext: BenchmarkSubmissionContext | null = null + let benchmarkVariant: BenchmarkVariant | null = null + + if (!registration) { + benchmarkContext = await getBenchmarkSubmissionContext( + data.competitionId, + data.trackWorkoutId, + ) + + if (benchmarkContext) { + benchmarkVariant = await getBenchmarkProfileVariant(session.userId) + registration = await ensureBenchmarkOpenJoinRegistration({ + context: benchmarkContext, + userId: session.userId, + }) + } + } + if (!registration) { throw new Error( "You must be registered for this competition to submit a video", ) } - // Only team captains can submit for team divisions if (!registration.isCaptain) { throw new Error("Only the team captain can submit videos and scores") } - // Submission window and team size are independent context lookups — run - // them in parallel (each helper opens its own DB connection internally). - const [windowCheck, teamSize] = await Promise.all([ - checkSubmissionWindow(data.competitionId, data.trackWorkoutId), - getTeamSize(registration.divisionId), - ]) + const windowCheck = + benchmarkContext === null + ? await checkSubmissionWindow(data.competitionId, data.trackWorkoutId) + : { allowed: true, competitionType: "benchmark" as const } if (!windowCheck.allowed) { throw new Error(windowCheck.reason ?? "Cannot submit video at this time") } + if (windowCheck.competitionType === "benchmark" && !benchmarkContext) { + benchmarkContext = await getBenchmarkSubmissionContext( + data.competitionId, + data.trackWorkoutId, + ) + if (!benchmarkContext) { + throw new Error( + "Benchmark competition is missing benchmark configuration", + ) + } + benchmarkVariant = await getBenchmarkProfileVariant(session.userId) + } + + const teamSize = await getTeamSize(registration.divisionId) + // Validate videoIndex against team size if (data.videoIndex >= teamSize) { throw new Error( @@ -1327,6 +1749,14 @@ export const submitVideoFn = createServerFn({ method: "POST" }) ) } + if (benchmarkContext && teamSize !== 1) { + throw new Error("Benchmark submissions are individual-only") + } + + if (!benchmarkContext && !data.videoUrl) { + throw new Error("A video URL is required when submitting") + } + // Check for existing video submission at this index const [existingSubmission] = await db .select({ id: videoSubmissionsTable.id }) @@ -1342,6 +1772,24 @@ export const submitVideoFn = createServerFn({ method: "POST" }) const now = new Date() + if (benchmarkContext && benchmarkVariant) { + return submitBenchmarkVideoScore({ + db, + data, + registration, + context: benchmarkContext, + variant: benchmarkVariant, + existingSubmission, + userId: session.userId, + now, + }) + } + + const submissionVideoUrl = data.videoUrl + if (!submissionVideoUrl) { + throw new Error("A video URL is required when submitting") + } + // Validate score is present before saving anything. // For team divisions the captain submits all videos in one form action; // the score is only sent with the first slot (videoIndex 0) and shared @@ -1379,7 +1827,7 @@ export const submitVideoFn = createServerFn({ method: "POST" }) trackWorkoutId: data.trackWorkoutId, videoIndex: data.videoIndex, userId: session.userId, - videoUrl: data.videoUrl, + videoUrl: submissionVideoUrl, notes: data.notes ?? null, submittedAt: now, }) diff --git a/apps/wodsmith-start/src/server/benchmark-open-join-rate-limit.ts b/apps/wodsmith-start/src/server/benchmark-open-join-rate-limit.ts new file mode 100644 index 000000000..e7a282313 --- /dev/null +++ b/apps/wodsmith-start/src/server/benchmark-open-join-rate-limit.ts @@ -0,0 +1,44 @@ +interface BenchmarkOpenJoinRateLimitInput { + userId: string + competitionId: string + now?: Date +} + +interface BenchmarkOpenJoinRateLimitResult { + allowed: boolean + retryAfterSeconds?: number +} + +const WINDOW_MS = 60_000 +const MAX_ATTEMPTS = 10 + +const attemptsByKey = new Map() + +export async function checkBenchmarkOpenJoinRateLimit({ + userId, + competitionId, + now = new Date(), +}: BenchmarkOpenJoinRateLimitInput): Promise { + const key = `${competitionId}:${userId}` + const nowMs = now.getTime() + const current = attemptsByKey.get(key) + + if (!current || current.resetAt <= nowMs) { + attemptsByKey.set(key, { count: 1, resetAt: nowMs + WINDOW_MS }) + return { allowed: true } + } + + if (current.count >= MAX_ATTEMPTS) { + return { + allowed: false, + retryAfterSeconds: Math.ceil((current.resetAt - nowMs) / 1000), + } + } + + current.count += 1 + return { allowed: true } +} + +export function resetBenchmarkOpenJoinRateLimitForTests() { + attemptsByKey.clear() +} diff --git a/apps/wodsmith-start/src/server/benchmark-submissions.ts b/apps/wodsmith-start/src/server/benchmark-submissions.ts new file mode 100644 index 000000000..21e106258 --- /dev/null +++ b/apps/wodsmith-start/src/server/benchmark-submissions.ts @@ -0,0 +1,712 @@ +import { and, asc, eq, inArray, ne } from "drizzle-orm" +import { type Database, getDb } from "@/db" +import { + benchmarkBatteriesTable, + benchmarkTestsTable, + benchmarkTierThresholdsTable, +} from "@/db/schemas/benchmarks" +import { + createCompetitionRegistrationId, + createTeamMembershipId, +} from "@/db/schemas/common" +import { + competitionRegistrationsTable, + competitionsTable, + REGISTRATION_STATUS, +} from "@/db/schemas/competitions" +import { trackWorkoutsTable } from "@/db/schemas/programming" +import { scalingLevelsTable } from "@/db/schemas/scaling" +import { scoresTable } from "@/db/schemas/scores" +import { SYSTEM_ROLES_ENUM, teamMembershipTable } from "@/db/schemas/teams" +import { userTable } from "@/db/schemas/users" +import { videoSubmissionsTable } from "@/db/schemas/video-submissions" +import { waiverSignaturesTable, waiversTable } from "@/db/schemas/waivers" +import type { TiebreakScheme } from "@/db/schemas/workouts" +import { + type AbsoluteTierEventTable, + BenchmarkConfigError, + calculateAbsoluteTier, +} from "@/lib/scoring/algorithms" +import { getSortDirection } from "@/lib/scoring/sort/direction" +import type { ScoreType, WorkoutScheme } from "@/lib/scoring/types" +import { + benchmarkVariantSchema, + type BenchmarkVariant, + type BenchmarkVideoPolicy, +} from "@/schemas/benchmark.schema" +import { parseCompetitionSettings } from "@/utils/competition-settings" +import { checkBenchmarkOpenJoinRateLimit } from "./benchmark-open-join-rate-limit" + +export interface BenchmarkSubmissionContext { + batteryId: string + competitionId: string + competitionTeamId: string + openDivisionId: string + openDivisionTeamSize: number + videoPolicy: BenchmarkVideoPolicy + isOpenJoin: boolean + testId: string + competitionStatus: "draft" | "published" | string + competitionVisibility: "public" | "private" | string + batteryStatus: "draft" | "published" | "archived" | string +} + +export interface BenchmarkRegistration { + id: string + divisionId: string | null + captainUserId: string | null + athleteTeamId: string | null + isCaptain: boolean +} + +export interface BenchmarkScoreWriteInput { + userId: string + teamId: string + workoutId: string + competitionEventId: string + scheme: WorkoutScheme + scoreType: ScoreType + scoreValue: number | null + status: "scored" | "cap" + statusOrder: number + sortKey: string | null + tiebreakScheme: TiebreakScheme | null + tiebreakValue: number | null + timeCapMs: number | null + secondaryValue: number | null + recordedAt: Date +} + +export interface BenchmarkScoreWriteResult { + written: boolean + retainedCurrentBest: boolean + scoreId?: string + candidateTier: number + existingTier?: number +} + +interface ExistingBenchmarkScore { + id: string + scoreValue: number | null + status: string + benchmarkVariant: string | null + verificationStatus: string | null +} + +type BenchmarkScoreDb = Pick + +export async function isBenchmarkCompetition( + competitionId: string, +): Promise { + const db = getDb() + const [battery] = await db + .select({ id: benchmarkBatteriesTable.id }) + .from(benchmarkBatteriesTable) + .where(eq(benchmarkBatteriesTable.competitionId, competitionId)) + .limit(1) + + if (battery) return true + + const [competition] = await db + .select({ competitionType: competitionsTable.competitionType }) + .from(competitionsTable) + .where(eq(competitionsTable.id, competitionId)) + .limit(1) + + return competition?.competitionType === "benchmark" +} + +export async function getBenchmarkSubmissionContext( + competitionId: string, + trackWorkoutId: string, +): Promise { + const db = getDb() + const [battery] = await db + .select({ + batteryId: benchmarkBatteriesTable.id, + videoPolicy: benchmarkBatteriesTable.videoPolicy, + isOpenJoin: benchmarkBatteriesTable.isOpenJoin, + batteryStatus: benchmarkBatteriesTable.status, + competitionTeamId: competitionsTable.competitionTeamId, + competitionStatus: competitionsTable.status, + competitionVisibility: competitionsTable.visibility, + competitionSettings: competitionsTable.settings, + }) + .from(benchmarkBatteriesTable) + .innerJoin( + competitionsTable, + eq(benchmarkBatteriesTable.competitionId, competitionsTable.id), + ) + .where(eq(benchmarkBatteriesTable.competitionId, competitionId)) + .limit(1) + + if (!battery) return null + + const settings = parseCompetitionSettings(battery.competitionSettings) + const scalingGroupId = settings?.divisions?.scalingGroupId + if (!scalingGroupId) { + throw new BenchmarkConfigError( + "Benchmark competition is missing an Open division scaling group", + ) + } + + const openDivisions = await db + .select({ + id: scalingLevelsTable.id, + teamSize: scalingLevelsTable.teamSize, + }) + .from(scalingLevelsTable) + .where(eq(scalingLevelsTable.scalingGroupId, scalingGroupId)) + .orderBy(asc(scalingLevelsTable.position)) + + if (openDivisions.length !== 1) { + throw new BenchmarkConfigError( + "Benchmark competitions must have exactly one Open division", + ) + } + + const [test] = await db + .select({ + id: benchmarkTestsTable.id, + includedInScoring: benchmarkTestsTable.includedInScoring, + }) + .from(trackWorkoutsTable) + .innerJoin( + benchmarkTestsTable, + eq(trackWorkoutsTable.benchmarkTestId, benchmarkTestsTable.id), + ) + .where( + and( + eq(trackWorkoutsTable.id, trackWorkoutId), + eq(benchmarkTestsTable.batteryId, battery.batteryId), + ), + ) + .limit(1) + + if (!test) { + throw new BenchmarkConfigError( + "Benchmark event is missing its source benchmark test", + ) + } + + if (!test.includedInScoring) { + throw new BenchmarkConfigError( + "Deferred benchmark tests cannot receive scored submissions", + ) + } + + return { + batteryId: battery.batteryId, + competitionId, + competitionTeamId: battery.competitionTeamId, + openDivisionId: openDivisions[0].id, + openDivisionTeamSize: openDivisions[0].teamSize, + videoPolicy: battery.videoPolicy, + isOpenJoin: battery.isOpenJoin, + testId: test.id, + competitionStatus: battery.competitionStatus, + competitionVisibility: battery.competitionVisibility, + batteryStatus: battery.batteryStatus, + } +} + +export async function getBenchmarkProfileVariant( + userId: string, +): Promise { + const db = getDb() + const [user] = await db + .select({ gender: userTable.gender }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1) + + const parsed = benchmarkVariantSchema.safeParse(user?.gender) + if (!parsed.success) { + throw new Error("Complete your athlete profile gender before submitting") + } + + return parsed.data +} + +export async function ensureBenchmarkOpenJoinRegistration({ + context, + userId, + now = new Date(), +}: { + context: BenchmarkSubmissionContext + userId: string + now?: Date +}): Promise { + if (!context.isOpenJoin) { + throw new Error( + "You must be registered for this benchmark before submitting", + ) + } + + assertBenchmarkBoardOpenForSubmissions(context) + + if (context.openDivisionTeamSize !== 1) { + throw new BenchmarkConfigError( + "Benchmark submissions only support individual Open divisions", + ) + } + + const rateLimit = await checkBenchmarkOpenJoinRateLimit({ + userId, + competitionId: context.competitionId, + now, + }) + if (!rateLimit.allowed) { + throw new Error("Too many benchmark join attempts. Please try again later.") + } + + await assertRequiredWaiversSigned({ + competitionId: context.competitionId, + userId, + }) + + const db = getDb() + const registration = await db.transaction(async (tx) => { + const [existingRegistration] = await tx + .select({ + id: competitionRegistrationsTable.id, + divisionId: competitionRegistrationsTable.divisionId, + captainUserId: competitionRegistrationsTable.captainUserId, + athleteTeamId: competitionRegistrationsTable.athleteTeamId, + }) + .from(competitionRegistrationsTable) + .where( + and( + eq(competitionRegistrationsTable.eventId, context.competitionId), + eq(competitionRegistrationsTable.userId, userId), + eq(competitionRegistrationsTable.divisionId, context.openDivisionId), + ne(competitionRegistrationsTable.status, REGISTRATION_STATUS.REMOVED), + ), + ) + .limit(1) + + if (existingRegistration) { + return { ...existingRegistration, isCaptain: true } + } + + const [existingMembership] = await tx + .select({ id: teamMembershipTable.id }) + .from(teamMembershipTable) + .where( + and( + eq(teamMembershipTable.teamId, context.competitionTeamId), + eq(teamMembershipTable.userId, userId), + eq(teamMembershipTable.isActive, true), + ), + ) + .limit(1) + + const teamMemberId = existingMembership?.id ?? createTeamMembershipId() + if (!existingMembership) { + await tx.insert(teamMembershipTable).values({ + id: teamMemberId, + teamId: context.competitionTeamId, + userId, + roleId: SYSTEM_ROLES_ENUM.MEMBER, + isSystemRole: true, + joinedAt: now, + isActive: true, + }) + } + + await tx + .insert(competitionRegistrationsTable) + .values({ + id: createCompetitionRegistrationId(), + eventId: context.competitionId, + userId, + teamMemberId, + divisionId: context.openDivisionId, + registeredAt: now, + captainUserId: userId, + athleteTeamId: null, + metadata: JSON.stringify({ benchmarkOpenJoin: true }), + }) + .onDuplicateKeyUpdate({ set: { updatedAt: now } }) + + const [createdRegistration] = await tx + .select({ + id: competitionRegistrationsTable.id, + divisionId: competitionRegistrationsTable.divisionId, + captainUserId: competitionRegistrationsTable.captainUserId, + athleteTeamId: competitionRegistrationsTable.athleteTeamId, + }) + .from(competitionRegistrationsTable) + .where( + and( + eq(competitionRegistrationsTable.eventId, context.competitionId), + eq(competitionRegistrationsTable.userId, userId), + eq(competitionRegistrationsTable.divisionId, context.openDivisionId), + ne(competitionRegistrationsTable.status, REGISTRATION_STATUS.REMOVED), + ), + ) + .limit(1) + + if (!createdRegistration) { + throw new Error("Failed to join benchmark board") + } + + return { ...createdRegistration, isCaptain: true } + }) + + return registration +} + +export async function saveBenchmarkScore({ + context, + variant, + score, + now = new Date(), +}: { + context: BenchmarkSubmissionContext + variant: BenchmarkVariant + score: BenchmarkScoreWriteInput + now?: Date +}): Promise { + const db = getDb() + return db.transaction((tx) => + saveBenchmarkScoreInTransaction({ + db: tx, + context, + variant, + score, + now, + }), + ) +} + +export async function saveBenchmarkScoreInTransaction({ + db, + context, + variant, + score, + now = new Date(), +}: { + db: BenchmarkScoreDb + context: BenchmarkSubmissionContext + variant: BenchmarkVariant + score: BenchmarkScoreWriteInput + now?: Date +}): Promise { + if (score.scoreValue === null) { + throw new Error("A score is required when submitting") + } + const candidateValue = score.scoreValue + + assertBenchmarkBoardOpenForSubmissions(context) + + const table = await loadAbsoluteTierTable(context.testId, score.scoreType) + const candidateTier = calculateAbsoluteTier( + { + userId: score.userId, + value: candidateValue, + status: score.status, + variant, + }, + table, + score.scheme, + ) + + const [existingScore] = await db + .select({ + id: scoresTable.id, + scoreValue: scoresTable.scoreValue, + status: scoresTable.status, + benchmarkVariant: scoresTable.benchmarkVariant, + verificationStatus: scoresTable.verificationStatus, + }) + .from(scoresTable) + .where( + and( + eq(scoresTable.competitionEventId, score.competitionEventId), + eq(scoresTable.userId, score.userId), + eq(scoresTable.scalingLevelId, context.openDivisionId), + ), + ) + .for("update") + .limit(1) + + const existingTier = existingScore + ? calculateExistingTier({ + existingScore, + table, + scheme: score.scheme, + }) + : undefined + + if ( + existingScore && + existingTier !== undefined && + !doesBenchmarkScoreImprove({ + candidateTier, + candidateValue, + existingTier, + existingValue: existingScore.scoreValue, + scheme: score.scheme, + scoreType: score.scoreType, + }) + ) { + return { + written: false, + retainedCurrentBest: true, + scoreId: existingScore.id, + candidateTier, + existingTier, + } + } + + const scoreValues = { + userId: score.userId, + teamId: score.teamId, + workoutId: score.workoutId, + competitionEventId: score.competitionEventId, + scheme: score.scheme, + scoreType: score.scoreType, + scoreValue: candidateValue, + status: score.status, + statusOrder: score.statusOrder, + sortKey: score.sortKey, + tiebreakScheme: score.tiebreakScheme, + tiebreakValue: score.tiebreakValue, + timeCapMs: score.timeCapMs, + secondaryValue: score.secondaryValue, + scalingLevelId: context.openDivisionId, + benchmarkVariant: variant, + asRx: true, + recordedAt: score.recordedAt, + } + + if (existingScore) { + await db + .update(scoresTable) + .set({ + ...scoreValues, + verificationStatus: null, + verifiedAt: null, + verifiedByUserId: null, + penaltyType: null, + penaltyPercentage: null, + noRepCount: null, + updatedAt: now, + }) + .where(eq(scoresTable.id, existingScore.id)) + + return { + written: true, + retainedCurrentBest: false, + scoreId: existingScore.id, + candidateTier, + existingTier, + } + } + + await db.insert(scoresTable).values(scoreValues) + + return { + written: true, + retainedCurrentBest: false, + candidateTier, + } +} + +export async function isBenchmarkVideoEvidenceRequired({ + context, + variant, + score, +}: { + context: BenchmarkSubmissionContext + variant: BenchmarkVariant + score: BenchmarkScoreWriteInput +}): Promise { + if (context.videoPolicy === "never") return false + if (context.videoPolicy === "always") return true + + if (score.scoreValue === null) { + throw new Error("A score is required when submitting") + } + + const table = await loadAbsoluteTierTable(context.testId, score.scoreType) + const candidateTier = calculateAbsoluteTier( + { + userId: score.userId, + value: score.scoreValue, + status: score.status, + variant, + }, + table, + score.scheme, + ) + const thresholds = table.thresholdsByVariant.get(variant) + const topTier = Math.max(...(thresholds ?? []).map((row) => row.tier)) + + return candidateTier >= topTier +} + +export async function resetBenchmarkVideoReviewState( + submissionId: string, + now = new Date(), + db: Pick = getDb(), +): Promise { + await db + .update(videoSubmissionsTable) + .set({ + reviewStatus: "pending", + statusUpdatedAt: now, + reviewerNotes: null, + reviewedAt: null, + reviewedBy: null, + updatedAt: now, + }) + .where(eq(videoSubmissionsTable.id, submissionId)) +} + +function assertBenchmarkBoardOpenForSubmissions( + context: BenchmarkSubmissionContext, +) { + if ( + context.batteryStatus !== "published" || + context.competitionStatus !== "published" || + context.competitionVisibility !== "public" + ) { + throw new Error("This benchmark board is not open for submissions") + } +} + +async function assertRequiredWaiversSigned({ + competitionId, + userId, +}: { + competitionId: string + userId: string +}) { + const db = getDb() + const requiredWaivers = await db + .select({ id: waiversTable.id }) + .from(waiversTable) + .where( + and( + eq(waiversTable.competitionId, competitionId), + eq(waiversTable.required, true), + ), + ) + + if (requiredWaivers.length === 0) return + + const waiverIds = requiredWaivers.map((waiver) => waiver.id) + const signedWaivers = await db + .select({ waiverId: waiverSignaturesTable.waiverId }) + .from(waiverSignaturesTable) + .where( + and( + eq(waiverSignaturesTable.userId, userId), + inArray(waiverSignaturesTable.waiverId, waiverIds), + ), + ) + + const signedIds = new Set( + signedWaivers.map((signature) => signature.waiverId), + ) + if (waiverIds.some((waiverId) => !signedIds.has(waiverId))) { + throw new Error( + "Please sign required waivers before joining this benchmark", + ) + } +} + +async function loadAbsoluteTierTable( + testId: string, + scoreType: ScoreType, +): Promise { + const db = getDb() + const thresholdRows = await db + .select({ + variant: benchmarkTierThresholdsTable.variant, + tier: benchmarkTierThresholdsTable.tier, + value: benchmarkTierThresholdsTable.thresholdValue, + }) + .from(benchmarkTierThresholdsTable) + .where(eq(benchmarkTierThresholdsTable.testId, testId)) + .orderBy(asc(benchmarkTierThresholdsTable.tier)) + + if (thresholdRows.length === 0) { + throw new BenchmarkConfigError( + `Missing benchmark thresholds for test ${testId}`, + ) + } + + const thresholdsByVariant = new Map< + string, + { tier: number; value: number }[] + >() + for (const row of thresholdRows) { + const rows = thresholdsByVariant.get(row.variant) ?? [] + rows.push({ tier: row.tier, value: row.value }) + thresholdsByVariant.set(row.variant, rows) + } + + return { + scoreType, + thresholdsByVariant, + } +} + +function calculateExistingTier({ + existingScore, + table, + scheme, +}: { + existingScore: ExistingBenchmarkScore + table: AbsoluteTierEventTable + scheme: WorkoutScheme +}): number { + if ( + existingScore.verificationStatus === "invalid" || + existingScore.scoreValue === null || + !existingScore.benchmarkVariant + ) { + return 0 + } + + return calculateAbsoluteTier( + { + userId: "existing", + value: existingScore.scoreValue, + status: existingScore.status === "cap" ? "cap" : "scored", + variant: existingScore.benchmarkVariant, + }, + table, + scheme, + ) +} + +function doesBenchmarkScoreImprove({ + candidateTier, + candidateValue, + existingTier, + existingValue, + scheme, + scoreType, +}: { + candidateTier: number + candidateValue: number + existingTier: number + existingValue: number | null + scheme: WorkoutScheme + scoreType: ScoreType +}) { + if (candidateTier !== existingTier) { + return candidateTier > existingTier + } + + if (existingValue === null) return true + + const sortDirection = getSortDirection(scheme, scoreType) + return sortDirection === "asc" + ? candidateValue < existingValue + : candidateValue > existingValue +} diff --git a/apps/wodsmith-start/test/components/video-submission-form.test.tsx b/apps/wodsmith-start/test/components/video-submission-form.test.tsx index ac0470349..2b27941e0 100644 --- a/apps/wodsmith-start/test/components/video-submission-form.test.tsx +++ b/apps/wodsmith-start/test/components/video-submission-form.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { describe, expect, it, vi, beforeEach } from "vitest" import type { ReviewStatus } from "@/db/schemas/video-submissions" import type { ScoreType, WorkoutScheme } from "@/lib/scoring" @@ -123,6 +123,7 @@ vi.mock("@/components/ui/video-url-input", () => ({ })) import { VideoSubmissionForm } from "@/components/compete/video-submission-form" +import { submitVideoFn } from "@/server-fns/video-submission-fns" // ── Factories ────────────────────────────────────────────────────────────── @@ -170,7 +171,9 @@ function createInitialData(overrides?: Partial<{ repsPerRound: number | null roundsToScore: number | null } | null - existingScore: null + existingScore: null + isBenchmarkOpenJoin: boolean + videoRequired: boolean }>) { return { submissions: [], @@ -179,6 +182,8 @@ function createInitialData(overrides?: Partial<{ canSubmit: true, reason: undefined, isRegistered: true, + isBenchmarkOpenJoin: false, + videoRequired: true, submissionWindow: null, workout: null, existingScore: null, @@ -286,6 +291,56 @@ describe("VideoSubmissionForm", () => { ), ).toBeTruthy() }) + + it("lets benchmark open-join athletes submit score-only when video is optional", async () => { + vi.mocked(submitVideoFn).mockResolvedValue({ + success: true, + submissionId: undefined, + isUpdate: false, + retainedCurrentBest: false, + }) + + render( + , + ) + + expect(screen.queryByText("Registration Required")).toBeNull() + + fireEvent.change(screen.getByLabelText("Your Reps"), { + target: { value: "20" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Submit result" })) + + await waitFor(() => { + expect(submitVideoFn).toHaveBeenCalledWith({ + data: expect.objectContaining({ + trackWorkoutId: "tw-1", + competitionId: "comp-1", + videoUrl: undefined, + score: "20", + }), + }) + }) + }) }) describe("non-captain team member view", () => { diff --git a/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts b/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts index 0e0588cec..b0f12027a 100644 --- a/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts +++ b/apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts @@ -80,22 +80,8 @@ describe("submission API capability gates", () => { mockAuthSession.mockResolvedValue({ userId: "user-1" }) }) - it("allows benchmark score submission without submission-window rows", async () => { - // @lat: [[competition-type-capabilities#Perpetual Submission Gate Test]] - mockLimit - .mockResolvedValueOnce([{ id: "reg-1", divisionId: "open" }]) - .mockResolvedValueOnce([{ competitionType: "benchmark" }]) - .mockResolvedValueOnce([{ workoutId: "workout-1", trackId: "track-1" }]) - .mockResolvedValueOnce([ - { - scheme: "time", - scoreType: "min", - tiebreakScheme: null, - timeCap: null, - }, - ]) - .mockResolvedValueOnce([{ ownerTeamId: "team-1" }]) - .mockResolvedValueOnce([{ id: "score-1" }]) + it("rejects direct benchmark score API writes so they cannot bypass benchmark rules", async () => { + mockLimit.mockResolvedValueOnce([{ id: "benchmark-battery-1" }]) const response = await scoreSubmitRoute.server.handlers.POST({ request: postRequest("/api/compete/scores/submit", { @@ -111,16 +97,41 @@ describe("submission API capability gates", () => { error?: string } - expect(response.status).toBe(200) - expect(data).toMatchObject({ success: true, scoreId: "score-1" }) - expect(data.error).toBeUndefined() + expect(response.status).toBe(422) + expect(data.success).not.toBe(true) + expect(data.scoreId).toBeUndefined() + expect(data.error).toEqual(expect.any(String)) + expect(mockDb.insert).not.toHaveBeenCalled() }) - it("allows benchmark video submission without submission-window rows", async () => { + it("rejects direct score API writes for malformed benchmark competitions without a battery", async () => { mockLimit - .mockResolvedValueOnce([{ id: "reg-1", divisionId: "open" }]) - .mockResolvedValueOnce([{ competitionType: "benchmark" }]) .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ competitionType: "benchmark" }]) + + const response = await scoreSubmitRoute.server.handlers.POST({ + request: postRequest("/api/compete/scores/submit", { + competitionId: "comp-1", + trackWorkoutId: "tw-1", + score: "10:00", + status: "scored", + }), + }) + const data = (await response.json()) as { + success?: boolean + scoreId?: string + error?: string + } + + expect(response.status).toBe(422) + expect(data.success).not.toBe(true) + expect(data.scoreId).toBeUndefined() + expect(data.error).toEqual(expect.any(String)) + expect(mockDb.insert).not.toHaveBeenCalled() + }) + + it("rejects direct benchmark video API writes so they cannot bypass benchmark rules", async () => { + mockLimit.mockResolvedValueOnce([{ id: "benchmark-battery-1" }]) const response = await videoSubmitRoute.server.handlers.POST({ request: postRequest("/api/compete/video/submit", { @@ -135,9 +146,10 @@ describe("submission API capability gates", () => { error?: string } - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.submissionId).toEqual(expect.any(String)) - expect(data.error).toBeUndefined() + expect(response.status).toBe(422) + expect(data.success).not.toBe(true) + expect(data.submissionId).toBeUndefined() + expect(data.error).toEqual(expect.any(String)) + expect(mockDb.insert).not.toHaveBeenCalled() }) }) diff --git a/apps/wodsmith-start/test/server-fns/benchmark-submission-m3.test.ts b/apps/wodsmith-start/test/server-fns/benchmark-submission-m3.test.ts new file mode 100644 index 000000000..404d8c934 --- /dev/null +++ b/apps/wodsmith-start/test/server-fns/benchmark-submission-m3.test.ts @@ -0,0 +1,838 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + benchmarkBatteriesTable, + benchmarkTestsTable, + benchmarkTierThresholdsTable, +} from "@/db/schemas/benchmarks" +import { + competitionEventsTable, + competitionRegistrationsTable, + competitionsTable, +} from "@/db/schemas/competitions" +import { + programmingTracksTable, + trackWorkoutsTable, +} from "@/db/schemas/programming" +import { scalingLevelsTable } from "@/db/schemas/scaling" +import { scoresTable } from "@/db/schemas/scores" +import { teamMembershipTable } from "@/db/schemas/teams" +import { userTable } from "@/db/schemas/users" +import { videoSubmissionsTable } from "@/db/schemas/video-submissions" +import { waiverSignaturesTable, waiversTable } from "@/db/schemas/waivers" +import { workouts } from "@/db/schemas/workouts" +import { + checkBenchmarkOpenJoinRateLimit, + resetBenchmarkOpenJoinRateLimitForTests, +} from "@/server/benchmark-open-join-rate-limit" +import { submitVideoFn } from "@/server-fns/video-submission-fns" + +type BenchmarkGender = "male" | "female" + +type ScoreRow = { + id: string + scoreValue: number | null + status: string + scheme: string + scoreType: string + secondaryValue: number | null + tiebreakValue: number | null + verificationStatus: string | null + verifiedAt: Date | null + verifiedByUserId: string | null + penaltyType: string | null + penaltyPercentage: number | null + noRepCount: number | null + benchmarkVariant: BenchmarkGender | null + scalingLevelId: string | null +} + +type VideoSubmissionRow = { + id: string + reviewStatus: string + reviewedAt: Date | null + reviewedBy: string | null + reviewerNotes: string | null +} + +type BenchmarkFixture = { + profileGender: BenchmarkGender | null + hasRegistration: boolean + registrationDivisionId: string + openDivisionId: string + divisionTeamSize: number + hasBattery: boolean + videoPolicy: "never" | "always" | "for_top_scores" + batteryStatus: "draft" | "published" | "archived" + competitionStatus: "draft" | "published" + competitionVisibility: "public" | "private" + isOpenJoin: boolean + requiredWaiverIds: string[] + signedWaiverIds: string[] + existingScore: ScoreRow | null + existingSubmission: VideoSubmissionRow | null +} + +type MutationLog = { + scoreInserts: Record[] + scoreDuplicateUpdates: Record[] + scoreUpdateSets: Record[] + videoInserts: Record[] + videoUpdateSets: Record[] + registrationInserts: Record[] + teamMembershipInserts: Record[] +} + +const mockAuthenticatedSession = { + userId: "athlete-1", + user: { + id: "athlete-1", + email: "athlete@example.com", + }, + teams: [], +} + +class QueryChain { + private kind: "select" | "insert" | "update" | "delete" = "select" + private fromTable: unknown + private mutationTable: unknown + private selection: Record | undefined + + constructor(private readonly db: BenchmarkSubmissionDb) {} + + select = vi.fn((selection?: Record) => { + this.kind = "select" + this.selection = selection + return this + }) + + from = vi.fn((table: unknown) => { + this.fromTable = table + return this + }) + + innerJoin = vi.fn(() => this) + leftJoin = vi.fn(() => this) + where = vi.fn(() => this) + orderBy = vi.fn(() => this) + groupBy = vi.fn(() => this) + for = vi.fn(() => this) + + limit = vi.fn(async () => this.resolve()) + + insert = vi.fn((table: unknown) => { + this.kind = "insert" + this.mutationTable = table + return this + }) + + values = vi.fn((values: Record | Record[]) => { + this.db.recordValues(this.mutationTable, values) + return this + }) + + onDuplicateKeyUpdate = vi.fn((config: { set?: Record }) => { + this.db.recordDuplicateUpdate(this.mutationTable, config) + return this + }) + + returning = vi.fn(async () => this.db.returningRows(this.mutationTable)) + + update = vi.fn((table: unknown) => { + this.kind = "update" + this.mutationTable = table + return this + }) + + set = vi.fn((values: Record) => { + this.db.recordSet(this.mutationTable, values) + return this + }) + + delete = vi.fn((table?: unknown) => { + this.kind = "delete" + this.mutationTable = table + return this + }) + + then( + onfulfilled?: + | ((value: unknown[]) => TResult1 | PromiseLike) + | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return Promise.resolve(this.resolve()).then(onfulfilled, onrejected) + } + + private resolve() { + if (this.kind !== "select") return [] + return this.db.rowsFor(this.fromTable, this.selection) + } +} + +class BenchmarkSubmissionDb { + fixture = createFixture() + mutations: MutationLog = createMutationLog() + private createdOpenJoinRegistration = false + + select = vi.fn((selection?: Record) => + new QueryChain(this).select(selection), + ) + + insert = vi.fn((table: unknown) => new QueryChain(this).insert(table)) + update = vi.fn((table: unknown) => new QueryChain(this).update(table)) + delete = vi.fn((table?: unknown) => new QueryChain(this).delete(table)) + transaction = vi.fn(async (fn: (tx: BenchmarkSubmissionDb) => unknown) => + fn(this), + ) + + reset(overrides: Partial = {}) { + this.fixture = createFixture(overrides) + this.mutations = createMutationLog() + this.createdOpenJoinRegistration = false + } + + rowsFor(table: unknown, selection?: Record) { + if (table === competitionRegistrationsTable) { + if (!this.fixture.hasRegistration && !this.createdOpenJoinRegistration) { + return [] + } + + const divisionId = this.createdOpenJoinRegistration + ? this.fixture.openDivisionId + : this.fixture.registrationDivisionId + + return [ + { + id: "registration-1", + divisionId, + captainUserId: null, + athleteTeamId: null, + }, + ] + } + + if (table === competitionsTable) { + return [ + { + id: "competition-benchmark", + competitionType: "benchmark", + visibility: "public", + status: "published", + }, + ] + } + + if (table === competitionEventsTable) { + return [] + } + + if (table === scalingLevelsTable) { + if (selection && Object.keys(selection).every((key) => key === "teamSize")) { + return [ + { + id: this.fixture.registrationDivisionId, + teamSize: this.fixture.divisionTeamSize, + }, + ] + } + + return [ + { + id: this.fixture.openDivisionId, + label: "Open", + position: 0, + teamSize: 1, + scalingGroupId: "benchmark-open-scaling-group", + }, + ] + } + + if (table === videoSubmissionsTable) { + if (!this.fixture.existingSubmission) return [] + return [ + { + ...this.fixture.existingSubmission, + registrationId: "registration-1", + trackWorkoutId: "track-workout-benchmark", + videoIndex: 0, + userId: "athlete-1", + videoUrl: "https://example.com/current-video", + notes: null, + }, + ] + } + + if (table === trackWorkoutsTable) { + return [ + { + id: "track-workout-benchmark", + workoutId: "workout-benchmark-test", + name: "Benchmark test", + scheme: "reps", + scoreType: "max", + timeCap: null, + tiebreakScheme: null, + repsPerRound: null, + roundsToScore: 1, + trackId: "programming-track-1", + benchmarkTestId: "benchmark-test-1", + benchmarkCategory: "engine", + includedInScoring: true, + }, + ] + } + + if (table === workouts) { + return [ + { + id: "workout-benchmark-test", + scheme: "reps", + scoreType: "max", + timeCap: null, + tiebreakScheme: null, + }, + ] + } + + if (table === programmingTracksTable) { + return [{ ownerTeamId: "benchmark-owner-team" }] + } + + if (table === userTable) { + return [ + { + id: "athlete-1", + gender: this.fixture.profileGender, + }, + ] + } + + if (table === benchmarkBatteriesTable) { + if (!this.fixture.hasBattery) return [] + + return [ + { + id: "benchmark-battery-1", + batteryId: "benchmark-battery-1", + competitionId: "competition-benchmark", + status: this.fixture.batteryStatus, + batteryStatus: this.fixture.batteryStatus, + videoPolicy: this.fixture.videoPolicy, + isOpenJoin: this.fixture.isOpenJoin, + variantScalingGroupId: "benchmark-open-scaling-group", + competitionTeamId: "competition-team-1", + competitionStatus: this.fixture.competitionStatus, + competitionVisibility: this.fixture.competitionVisibility, + competitionSettings: JSON.stringify({ + divisions: { scalingGroupId: "benchmark-open-scaling-group" }, + }), + }, + ] + } + + if (table === benchmarkTestsTable) { + return [ + { + id: "benchmark-test-1", + batteryId: "benchmark-battery-1", + categoryKey: "engine", + scheme: "reps", + scoreType: "max", + includedInScoring: true, + scoreModel: "standard", + }, + ] + } + + if (table === benchmarkTierThresholdsTable) { + return [ + { + testId: "benchmark-test-1", + variant: "female", + tier: 1, + thresholdValue: 10, + value: 10, + }, + { + testId: "benchmark-test-1", + variant: "female", + tier: 2, + thresholdValue: 20, + value: 20, + }, + { + testId: "benchmark-test-1", + variant: "female", + tier: 3, + thresholdValue: 30, + value: 30, + }, + { + testId: "benchmark-test-1", + variant: "male", + tier: 1, + thresholdValue: 12, + value: 12, + }, + { + testId: "benchmark-test-1", + variant: "male", + tier: 2, + thresholdValue: 24, + value: 24, + }, + { + testId: "benchmark-test-1", + variant: "male", + tier: 3, + thresholdValue: 36, + value: 36, + }, + ] + } + + if (table === scoresTable) { + return this.fixture.existingScore ? [this.fixture.existingScore] : [] + } + + if (table === teamMembershipTable) { + return [] + } + + if (table === waiversTable) { + return this.fixture.requiredWaiverIds.map((id) => ({ id })) + } + + if (table === waiverSignaturesTable) { + return this.fixture.signedWaiverIds.map((waiverId) => ({ waiverId })) + } + + return [] + } + + recordValues( + table: unknown, + values: Record | Record[], + ) { + const rows = Array.isArray(values) ? values : [values] + if (table === scoresTable) this.mutations.scoreInserts.push(...rows) + if (table === videoSubmissionsTable) this.mutations.videoInserts.push(...rows) + if (table === competitionRegistrationsTable) { + this.createdOpenJoinRegistration = true + this.mutations.registrationInserts.push(...rows) + } + if (table === teamMembershipTable) { + this.mutations.teamMembershipInserts.push(...rows) + } + } + + recordSet(table: unknown, values: Record) { + if (table === scoresTable) this.mutations.scoreUpdateSets.push(values) + if (table === videoSubmissionsTable) this.mutations.videoUpdateSets.push(values) + } + + recordDuplicateUpdate( + table: unknown, + config: { set?: Record }, + ) { + if (table === scoresTable) { + this.mutations.scoreDuplicateUpdates.push(config.set ?? {}) + } + } + + returningRows(table: unknown) { + if (table === videoSubmissionsTable) return [{ id: "video-submission-new" }] + if (table === scoresTable) return [{ id: "score-new" }] + return [] + } +} + +const mockDb = new BenchmarkSubmissionDb() + +vi.mock("@/db", () => ({ + getDb: vi.fn(() => mockDb), +})) + +vi.mock("@/utils/auth", () => ({ + getSessionFromCookie: vi.fn(() => Promise.resolve(mockAuthenticatedSession)), +})) + +vi.mock("@tanstack/react-start", () => ({ + createServerFn: () => ({ + inputValidator: (validator: (data: unknown) => unknown) => ({ + handler: (fn: (ctx: { data: unknown }) => Promise) => { + return async (ctx: { data: unknown }) => { + const validatedData = validator(ctx.data) + return fn({ data: validatedData }) + } + }, + }), + }), +})) + +function createFixture( + overrides: Partial = {}, +): BenchmarkFixture { + return { + profileGender: "female", + hasRegistration: true, + registrationDivisionId: "female-variant-division", + openDivisionId: "open-division", + divisionTeamSize: 1, + hasBattery: true, + videoPolicy: "always", + batteryStatus: "published", + competitionStatus: "published", + competitionVisibility: "public", + isOpenJoin: true, + requiredWaiverIds: [], + signedWaiverIds: [], + existingScore: null, + existingSubmission: null, + ...overrides, + } +} + +function createMutationLog(): MutationLog { + return { + scoreInserts: [], + scoreDuplicateUpdates: [], + scoreUpdateSets: [], + videoInserts: [], + videoUpdateSets: [], + registrationInserts: [], + teamMembershipInserts: [], + } +} + +function createVerifiedScore(overrides: Partial = {}): ScoreRow { + return { + id: "score-existing", + scoreValue: 20, + status: "scored", + scheme: "reps", + scoreType: "max", + secondaryValue: null, + tiebreakValue: null, + verificationStatus: "verified", + verifiedAt: new Date("2026-01-01T00:00:00.000Z"), + verifiedByUserId: "judge-1", + penaltyType: "minor", + penaltyPercentage: 10, + noRepCount: 2, + benchmarkVariant: "female", + scalingLevelId: "open-division", + ...overrides, + } +} + +function createReviewedSubmission( + overrides: Partial = {}, +): VideoSubmissionRow { + return { + id: "video-submission-existing", + reviewStatus: "verified", + reviewedAt: new Date("2026-01-01T00:05:00.000Z"), + reviewedBy: "judge-1", + reviewerNotes: "Looks good", + ...overrides, + } +} + +function submissionPayload(overrides: Record = {}) { + return { + trackWorkoutId: "track-workout-benchmark", + competitionId: "competition-benchmark", + divisionId: "female-variant-division", + videoUrl: "https://example.com/benchmark-video", + videoIndex: 0, + score: "30", + scoreStatus: "scored", + ...overrides, + } +} + +function allScoreWritePayloads() { + return [ + ...mockDb.mutations.scoreInserts, + ...mockDb.mutations.scoreDuplicateUpdates, + ...mockDb.mutations.scoreUpdateSets, + ] +} + +describe("benchmark submission M3 regressions", () => { + beforeEach(() => { + vi.clearAllMocks() + resetBenchmarkOpenJoinRateLimitForTests() + mockDb.reset() + }) + + it("rejects benchmark submissions without profile gender instead of defaulting to male", async () => { + mockDb.reset({ profileGender: null }) + + await expect( + submitVideoFn({ data: submissionPayload() }), + ).rejects.toThrow(/gender/i) + + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreDuplicateUpdates).toHaveLength(0) + expect( + allScoreWritePayloads().some( + (payload) => payload.benchmarkVariant === "male", + ), + ).toBe(false) + }) + + it("writes benchmarkVariant from profile gender and stores the single Open division", async () => { + // @lat: [[competition-type-capabilities#Perpetual Submission Gate Test]] + await submitVideoFn({ data: submissionPayload({ score: "32" }) }) + + expect(mockDb.mutations.scoreInserts).toContainEqual( + expect.objectContaining({ + benchmarkVariant: "female", + scalingLevelId: "open-division", + }), + ) + expect(allScoreWritePayloads()).not.toContainEqual( + expect.objectContaining({ + benchmarkVariant: "male", + }), + ) + expect(allScoreWritePayloads()).not.toContainEqual( + expect.objectContaining({ + scalingLevelId: "female-variant-division", + }), + ) + }) + + it("accepts score-only benchmark submissions when the battery video policy is never", async () => { + mockDb.reset({ videoPolicy: "never" }) + + const result = await submitVideoFn({ + data: submissionPayload({ videoUrl: undefined, score: "32" }), + }) + + expect(result).toMatchObject({ + success: true, + retainedCurrentBest: false, + }) + expect(mockDb.mutations.scoreInserts).toContainEqual( + expect.objectContaining({ + benchmarkVariant: "female", + scalingLevelId: "open-division", + }), + ) + expect(mockDb.mutations.videoInserts).toHaveLength(0) + }) + + it("accepts non-top score-only submissions when the battery video policy is for_top_scores", async () => { + mockDb.reset({ videoPolicy: "for_top_scores" }) + + const result = await submitVideoFn({ + data: submissionPayload({ videoUrl: undefined, score: "20" }), + }) + + expect(result).toMatchObject({ + success: true, + retainedCurrentBest: false, + }) + expect(mockDb.mutations.scoreInserts).toContainEqual( + expect.objectContaining({ + scoreValue: 20, + benchmarkVariant: "female", + scalingLevelId: "open-division", + }), + ) + expect(mockDb.mutations.videoInserts).toHaveLength(0) + }) + + it("requires video before writing a top-tier score when the battery video policy is for_top_scores", async () => { + mockDb.reset({ videoPolicy: "for_top_scores" }) + + await expect( + submitVideoFn({ + data: submissionPayload({ videoUrl: undefined, score: "30" }), + }), + ).rejects.toThrow(/video url/i) + + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreUpdateSets).toHaveLength(0) + expect(mockDb.mutations.videoInserts).toHaveLength(0) + }) + + it.each([ + ["equal", "20"], + ["worse", "15"], + ])( + "does not update the live score or reset review state for a retest that is %s", + async (_case, score) => { + mockDb.reset({ + existingScore: createVerifiedScore({ scoreValue: 20 }), + existingSubmission: createReviewedSubmission(), + }) + + await submitVideoFn({ data: submissionPayload({ score }) }) + + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreDuplicateUpdates).toHaveLength(0) + expect(mockDb.mutations.scoreUpdateSets).toHaveLength(0) + expect(mockDb.mutations.videoUpdateSets).not.toContainEqual( + expect.objectContaining({ + reviewStatus: "pending", + reviewedAt: null, + reviewedBy: null, + reviewerNotes: null, + }), + ) + }, + ) + + it("resets stale score verification and video review fields for a better retest", async () => { + mockDb.reset({ + existingScore: createVerifiedScore({ scoreValue: 20 }), + existingSubmission: createReviewedSubmission(), + }) + + await submitVideoFn({ data: submissionPayload({ score: "35" }) }) + + expect(allScoreWritePayloads()).toContainEqual( + expect.objectContaining({ + verificationStatus: null, + verifiedAt: null, + verifiedByUserId: null, + penaltyType: null, + penaltyPercentage: null, + noRepCount: null, + }), + ) + expect(mockDb.mutations.videoUpdateSets).toContainEqual( + expect.objectContaining({ + reviewStatus: "pending", + reviewedAt: null, + reviewedBy: null, + reviewerNotes: null, + }), + ) + }) + + it("rejects team division submissions because benchmark batteries are individual-only", async () => { + mockDb.reset({ + registrationDivisionId: "team-division", + divisionTeamSize: 2, + }) + + await expect( + submitVideoFn({ + data: submissionPayload({ divisionId: "team-division" }), + }), + ).rejects.toThrow(/benchmark|individual|team/i) + + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreDuplicateUpdates).toHaveLength(0) + }) + + it("rejects registered submissions when the benchmark board is not public and published", async () => { + mockDb.reset({ + competitionVisibility: "private", + }) + + await expect( + submitVideoFn({ data: submissionPayload({ score: "33" }) }), + ).rejects.toThrow(/not open/i) + + expect(mockDb.mutations.registrationInserts).toHaveLength(0) + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreUpdateSets).toHaveLength(0) + }) + + it("fails closed when a benchmark competition is missing benchmark configuration", async () => { + mockDb.reset({ hasBattery: false }) + + await expect( + submitVideoFn({ data: submissionPayload({ score: "33" }) }), + ).rejects.toThrow(/benchmark configuration/i) + + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + expect(mockDb.mutations.scoreUpdateSets).toHaveLength(0) + }) + + it("creates an open-join registration transactionally before accepting a benchmark score", async () => { + mockDb.reset({ hasRegistration: false }) + + await submitVideoFn({ + data: submissionPayload({ divisionId: undefined, score: "33" }), + }) + + expect(mockDb.transaction).toHaveBeenCalled() + expect(mockDb.mutations.teamMembershipInserts).toContainEqual( + expect.objectContaining({ + teamId: "competition-team-1", + userId: "athlete-1", + }), + ) + expect(mockDb.mutations.registrationInserts).toContainEqual( + expect.objectContaining({ + eventId: "competition-benchmark", + userId: "athlete-1", + divisionId: "open-division", + }), + ) + expect(mockDb.mutations.scoreInserts).toContainEqual( + expect.objectContaining({ + benchmarkVariant: "female", + scalingLevelId: "open-division", + }), + ) + }) + + it("rejects open-join when the benchmark board is not public and published", async () => { + mockDb.reset({ + hasRegistration: false, + competitionVisibility: "private", + }) + + await expect( + submitVideoFn({ + data: submissionPayload({ divisionId: undefined, score: "33" }), + }), + ).rejects.toThrow(/not open/i) + + expect(mockDb.mutations.registrationInserts).toHaveLength(0) + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + }) + + it("rejects open-join when required waivers are unsigned", async () => { + mockDb.reset({ + hasRegistration: false, + requiredWaiverIds: ["waiver-1"], + signedWaiverIds: [], + }) + + await expect( + submitVideoFn({ + data: submissionPayload({ divisionId: undefined, score: "33" }), + }), + ).rejects.toThrow(/waivers/i) + + expect(mockDb.mutations.registrationInserts).toHaveLength(0) + expect(mockDb.mutations.scoreInserts).toHaveLength(0) + }) + + it("rate-limits repeated benchmark open-join attempts through the shared boundary", async () => { + for (let i = 0; i < 10; i++) { + await expect( + checkBenchmarkOpenJoinRateLimit({ + userId: "athlete-rate-limited", + competitionId: "competition-benchmark", + }), + ).resolves.toMatchObject({ allowed: true }) + } + + await expect( + checkBenchmarkOpenJoinRateLimit({ + userId: "athlete-rate-limited", + competitionId: "competition-benchmark", + }), + ).resolves.toMatchObject({ allowed: false }) + }) +}) diff --git a/lat.md/competition-type-capabilities.md b/lat.md/competition-type-capabilities.md index a5ac35346..cddb9a174 100644 --- a/lat.md/competition-type-capabilities.md +++ b/lat.md/competition-type-capabilities.md @@ -50,9 +50,9 @@ The window-status test pins benchmark as open without submission-window rows whi ## Perpetual Submission Gate Test -The submission-gate tests pin benchmark write entries as open without seeded submission-window rows while preserving existing validation after the gate. +The submission-gate tests pin benchmark submissions as perpetual while preventing unsafe direct API bypasses. -[[apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts]] verifies the score and video API submit routes accept benchmark competitions through the window gate. [[apps/wodsmith-start/test/server-fns/athlete-score-fns.test.ts]] verifies the athlete score server function does the same. +[[apps/wodsmith-start/test/server-fns/benchmark-submission-m3.test.ts]] verifies the benchmark `submitVideoFn` path accepts writes without seeded submission-window rows while applying profile-variant, Open-division, evidence-policy, and best-retention rules. [[apps/wodsmith-start/test/routes/api/compete/submission-gates.test.ts]] verifies the legacy direct score and video API submit routes reject benchmark-backed competitions so they cannot bypass the shared benchmark write helper. [[apps/wodsmith-start/test/components/video-submission-form.test.tsx]] verifies the generic event-detail submission form can send a score-only benchmark open-join attempt when video is optional. ## Video Submission Route Gates Test diff --git a/lat.md/domain.md b/lat.md/domain.md index 2be010da3..de9b8a6d9 100644 --- a/lat.md/domain.md +++ b/lat.md/domain.md @@ -164,6 +164,8 @@ Benchmark competitions reuse the normal competition shell. [[packages/wodsmith-d Benchmark configuration validates before write/read code consumes it. [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkCategoriesSchema]] rejects duplicate or malformed category caches, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#getBenchmarkCategoryCountIssues]] compares cached `testCount` values to included tests, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkVariantSchema]] limits v1 score variants to the profile gender values, [[apps/wodsmith-start/src/schemas/benchmark.schema.ts#benchmarkThresholdValuesSchema]] requires ten tier thresholds per standard row, and [[apps/wodsmith-start/src/schemas/scoring.schema.ts#scoringConfigSchema]] requires `absoluteTier.batteryId` only when `algorithm` is `absolute_tier`. [[apps/wodsmith-start/src/lib/scoring/algorithms/index.ts#calculateEventPoints]] dispatches `absolute_tier` only with preloaded threshold context, [[apps/wodsmith-start/src/lib/scoring/algorithms/absolute-tier.ts#calculateAbsoluteTier]] fails closed on missing variant/table data, [[apps/wodsmith-start/src/lib/scoring/category-aggregation.ts#aggregateBenchmarkScores]] computes category scores and Overall/100 without a second rescale, [[apps/wodsmith-start/src/lib/scoring/tiebreakers.ts#compareBenchmarkTierHistograms]] replaces field-relative countback for benchmark ties, and [[apps/wodsmith-start/src/lib/scoring/format/points.ts#formatLeaderboardPoints]] preserves half tiers without a positive-points prefix. +Benchmark score writes are centralized behind the video-submission path. [[apps/wodsmith-start/src/server-fns/video-submission-fns.ts#submitVideoFn]] detects benchmark batteries and delegates to [[apps/wodsmith-start/src/server/benchmark-submissions.ts#saveBenchmarkScoreInTransaction]], which requires profile gender via [[apps/wodsmith-start/src/server/benchmark-submissions.ts#getBenchmarkProfileVariant]], writes `benchmarkVariant`, stores scores under the single Open division, keeps the best score by absolute tier plus raw-score direction, and lets accepted score, video review reset, and round rewrites commit together. Open-join creation is guarded by [[apps/wodsmith-start/src/server/benchmark-submissions.ts#ensureBenchmarkOpenJoinRegistration]], which requires published/public benchmark state, signed required waivers, individual-only Open division configuration, idempotent registration reuse, and the benchmark open-join rate-limit boundary. [[apps/wodsmith-start/src/server-fns/video-submission-fns.ts#getBatchEventVideoSubmissionsFn]] and [[apps/wodsmith-start/src/components/compete/video-submission-form.tsx#VideoSubmissionForm]] expose benchmark open-join and optional-video state so a qualified athlete can submit a score-only benchmark attempt from the generic event-detail form when the battery policy allows it. + The first benchmark seed treats the source PDF as provenance data, not a branded product surface. [[apps/wodsmith-start/scripts/seed/data/benchmark-training-guide.ts#buildBenchmarkSeedRows]] creates generic competition, battery, workout, and track-workout rows from 58 source tests, marks three unsupported tests as `includedInScoring=false`, emits tier thresholds only for the 55 included tests, and leaves `competition_events` empty so the board stays perpetual without submission windows. Customer-facing routes, navigation, stats pages, copy, logos, calls to action, and visual treatment remain generic WODsmith benchmark surfaces. ## Volunteers @@ -196,7 +198,7 @@ The waiver schema stores the athlete requirement as `required` and volunteer req Athletes can submit video evidence of their workout performance for remote judging. -Submissions link to a score and include a video URL (e.g., Vimeo). A valid score is **required** — both [[apps/wodsmith-start/src/components/compete/video-submission-form.tsx#VideoSubmissionForm]] and [[apps/wodsmith-start/src/server-fns/video-submission-fns.ts#submitVideoFn]] reject submissions without a score to prevent athletes from entering scores in the notes field instead. The server-side check is gated on `videoIndex === 0` so that a team captain submitting multiple teammate videos in one form action — where the score is intentionally only sent with the first slot — is not rejected on subsequent slots. Judges can verify or reject submissions. Community voting is supported via `videoVotesTable`. Event ownership validation in [[apps/wodsmith-start/src/server-fns/submission-verification-fns.ts]] uses `verifyEventBelongsToCompetition` which checks `competition_events` first, then falls back to `track_workouts` → `programming_tracks` for sub-events without submission windows. +Remote competition submissions link to a score and include a video URL (e.g., Vimeo). A valid score is **required** — both [[apps/wodsmith-start/src/components/compete/video-submission-form.tsx#VideoSubmissionForm]] and [[apps/wodsmith-start/src/server-fns/video-submission-fns.ts#submitVideoFn]] reject submissions without a score to prevent athletes from entering scores in the notes field instead. The server-side check is gated on `videoIndex === 0` so that a team captain submitting multiple teammate videos in one form action — where the score is intentionally only sent with the first slot — is not rejected on subsequent slots. Benchmark submissions reuse `submitVideoFn` but follow the battery `videoPolicy`: `never` allows a score-only accepted best, `always` requires a video URL, and `for_top_scores` requires evidence only when the candidate reaches the top configured tier until a v2 top-N setting exists. Judges can verify or reject video-backed submissions. Community voting is supported via `videoVotesTable`. Event ownership validation in [[apps/wodsmith-start/src/server-fns/submission-verification-fns.ts]] uses `verifyEventBelongsToCompetition` which checks `competition_events` first, then falls back to `track_workouts` → `programming_tracks` for sub-events without submission windows. ### Supported Video Platforms From 3131e0683eaa701684aaa95064a45e7c28fb23a2 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 20 Jun 2026 21:10:34 -0600 Subject: [PATCH 09/51] feat: add benchmark leaderboard stats --- .../hillerfit-benchmark-leaderboard-guide.md | 4 +- .../reviewer-alignment.md | 45 +- .../hillerfit-benchmark-leaderboard/tasks.md | 2 + .../technical-design.md | 16 +- .../test-strategy.md | 2 + .../src/components/benchmark-stat-line.tsx | 205 +++++++++ .../src/components/competition-tabs.tsx | 28 +- .../online-competition-leaderboard-table.tsx | 220 +++++++++- apps/wodsmith-start/src/routeTree.gen.ts | 30 +- .../routes/compete/$slug/announcements.tsx | 5 +- .../src/routes/compete/$slug/index.tsx | 5 +- .../src/routes/compete/$slug/leaderboard.tsx | 5 +- .../src/routes/compete/$slug/registered.tsx | 10 +- .../src/routes/compete/$slug/schedule.tsx | 5 +- .../src/routes/compete/$slug/stats.tsx | 270 ++++++++++++ .../compete/$slug/workouts/$eventId.tsx | 2 +- .../routes/compete/$slug/workouts/index.tsx | 10 +- .../src/server/benchmark-leaderboard.ts | 409 ++++++++++++++++++ .../src/server/competition-leaderboard.ts | 134 +++++- .../benchmark-branding-boundary.test.ts | 34 ++ .../components/benchmark-stat-line.test.tsx | 148 +++++++ .../test/components/competition-tabs.test.tsx | 60 +++ .../leaderboard-page-content.test.tsx | 118 ++--- .../compete/benchmark-stats-route.test.tsx | 114 +++++ .../test/server/benchmark-leaderboard.test.ts | 207 +++++++++ ...ition-leaderboard-capability-gates.test.ts | 24 +- lat.md/competition-type-capabilities.md | 2 +- lat.md/domain.md | 2 + 28 files changed, 1987 insertions(+), 129 deletions(-) create mode 100644 apps/wodsmith-start/src/components/benchmark-stat-line.tsx create mode 100644 apps/wodsmith-start/src/routes/compete/$slug/stats.tsx create mode 100644 apps/wodsmith-start/src/server/benchmark-leaderboard.ts create mode 100644 apps/wodsmith-start/test/components/benchmark-branding-boundary.test.ts create mode 100644 apps/wodsmith-start/test/components/benchmark-stat-line.test.tsx create mode 100644 apps/wodsmith-start/test/components/competition-tabs.test.tsx create mode 100644 apps/wodsmith-start/test/routes/compete/benchmark-stats-route.test.tsx create mode 100644 apps/wodsmith-start/test/server/benchmark-leaderboard.test.ts diff --git a/ai/research/hillerfit-benchmark-leaderboard-guide.md b/ai/research/hillerfit-benchmark-leaderboard-guide.md index 343cc5125..c9d4a4140 100644 --- a/ai/research/hillerfit-benchmark-leaderboard-guide.md +++ b/ai/research/hillerfit-benchmark-leaderboard-guide.md @@ -1,10 +1,10 @@ # 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 benchmark feature code has been written; the existing capability-registry base is already present for current competition types. This is the document Zac reviews *before* implementation begins. +> **Status:** Implementation contract maintained during the phased benchmark build (**revision 3** — updated after M0a/M1/M2/M3 landed and M4 implementation began). 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: the competition capability registry already exists for `"in-person"`/`"online"` and intentionally fails closed for `"benchmark"`; benchmark work extends that registry instead of rebuilding M0 from scratch. +> **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: M0a has landed, so `"benchmark"` is now a registered competition type with `videoSubmissions` and `perpetual`; M4 extends that current-state registry instead of rebuilding it. > > **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`. diff --git a/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md index d22728e94..1ddcd0869 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md +++ b/ai/research/hillerfit-benchmark-leaderboard/reviewer-alignment.md @@ -1,47 +1,54 @@ # Reviewer Alignment -This file records independent reviewer summaries and the final convergence check. It starts with the orchestrator's current contract and should be updated after independent review. +This file records independent reviewer summaries and the final convergence check for the current implementation packet. ## Reviewer A Summary -Reviewer A found that the largest remaining gap was current-state drift: the guide framed M0a as a new registry deliverable, but this checkout already has `apps/wodsmith-start/src/lib/competitions/capabilities.ts` and tests for `in-person`/`online`, with `benchmark` intentionally failing closed. The implementation contract now says to extend the existing registry, add `benchmark` and `perpetual`, preserve current tests, and add benchmark characterization tests. +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 independently described the same intended system: capability registry, benchmark schema/seed, `absolute_tier` scoring, category aggregation, keep-best-on-write submission, public Overall/100 leaderboard, and stats page. Their largest gap was that the guide referenced an implementation packet that did not exist in their fork; the packet now exists and is the implementation contract. +Reviewer B reviewed the UI, route, test, and branding slice. They identified and then re-checked three blocking gaps: the guide header still described the pre-M0a state, the stats route collapsed load/configuration errors into the empty no-score state, and tests did not yet pin Stats tab visibility or direct `/stats` fallback behavior. The implementation now says M0a has landed and `benchmark` is registered with `videoSubmissions`/`perpetual`, renders an explicit stats load-error state, and covers tab gating plus direct stats-route unavailable/load-error states. + +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, and tier fields, and the branding boundary keeps HillerFit references out of customer-facing routes/components. ## Orchestrator Contract Components: -- Competition-type capability registry. -- Benchmark schema and seed. -- Absolute-tier scoring and category aggregation. -- Benchmark submission wrapper on the existing video submission path. -- Existing leaderboard pipeline extended for benchmark context. -- Public stats route and benchmark-flavored leaderboard display. +- 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: -- Three v1 benchmark definition tables. -- Shared `trackWorkouts` benchmark binding columns. -- Shared `scores.benchmarkVariant` snapshot column. +- `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 before benchmark write/read chokepoints. -- M1 seed/schema before algorithm integration tests. -- M2 algorithm before M3 keep-best-on-write. -- M3 submission before M4 public demo verification. -- First seed derived from `/Users/zacjones/Downloads/HillerFit_Training_Guide.pdf`. +- 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 approved board, absolute-tier scoring, best-to-date submission semantics, public Overall/100 leaderboard, public stat line, focused tests, LAT updates, and manual smoke all pass. +- 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 pass. +- `lat check` has no benchmark errors; current failures are existing Crew references outside this slice. +- Package-wide type-check still fails on broad existing route implicit-`any` issues, so full-package type-check remains a repo baseline caveat rather than an M4-specific blocker. ## Convergence Result -The reviewers materially agree on components, data model, dependencies, and definition of done. The one substantive divergence risk was M0a current-state drift, now resolved by making the packet current-state-aware. The owner clarified that the build uses the training PDF as benchmark source data only and does not include HillerFit-branded customer-facing surfaces. +The two independent reviewers materially agree on components, data model, dependencies, branding boundary, and definition of done for M4. The remaining caveats are not product forks: package-wide type-check is blocked by existing route typing debt, and `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 index ea0dfeae5..b9825a264 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/tasks.md +++ b/ai/research/hillerfit-benchmark-leaderboard/tasks.md @@ -85,11 +85,13 @@ 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. diff --git a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md index 0505aa7f5..cdeb9521f 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/technical-design.md +++ b/ai/research/hillerfit-benchmark-leaderboard/technical-design.md @@ -46,12 +46,13 @@ This design defines the components and contracts engineers should build for v1. 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 fetches registrations and scores as today, including `scores.benchmarkVariant`. -4. For each event group, it creates `EventScoreInput` with `variant` from `scores.benchmarkVariant`. -5. `calculateEventPoints` dispatches to `absolute_tier` with preloaded `ctx`. -6. Event results carry `tier` and `benchmarkCategory`. -7. Category aggregation computes category scores and Overall/100 before final ranking. -8. Ranking feeds Overall/100 into tiebreakers and uses a benchmark tier-histogram tiebreaker. +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 @@ -67,8 +68,9 @@ This design defines the components and contracts engineers should build for v1. - Missing or invalid profile gender blocks submission before scoring. - Missing `absoluteTier.batteryId` fails schema validation. -- Missing categories JSON, stale test counts, missing thresholds, or missing variant tables fail closed with a typed configuration error. +- 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. diff --git a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md index 0099fe033..c2a146d94 100644 --- a/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md +++ b/ai/research/hillerfit-benchmark-leaderboard/test-strategy.md @@ -18,6 +18,7 @@ This strategy makes the implementation testable without requiring the full histo - 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. @@ -29,6 +30,7 @@ This strategy makes the implementation testable without requiring the full histo - 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. 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..6ef173136 --- /dev/null +++ b/apps/wodsmith-start/src/components/benchmark-stat-line.tsx @@ -0,0 +1,205 @@ +"use client" + +import { AlertTriangle, CheckCircle2, Clock, MinusCircle } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import type { CompetitionLeaderboardEntry } from "@/server-fns/leaderboard-fns" + +interface BenchmarkStatLineProps { + entry: CompetitionLeaderboardEntry +} + +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 (result.rawScore === null) return "untested" + if (reviewStatus === "pending" || reviewStatus === "under_review") { + return "pending" + } + 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 }: BenchmarkStatLineProps) { + const overall = entry.benchmarkOverallScore ?? entry.totalPoints + const tests = [...entry.eventResults].sort( + (a, b) => a.trackOrder - b.trackOrder, + ) + + return ( +
+
+
+
+ Overall +
+
+ + {formatBenchmarkNumber(overall)} + + /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) => { + const state = getTestState(result) + return ( +
+
+
+ {result.eventName} +
+
+ {result.benchmarkCategoryLabel ?? "Benchmark"} +
+
+
+
+ Score +
+
+ {result.rawScore === null ? "-" : result.formattedScore} +
+
+
+
+ Tier +
+
+ {result.benchmarkTier === null + ? "-" + : formatBenchmarkNumber(result.benchmarkTier)} +
+
+
+ +
+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/wodsmith-start/src/components/competition-tabs.tsx b/apps/wodsmith-start/src/components/competition-tabs.tsx index b774cd793..0a84ce729 100644 --- a/apps/wodsmith-start/src/components/competition-tabs.tsx +++ b/apps/wodsmith-start/src/components/competition-tabs.tsx @@ -1,7 +1,14 @@ "use client" import { Link, useLocation, useNavigate } from "@tanstack/react-router" -import { Calendar, Dumbbell, List, Megaphone, Trophy } from "lucide-react" +import { + BarChart3, + Calendar, + Dumbbell, + List, + Megaphone, + Trophy, +} from "lucide-react" import { Select, SelectContent, @@ -9,13 +16,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + getEffectiveScoringConfig, + parseCompetitionSettings, +} from "@/types/competitions" import { cn } from "@/utils/cn" interface CompetitionTabsProps { slug: string + settings?: string | null } -const tabs = [ +const baseTabs = [ { label: "Event Details", href: "", icon: List }, { label: "Workouts", href: "/workouts", icon: Dumbbell }, { label: "Schedule", href: "/schedule", icon: Calendar }, @@ -23,11 +35,21 @@ const tabs = [ { label: "Announcements", href: "/announcements", icon: Megaphone }, ] -export function CompetitionTabs({ slug }: CompetitionTabsProps) { +function shouldShowStatsTab(settings: string | null | undefined): boolean { + const scoringConfig = getEffectiveScoringConfig( + parseCompetitionSettings(settings ?? null), + ) + return scoringConfig?.algorithm === "absolute_tier" +} + +export function CompetitionTabs({ slug, settings }: CompetitionTabsProps) { const location = useLocation() const navigate = useNavigate() const pathname = location.pathname const basePath = `/compete/${slug}` + const tabs = shouldShowStatsTab(settings) + ? [...baseTabs, { label: "Stats", href: "/stats", icon: BarChart3 }] + : baseTabs // Determine active tab value for select const getActiveTabValue = () => { diff --git a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx index 1afeb8c63..50d198e0d 100644 --- a/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx +++ b/apps/wodsmith-start/src/components/online-competition-leaderboard-table.tsx @@ -145,7 +145,28 @@ function getRankIcon(rank: number) { } } -function RankCell({ rank, points }: { rank: number; points?: number }) { +function formatBenchmarkNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1) +} + +function formatBenchmarkOverall(entry: CompetitionLeaderboardEntry): string { + return `${formatBenchmarkNumber(entry.benchmarkOverallScore ?? entry.totalPoints)}/100` +} + +function formatBenchmarkTier(tier: number | null | undefined): string { + if (tier === null || tier === undefined) return "Tier -" + return `Tier ${formatBenchmarkNumber(tier)}` +} + +function RankCell({ + rank, + points, + pointsLabel, +}: { + rank: number + points?: number + pointsLabel?: string +}) { const icon = getRankIcon(rank) const isPodium = rank <= 3 return ( @@ -161,15 +182,67 @@ function RankCell({ rank, points }: { rank: number; points?: number }) { {rank}
- {points !== undefined && ( + {(points !== undefined || pointsLabel) && ( - {points} pts + {pointsLabel ?? `${points} pts`} )}
) } +function BenchmarkCategorySummary({ + categories, +}: { + categories: CompetitionLeaderboardEntry["benchmarkCategoryScores"] +}) { + if (categories.length === 0) { + return - + } + + return ( +
+ {categories.map((category) => ( + + {category.label ?? category.key} + + {formatBenchmarkNumber(category.score)} + + + ))} +
+ ) +} + +function BenchmarkEventBadges({ + result, +}: { + result: CompetitionLeaderboardEntry["eventResults"][number] +}) { + if (result.benchmarkTier === null && !result.benchmarkCategoryLabel) { + return null + } + + return ( + + {result.benchmarkTier !== null && ( + + {formatBenchmarkTier(result.benchmarkTier)} + + )} + {result.benchmarkCategoryLabel && ( + + {result.benchmarkCategoryLabel} + + )} + + ) +} + function formatMemberName(member: TeamMemberInfo): string { const name = `${member.firstName || ""} ${member.lastName || ""}`.trim() || "Unknown" @@ -281,11 +354,23 @@ function PenaltyIndicator({ ) } -function TeamCell({ entry }: { entry: CompetitionLeaderboardEntry }) { +function TeamCell({ + entry, + showBenchmarkDetails = false, +}: { + entry: CompetitionLeaderboardEntry + showBenchmarkDetails?: boolean +}) { + const ratingLabel = entry.benchmarkRatingBand?.label if (!entry.isTeamDivision) { return (
{entry.athleteName} + {showBenchmarkDetails && ratingLabel && ( + + {ratingLabel} + + )} {entry.affiliate && ( {entry.affiliate} @@ -298,6 +383,11 @@ function TeamCell({ entry }: { entry: CompetitionLeaderboardEntry }) { return (
{entry.teamName || "Unknown Team"} + {showBenchmarkDetails && ratingLabel && ( + + {ratingLabel} + + )} {entry.affiliate && ( {entry.affiliate} @@ -630,6 +720,7 @@ function MobileOnlineLeaderboardRow({ entry.teamMembers.some((m) => m.userId === currentUserId)) const icon = getRankIcon(entry.overallRank) const isPodium = entry.overallRank <= 3 + const isBenchmarkLeaderboard = scoringAlgorithm === "absolute_tier" const sortedEvents = useMemo( () => [...events].sort((a, b) => a.trackOrder - b.trackOrder), @@ -657,7 +748,9 @@ function MobileOnlineLeaderboardRow({
- {entry.totalPoints} pts + {isBenchmarkLeaderboard + ? formatBenchmarkOverall(entry) + : `${entry.totalPoints} pts`}
@@ -667,6 +760,11 @@ function MobileOnlineLeaderboardRow({ {entry.teamName || "Unknown Team"} + {isBenchmarkLeaderboard && entry.benchmarkRatingBand && ( + + {entry.benchmarkRatingBand.label} + + )} {entry.affiliate && ( {entry.affiliate} @@ -685,6 +783,11 @@ function MobileOnlineLeaderboardRow({ {entry.athleteName} + {isBenchmarkLeaderboard && entry.benchmarkRatingBand && ( + + {entry.benchmarkRatingBand.label} + + )} {entry.affiliate && ( {entry.affiliate} @@ -748,6 +851,9 @@ function MobileOnlineLeaderboardRow({ {result.formattedScore} + {isBenchmarkLeaderboard && ( + + )} {linkToSubmission && ( #{result.rank}{" "} - {formatLeaderboardPoints( - result.points, - scoringAlgorithm, - )} + {isBenchmarkLeaderboard + ? formatBenchmarkTier(result.benchmarkTier) + : formatLeaderboardPoints( + result.points, + scoringAlgorithm, + )} {result.penaltyType && ( @@ -838,6 +946,7 @@ export function OnlineCompetitionLeaderboardTable({ const session = useSession() const isLoggedIn = !!session?.userId const currentUserId = session?.userId ?? null + const isBenchmarkLeaderboard = scoringAlgorithm === "absolute_tier" const defaultSortColumn = selectedEventId ? "eventRank" : "overallRank" @@ -982,7 +1091,17 @@ export function OnlineCompetitionLeaderboardTable({ if (!result || result.rank === 0) { return } - return + return ( + + ) }, sortingFn: "basic", }, @@ -991,7 +1110,10 @@ export function OnlineCompetitionLeaderboardTable({ header: athleteColumnLabel, accessorKey: isTeamLeaderboard ? "teamName" : "athleteName", cell: ({ row }: LeaderboardCellContext) => ( - + ), }, ...(hasAffiliates @@ -1035,6 +1157,9 @@ export function OnlineCompetitionLeaderboardTable({ {result.formattedScore} + {isBenchmarkLeaderboard && ( + + )} {linkToSubmission && ( )} @@ -1074,7 +1199,14 @@ export function OnlineCompetitionLeaderboardTable({ cell: ({ row }: LeaderboardCellContext) => ( ), sortingFn: "basic", @@ -1086,11 +1218,45 @@ export function OnlineCompetitionLeaderboardTable({ ), accessorKey: isTeamLeaderboard ? "teamName" : "athleteName", cell: ({ row }: LeaderboardCellContext) => ( - + ), }, ] + if (isBenchmarkLeaderboard) { + baseColumns.push( + { + id: "benchmarkRating", + header: ({ column }: LeaderboardHeaderContext) => ( + Rating + ), + accessorFn: (row: CompetitionLeaderboardEntry) => + row.benchmarkRatingBand?.label ?? "", + cell: ({ row }: LeaderboardCellContext) => ( + + {row.original.benchmarkRatingBand?.label ?? "-"} + + ), + }, + { + id: "benchmarkCategories", + header: "Categories", + accessorFn: (row: CompetitionLeaderboardEntry) => + row.benchmarkCategoryScores + .map((category) => category.score) + .join("|"), + cell: ({ row }: LeaderboardCellContext) => ( + + ), + }, + ) + } + if (hasAffiliates) { baseColumns.push({ id: "affiliate", @@ -1141,6 +1307,9 @@ export function OnlineCompetitionLeaderboardTable({ {result.formattedScore} + {isBenchmarkLeaderboard && ( + + )} {result.formattedTiebreak && ( (TB: {result.formattedTiebreak}) @@ -1151,7 +1320,12 @@ export function OnlineCompetitionLeaderboardTable({ #{result.rank} · - {formatLeaderboardPoints(result.points, scoringAlgorithm)} + {isBenchmarkLeaderboard + ? formatBenchmarkTier(result.benchmarkTier) + : formatLeaderboardPoints( + result.points, + scoringAlgorithm, + )} {result.videoUrl &&