diff --git a/docs/seed/BASEBALLHELM_DEMO_DATA_CONTRACT.md b/docs/seed/BASEBALLHELM_DEMO_DATA_CONTRACT.md index f2c577125..6ae098b41 100644 --- a/docs/seed/BASEBALLHELM_DEMO_DATA_CONTRACT.md +++ b/docs/seed/BASEBALLHELM_DEMO_DATA_CONTRACT.md @@ -13,6 +13,8 @@ Companion docs: surfaces). - `scripts/seed-baseball-lifting-demo.ts` — Phase 2 (Helm Lifting Lab). - `scripts/seed-baseball-surfaces-demo.ts` — Phase 3 (this contract). +- `scripts/seed-baseball-demo-program.ts` — Phase 4 (season + risks + + recruiting board; #912). ## Seed run order @@ -26,6 +28,7 @@ dependency has run at least once. | 1 | `scripts/seed-baseball-demo.ts` | — | Org, team, coach + 8-player roster, calendar, practice plan, lift assignments (Lite), readiness, coach insights, timeline events, one `baseball_import_runs` row. | | 2 | `scripts/seed-baseball-lifting-demo.ts` | Phase 1 | Helm Lifting Lab: lifting-coach identity, programs/weeks/days/sections/prescriptions, sessions, set results, readiness check-ins. | | 3 | `scripts/seed-baseball-surfaces-demo.ts` | Phase 1 (player/coach ids only — does **not** depend on Phase 2) | Every table this contract documents below. | +| 4 | `scripts/seed-baseball-demo-program.ts` | Phase 1 (roster + coach ids only — independent of Phases 2/3) | A believable, already-completed season (games + box scores + derived season stats), open risk flags, a recruiting board (3 fictional feeder programs + 8 recruits across all 4 active pipeline stages), a rolling lifting-session history, and more upcoming calendar events/practices. | Run them in order for a from-scratch demo team. Re-running any phase alone is always safe (idempotent upserts) — it just won't create anything the @@ -41,6 +44,9 @@ DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/seed-baseball-lif # 3. Messaging / video / tasks / strength groups / dev plans / seasons / imports / stats DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/seed-baseball-surfaces-demo.ts --confirm +# 4. Season (games + box scores) / risk flags / recruiting board / lifting history / calendar +DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/seed-baseball-demo-program.ts --confirm + # Verify coverage (any time, read-only, no --confirm flag needed) DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/verify-baseball-demo-coverage.ts @@ -127,6 +133,12 @@ named "Demo University Baseball") and its 8-player roster. | `baseball_stat_uploads` | `team_id`, `coach_id`, `import_run_id` | 1 completed upload linked to the Phase-3 import run | `/baseball/dashboard/import` | | `baseball_player_stats` | `team_id`, `coach_id`, `player_id` | 3 sessions per player (1 practice + 2 game) × 8 players = 24 rows, batting/pitching/fielding fields filled per position | `/baseball/dashboard/stats`, `/baseball/dashboard/stats/team` | | `baseball_player_aggregates` | `player_id`, `team_id` | 1 row per player (8 total), computed from the seeded `baseball_player_stats` rows so career/game/practice averages are internally consistent | `/baseball/dashboard/stats`, player profile | +| `baseball_games` (Phase-4 addition) | `team_id` | 20 completed games (17 official + 3 scrimmage) spanning a full Feb–May season, deterministically simulated so scores tie exactly to the box-score lines below | `/baseball/dashboard/stats/games` | +| `baseball_box_score_batting` / `baseball_box_score_pitching` (Phase-4 addition) | `game_id`, `player_id`, `team_id` | Per-game lines for the roster's 6 hitters + 2 pitchers across all 20 games; `baseball_player_season_stats` is then derived via `recalculate_baseball_season_stats` (never hand-authored) | `/baseball/dashboard/stats`, `/baseball/dashboard/stats/games/[gameId]` | +| `baseball_coach_insights` (Phase-4 addition) | `team_id`, `coach_id` | 3 additional `status='active'` risk flags (a pitcher workload flag, a batting cold-streak flag, a stale-recruiting-outreach flag) | `/baseball/dashboard/command-center` | +| `baseball_watchlists` (Phase-4 addition) | `coach_id`, `player_id` | 8 recruits across all 4 active pipeline stages (`watchlist`/`high_priority`/`offer_extended`/`committed`) | `/baseball/dashboard/pipeline`, `/baseball/dashboard/watchlist` | +| `baseball_lift_results` (Phase-4 addition) | `team_id`, `player_id` | 8 additional sessions per player (64 total), alternating squat/bench across the last ~8 weeks | `/baseball/dashboard/performance` | +| `baseball_events` / `baseball_practices` (Phase-4 addition) | `team_id` | 7 more upcoming events (practices, a team meeting, a fall exhibition) + 2 more published practices with blocks | `/baseball/dashboard/calendar`, `/baseball/dashboard/practice` | ## Intentionally-empty surfaces (NOT a coverage gap) @@ -136,7 +148,7 @@ exit code: | Table | Why it stays empty | |---|---| -| `baseball_recruiting_interests`, `baseball_watchlists` | The demo team (`player_type='college'`, `recruiting_activated=false` on every roster player) is a *college roster*, not a recruiting pipeline. College players never activate recruiting (see `CLAUDE.md` "Recruiting Activation Model"). Seeding recruiting data here would be product-incorrect, not just incomplete. | +| `baseball_recruiting_interests` | A *separate*, org-scoped recruiting-interest concept the demo doesn't populate — not to be confused with `baseball_watchlists` (the coach's own pipeline board), which Phase 4 (`scripts/seed-baseball-demo-program.ts`, #912) now seeds with 8 recruits. The demo team's own 8 roster players remain `player_type='college'` / `recruiting_activated=false` throughout — college players never activate recruiting (see `CLAUDE.md` "Recruiting Activation Model") — only the *recruits on the board* (separate `baseball_players` rows on fictional feeder programs) have `recruiting_activated=true`. | | `baseball_decision_log`, `baseball_meeting_items`, `baseball_signals`, `baseball_actions` | "Decision Room" is a separate CoachHelm-adjacent workflow (`src/lib/baseball/read-models/decision-room/`) built on top of *other* already-seeded surfaces (readiness, lift, insights, games). It is out of scope for this demo-coverage pass — not audited as empty in the original stale-surface audit. | | `baseball_video_events` | The staff-anchored film-tagging queue (Event/Tagged/Evidence video views) is distinct from the player-uploaded `baseball_videos` library this contract seeds, and was not in the original audit's empty-table list. | diff --git a/package.json b/package.json index 1735c9ca0..5e0690848 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "db:drift:check": "node scripts/db/check-supabase-drift.mjs", "check:stats": "tsx -r dotenv/config scripts/verify-stats-consistency.ts", "seed:baseball:e2e": "tsx -r dotenv/config scripts/seed-baseball-e2e.ts", - "seed:baseball:demo": "tsx -r dotenv/config scripts/seed-baseball-demo.ts --confirm && tsx -r dotenv/config scripts/seed-baseball-lifting-demo.ts --confirm && tsx -r dotenv/config scripts/seed-baseball-surfaces-demo.ts --confirm && tsx -r dotenv/config scripts/verify-baseball-demo-coverage.ts", + "seed:baseball:demo": "tsx -r dotenv/config scripts/seed-baseball-demo.ts --confirm && tsx -r dotenv/config scripts/seed-baseball-lifting-demo.ts --confirm && tsx -r dotenv/config scripts/seed-baseball-surfaces-demo.ts --confirm && tsx -r dotenv/config scripts/seed-baseball-demo-program.ts --confirm && tsx -r dotenv/config scripts/verify-baseball-demo-coverage.ts", "coachhelm:refresh": "bash scripts/coachhelm-refresh-all.sh", "coachhelm:regen": "DOTENV_CONFIG_PATH=.env.local tsx -r dotenv/config scripts/regen-coachhelm-from-corrected-stats.ts", "seed:baseball:ci": "tsx scripts/seed-baseball-demo.ts --confirm", diff --git a/scripts/__tests__/scripts-no-committed-secrets.test.mjs b/scripts/__tests__/scripts-no-committed-secrets.test.mjs index 0bdfa9bd6..07d35c727 100644 --- a/scripts/__tests__/scripts-no-committed-secrets.test.mjs +++ b/scripts/__tests__/scripts-no-committed-secrets.test.mjs @@ -32,6 +32,7 @@ const GENERIC_PASSWORD_ALLOWLIST = new Set([ 'scripts/seed-baseball-roster.mjs::HelmSeed2026!!', 'scripts/verify-ios-itinerary-create.mjs::DenisonBigRed2026!', 'scripts/seed-baseball-demo.ts::BaseballDemo2026', + 'scripts/seed-baseball-demo-program.ts::BaseballDemo2026', 'scripts/setup-admin.ts::Helm2026!!', ]); const SECRET_FIXTURE_ALLOWLIST = new Set([ diff --git a/scripts/seed-baseball-demo-program.ts b/scripts/seed-baseball-demo-program.ts new file mode 100644 index 000000000..1e369d9cf --- /dev/null +++ b/scripts/seed-baseball-demo-program.ts @@ -0,0 +1,755 @@ +/** + * seed-baseball-demo-program.ts — Fixes #912. + * + * The public BaseballHelm demo ("Demo University Baseball", entered via + * /baseball/demo) promises a "fully-populated" program but — once past the + * Phase-1 seed (scripts/seed-baseball-demo.ts) — actually shows an empty + * season: 0 games on record in Stats Center, 0 open risk flags, an empty + * recruiting board, and a single stale practice on the calendar. This script + * closes that gap for the SAME Phase-1 demo team, WITHOUT re-seeding org/ + * team/coach/roster (Phase-1 owns those). + * + * WHAT IT SEEDS (extends the demo team; see docs/seed/BASEBALLHELM_DEMO_DATA_CONTRACT.md): + * 1. A believable, already-completed season: 20 baseball_games rows (17 + * official + 3 scrimmage) spanning Feb–May of the "current" season + * year, each with box-score-level baseball_box_score_batting / + * baseball_box_score_pitching lines for the roster's 6 hitters + 2 + * pitchers, generated by a deterministic simulator + * (src/lib/baseball/seed/demo-program-sim.ts) so per-player lines stay + * internally consistent game-to-game (same hitter is always the same + * hitter). baseball_player_season_stats is then derived via the SAME + * `recalculate_baseball_season_stats` RPC the app's own box-score save + * flow calls (games.ts's completeGameAndRecalculate) — never a + * hand-authored aggregate row, so it can never drift from the canonical + * derivation. + * 2. 3 open (status='active') baseball_coach_insights risk flags, each + * citing real rows this script (or Phase-1) seeded — a workload flag on + * one pitcher, a cooling-off flag on the weakest bat, and a stale- + * outreach flag on two recruiting-board prospects. + * 3. A recruiting board: 3 small "feeder" programs (a high school, a + * showcase org, a JUCO — organizations + baseball_teams + team members, + * clearly fictional / demo-only) and 8 recruit baseball_players rows + * (recruiting_activated=true, never 'college' player_type) added to + * baseball_watchlists across all 4 active pipeline stages (watchlist / + * high_priority / offer_extended / committed). + * 4. A rolling lifting-session history: 8 baseball_lift_results per + * roster player (squat + bench, alternating, ~8 weeks back), reusing + * Phase-1's own exercises (no duplicate "Back Squat" entries). + * 5. A rolling calendar: 7 more upcoming baseball_events (practices, a + * team meeting, and a fall exhibition game) anchored to THIS script's + * run date, plus 2 more baseball_practices (with blocks) so Practice + * Planner shows more than Phase-1's single practice. + * + * SAFETY / IDEMPOTENCY (same guarantees as scripts/seed-baseball-demo.ts): + * - Every row id is deterministic (`detId()`, sha1 under a fixed + * namespace DISTINCT from Phase-1's, so nothing here can ever collide + * with or overwrite a Phase-1 row by accident). + * - Every write is `.upsert({ onConflict })` — there is no `.delete()` + * anywhere in this file. + * - Season-game dates are computed from "now" (evergreen: always "the + * most recently completed spring"), so re-running this script in a + * later year still produces a season that reads as just-finished + * instead of silently going stale. + * - Scoped strictly to the Phase-1 demo team id + this script's own new + * ids. Nothing outside that scope is ever read or written. + * - Recruit auth users are looked up by email first and only created when + * missing — never deleted, never password-reset (unlike the two shared + * CI login identities Phase-1 force-resets). + * + * DEPENDS ON scripts/seed-baseball-demo.ts having run at least once — this + * script re-derives the Phase-1 org/team/coach/roster ids from the same + * namespace + keys (mirrors scripts/seed-baseball-lifting-demo.ts's + * `p1Id()` pattern) rather than re-creating them. If Phase-1 hasn't run, + * the FK-dependent writes below will fail loudly with a clear Postgres + * foreign-key error — run scripts/seed-baseball-demo.ts --confirm first. + * + * SAFE BY DEFAULT — dry run unless --confirm is passed. + * + * Run (later, by a human — this PR ships the script REVIEWED-ONLY, it is + * never executed by the agent that authored it): + * # Dry run (prints the plan, writes nothing): + * DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/seed-baseball-demo-program.ts + * # Actually seed: + * DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/seed-baseball-demo-program.ts --confirm + * + * Requires env: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +import 'dotenv/config'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { createHash } from 'node:crypto'; +import { + simulateGame, + type HitterSkill, + type PitcherSkill, +} from '../src/lib/baseball/seed/demo-program-sim'; + +// --------------------------------------------------------------------------- +// CI guard (mirrors scripts/seed-baseball-demo.ts) — fail loudly before any +// async work if the required Supabase credentials are missing. +// --------------------------------------------------------------------------- +function assertRequiredSeedEnv(): void { + const required = { + NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, + } as const; + for (const [name, value] of Object.entries(required)) { + if (!value || !value.trim()) { + console.error(`Missing required env var: ${name} — cannot seed the baseball demo program`); + process.exit(1); + } + } +} +assertRequiredSeedEnv(); + +// --------------------------------------------------------------------------- +// Deterministic ids. +// +// NS_P1 re-derives scripts/seed-baseball-demo.ts's ids (org/team/coach/ +// roster) — MUST stay byte-identical to that script's namespace + keys. +// NS is THIS script's own namespace for every new row it creates, so ids +// here can never collide with Phase-1 (or seed-baseball-lifting-demo.ts's +// own 'baseballhelm-demo-lifting-v1' namespace). +// --------------------------------------------------------------------------- +const NS_P1 = 'baseballhelm-demo-phase1'; +const NS = 'baseballhelm-demo-program-v1'; + +function detIdIn(namespace: string, key: string): string { + const h = createHash('sha1').update(`${namespace}:${key}`).digest('hex'); + const b = h.slice(0, 32).split(''); + b[12] = '5'; + b[16] = ((parseInt(b[16], 16) & 0x3) | 0x8).toString(16); + const s = b.join(''); + return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`; +} +const detId = (key: string) => detIdIn(NS, key); +const p1Id = (key: string) => detIdIn(NS_P1, key); + +const TEAM_ID = p1Id('team'); +const COACH_ID = p1Id('coach'); + +// Phase-1 roster (byte-identical to ROSTER in scripts/seed-baseball-demo.ts — +// only the fields THIS script needs: position for def_position/lineup slotting +// and bats/throws for the recruit profiles' flavor is not needed here). +const ROSTER = [ + { key: 'p1', first: 'Marcus', last: 'Rodriguez', pos: 'SS' }, + { key: 'p2', first: 'Jake', last: 'Thompson', pos: 'C' }, + { key: 'p3', first: 'Caleb', last: 'Williams', pos: 'OF' }, + { key: 'p4', first: 'Ethan', last: 'Brooks', pos: 'P' }, + { key: 'p5', first: 'Noah', last: 'Mitchell', pos: '3B' }, + { key: 'p6', first: 'Liam', last: 'Harrison', pos: '2B' }, + { key: 'p7', first: 'Aiden', last: 'Clark', pos: 'P' }, + { key: 'p8', first: 'Owen', last: 'Davis', pos: 'OF' }, +] as const; +type RosterKey = (typeof ROSTER)[number]['key']; +const posByKey: Record = Object.fromEntries( + ROSTER.map((p) => [p.key, p.pos]), +) as Record; +const playerIdByKey: Record = Object.fromEntries( + ROSTER.map((p) => [p.key, p1Id(`player:${p.key}`)]), +) as Record; + +// Batting order (6 non-pitcher roster slots) + the 2 pitchers who split every +// game's innings (starter alternates so both accumulate starts + relief). +const HITTER_ORDER: RosterKey[] = ['p1', 'p6', 'p3', 'p5', 'p2', 'p8']; + +const HITTER_SKILLS: Record = { + p1: { avg: 0.342, hrPct: 0.1, xbhPct: 0.38, bbPct: 0.1, kPct: 0.14, speed: 0.55 }, // Rodriguez, SS — star, leadoff + p2: { avg: 0.261, hrPct: 0.09, xbhPct: 0.32, bbPct: 0.07, kPct: 0.19, speed: 0.1 }, // Thompson, C + p3: { avg: 0.318, hrPct: 0.05, xbhPct: 0.34, bbPct: 0.09, kPct: 0.13, speed: 0.7 }, // Williams, OF — speedster + p5: { avg: 0.278, hrPct: 0.14, xbhPct: 0.42, bbPct: 0.08, kPct: 0.22, speed: 0.15 }, // Mitchell, 3B — power + p6: { avg: 0.301, hrPct: 0.06, xbhPct: 0.3, bbPct: 0.12, kPct: 0.15, speed: 0.45 }, // Harrison, 2B — switch + p8: { avg: 0.249, hrPct: 0.06, xbhPct: 0.28, bbPct: 0.09, kPct: 0.21, speed: 0.2 }, // Davis, OF — weakest bat +}; +const PITCHER_SKILLS: Record<'p4' | 'p7', PitcherSkill> = { + p4: { h9: 7.6, k9: 9.4, bb9: 2.6, eraTarget: 2.95 }, // Brooks — ace + p7: { h9: 8.4, k9: 7.9, bb9: 3.4, eraTarget: 4.1 }, // Clark — workhorse, workload-flag candidate +}; + +// --------------------------------------------------------------------------- +// Date helpers. +// --------------------------------------------------------------------------- +const NOW = new Date(); +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} +/** "Evergreen" season year: the most recently completed spring. College + * seasons run roughly Feb–June, so before June 1 of `year` that spring + * hasn't happened yet — fall back to the PRIOR year's season. */ +const SEASON_YEAR = NOW.getUTCMonth() >= 5 ? NOW.getUTCFullYear() : NOW.getUTCFullYear() - 1; +function seasonDate(month1to12: number, day: number): string { + return `${SEASON_YEAR}-${pad2(month1to12)}-${pad2(day)}`; +} +function isoDaysFromNow(days: number): string { + const d = new Date(NOW); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString(); +} +function isoDaysAgo(days: number): string { + return isoDaysFromNow(-days); +} + +// --------------------------------------------------------------------------- +// Upsert wrapper + counters (identical shape to scripts/seed-baseball-demo.ts). +// --------------------------------------------------------------------------- +type Counts = Record; +const counts: Counts = {}; +let DRY = false; +let supabase: SupabaseClient; +const skipped: string[] = []; + +async function upsert(table: string, rows: readonly unknown[], conflict = 'id') { + if (rows.length === 0) return; + counts[table] = (counts[table] ?? 0) + rows.length; + if (DRY) return; + const { error } = await supabase.from(table).upsert(rows as never, { onConflict: conflict }); + if (error) { + const msg = error.message || ''; + if ( + /could not find the table|schema cache|does not exist|could not find the '.*' column|violates not-null constraint|violates check constraint/i.test( + msg, + ) + ) { + delete counts[table]; + skipped.push(`${table} — ${msg}`); + console.warn(` ⚠ skipped ${table} (schema not present yet): ${msg}`); + return; + } + throw new Error(`upsert ${table} failed: ${msg}`); + } +} + +const DEMO_DOMAIN = 'baseballhelmdemo.com'; +const DEMO_PASSWORD = 'BaseballDemo2026'; + +async function ensureAuthUser(email: string): Promise<{ userId: string | null; created: boolean }> { + const { data: existing } = await supabase + .from('users') + .select('id') + .ilike('email', email) + .maybeSingle(); + if (existing) return { userId: existing.id as string, created: false }; + if (DRY) return { userId: null, created: false }; + const { data, error } = await supabase.auth.admin.createUser({ + email, + password: DEMO_PASSWORD, + email_confirm: true, + }); + if (error || !data?.user) throw new Error(`createUser failed for ${email}: ${error?.message}`); + return { userId: data.user.id, created: true }; +} + +// --------------------------------------------------------------------------- +// Season schedule — 20 games (17 official + 3 scrimmage), Feb–May of +// SEASON_YEAR. Opponent names are all fictional (no real-school names), same +// convention as "Demo University" / "Demo Conference" elsewhere in this seed. +// --------------------------------------------------------------------------- +interface ScheduledGame { + month: number; + day: number; + opponent: string; + homeAway: 'home' | 'away'; + gameType: 'game' | 'scrimmage'; +} +const SCHEDULE: ScheduledGame[] = [ + { month: 2, day: 8, opponent: 'Sable Point', homeAway: 'home', gameType: 'scrimmage' }, + { month: 2, day: 14, opponent: 'Coastal Ridge', homeAway: 'home', gameType: 'game' }, + { month: 2, day: 15, opponent: 'Coastal Ridge', homeAway: 'home', gameType: 'game' }, + { month: 2, day: 21, opponent: 'Blue Harbor State', homeAway: 'away', gameType: 'scrimmage' }, + { month: 2, day: 28, opponent: 'Northgate', homeAway: 'away', gameType: 'game' }, + { month: 3, day: 1, opponent: 'Northgate', homeAway: 'away', gameType: 'game' }, + { month: 3, day: 7, opponent: 'Cedar Valley State', homeAway: 'home', gameType: 'game' }, + { month: 3, day: 8, opponent: 'Cedar Valley State', homeAway: 'home', gameType: 'game' }, + { month: 3, day: 14, opponent: 'Union Springs', homeAway: 'away', gameType: 'game' }, + { month: 3, day: 15, opponent: 'Union Springs', homeAway: 'away', gameType: 'game' }, + { month: 3, day: 21, opponent: 'Highland Tech', homeAway: 'home', gameType: 'scrimmage' }, + { month: 3, day: 28, opponent: 'Prairie Crossing', homeAway: 'home', gameType: 'game' }, + { month: 3, day: 29, opponent: 'Prairie Crossing', homeAway: 'home', gameType: 'game' }, + { month: 4, day: 4, opponent: 'Silver Creek', homeAway: 'away', gameType: 'game' }, + { month: 4, day: 11, opponent: 'Maple Hollow State', homeAway: 'home', gameType: 'game' }, + { month: 4, day: 12, opponent: 'Maple Hollow State', homeAway: 'home', gameType: 'game' }, + { month: 4, day: 18, opponent: 'Redstone', homeAway: 'away', gameType: 'game' }, + { month: 4, day: 25, opponent: 'Ashford', homeAway: 'home', gameType: 'game' }, + { month: 4, day: 26, opponent: 'Ashford', homeAway: 'home', gameType: 'game' }, + { month: 5, day: 2, opponent: 'Ironwood State', homeAway: 'away', gameType: 'game' }, +]; + +// --------------------------------------------------------------------------- +// Recruiting board — 3 fictional feeder programs + 8 recruits. +// --------------------------------------------------------------------------- +type FeederOrgKey = 'hs' | 'sc' | 'jc'; +const FEEDER_ORGS: Record< + FeederOrgKey, + { name: string; type: 'high_school' | 'showcase' | 'juco'; teamName: string; joinCode: string; city: string; state: string } +> = { + hs: { + name: 'Cypress Ridge High School', + type: 'high_school', + teamName: 'Cypress Ridge High School Baseball', + joinCode: 'DEMOHS1', + city: 'Ridgeview', + state: 'NC', + }, + sc: { + name: 'Lakeview Showcase Academy', + type: 'showcase', + teamName: 'Lakeview Showcase Academy Baseball', + joinCode: 'DEMOSC1', + city: 'Lakeview', + state: 'FL', + }, + jc: { + name: 'Tri-County Community College', + type: 'juco', + teamName: 'Tri-County Community College Baseball', + joinCode: 'DEMOJC1', + city: 'Millbrook', + state: 'TX', + }, +}; + +type PipelineStage = 'watchlist' | 'high_priority' | 'offer_extended' | 'committed'; +interface RecruitDef { + key: string; + first: string; + last: string; + org: FeederOrgKey; + pos: string; + bats: 'L' | 'R' | 'S'; + throws: 'L' | 'R'; + gradYear: number; + jersey: number; + exitVelo?: number; + pitchVelo?: number; + sixtyTime?: number; + popTime?: number; + armStrength?: number; + gpa: number; + stage: PipelineStage; + source: string; + tags: string[]; + fitScore: number; + priority: 0 | 1; + addedDaysAgo: number; + lastContactDaysAgo: number; + notes: string; +} +const RECRUITS: RecruitDef[] = [ + { + key: 'r1', first: 'Tyler', last: 'Ramirez', org: 'hs', pos: 'SS', bats: 'R', throws: 'R', + gradYear: 2027, jersey: 2, exitVelo: 94, sixtyTime: 6.7, armStrength: 88, gpa: 3.6, + stage: 'high_priority', source: 'showcase', tags: ['bat-first', 'middle-infield'], + fitScore: 88, priority: 1, addedDaysAgo: 62, lastContactDaysAgo: 6, + notes: 'Plus bat speed, needs to add strength. Projects as an everyday SS in two years.', + }, + { + key: 'r2', first: 'Jordan', last: 'Blake', org: 'hs', pos: 'P', bats: 'R', throws: 'R', + gradYear: 2027, jersey: 15, pitchVelo: 89, gpa: 3.4, + stage: 'watchlist', source: 'film', tags: ['projectable-arm'], + fitScore: 74, priority: 0, addedDaysAgo: 45, lastContactDaysAgo: 21, + notes: 'Fastball ticking up; secondary pitches still inconsistent. Follow up overdue.', + }, + { + key: 'r3', first: 'DeShawn', last: 'Carter', org: 'sc', pos: 'OF', bats: 'L', throws: 'L', + gradYear: 2028, jersey: 8, exitVelo: 96, sixtyTime: 6.5, gpa: 3.2, + stage: 'offer_extended', source: 'camp', tags: ['power-speed'], + fitScore: 91, priority: 1, addedDaysAgo: 90, lastContactDaysAgo: 4, + notes: 'Offer extended after fall camp. Waiting on an official-visit date.', + }, + { + key: 'r4', first: 'Mason', last: 'Cole', org: 'jc', pos: 'C', bats: 'R', throws: 'R', + gradYear: 2027, jersey: 21, popTime: 1.95, exitVelo: 90, gpa: 3.0, + stage: 'committed', source: 'referral', tags: ['transfer', 'catcher'], + fitScore: 95, priority: 1, addedDaysAgo: 120, lastContactDaysAgo: 2, + notes: 'Verbally committed for the transfer class. Framing and arm both plus.', + }, + { + key: 'r5', first: 'Ryan', last: 'Ferguson', org: 'hs', pos: 'P', bats: 'L', throws: 'L', + gradYear: 2028, jersey: 33, pitchVelo: 86, gpa: 3.8, + stage: 'watchlist', source: 'film', tags: ['lefty', 'academic'], + fitScore: 70, priority: 0, addedDaysAgo: 30, lastContactDaysAgo: 10, + notes: 'Young lefty with a strong academic profile. Revisit next spring.', + }, + { + key: 'r6', first: 'Andre', last: 'Simmons', org: 'sc', pos: '3B', bats: 'R', throws: 'R', + gradYear: 2027, jersey: 11, exitVelo: 92, armStrength: 90, gpa: 3.1, + stage: 'high_priority', source: 'showcase', tags: ['corner-power'], + fitScore: 85, priority: 1, addedDaysAgo: 55, lastContactDaysAgo: 5, + notes: 'Big arm at third; power still developing. Top target for the class.', + }, + { + key: 'r7', first: 'Kevin', last: 'Nakamura', org: 'hs', pos: '2B', bats: 'R', throws: 'R', + gradYear: 2029, jersey: 4, exitVelo: 88, sixtyTime: 6.6, gpa: 3.9, + stage: 'watchlist', source: 'referral', tags: ['early-look', 'academic'], + fitScore: 68, priority: 0, addedDaysAgo: 20, lastContactDaysAgo: 19, + notes: 'Early look via a coaching contact. Keep on the radar; follow up overdue.', + }, + { + key: 'r8', first: 'Miguel', last: 'Ortiz', org: 'jc', pos: 'P', bats: 'R', throws: 'R', + gradYear: 2027, jersey: 19, pitchVelo: 91, gpa: 2.9, + stage: 'offer_extended', source: 'camp', tags: ['transfer', 'power-arm'], + fitScore: 89, priority: 1, addedDaysAgo: 40, lastContactDaysAgo: 3, + notes: 'Offer extended. Weighing the MLB Draft against transferring in.', + }, +]; + +// =========================================================================== +async function main() { + const confirmed = process.argv.includes('--confirm'); + DRY = !confirmed; + const url = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').trim(); + const key = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? '').trim(); + supabase = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); + + if (DRY) { + console.log('[DRY RUN] No flag passed — printing the seed plan, writing NOTHING.'); + console.log('[DRY RUN] Re-run with --confirm to actually seed.\n'); + } + console.log( + `${DRY ? '[DRY RUN] ' : ''}Populating BaseballHelm demo program (team ${TEAM_ID.slice(0, 8)}, season ${SEASON_YEAR})`, + ); + + // ========================================================================== + // SECTION A — Season: games + box scores + season-stat recalculation. + // ========================================================================== + const gamesRows: Record[] = []; + const battingRows: Record[] = []; + const pitchingRows: Record[] = []; + + SCHEDULE.forEach((g, index) => { + const gameId = detId(`game:${index}`); + const dateStr = seasonDate(g.month, g.day); + const startsWithP4 = index % 2 === 0; + const starterKey: 'p4' | 'p7' = startsWithP4 ? 'p4' : 'p7'; + const relieverKey: 'p4' | 'p7' = startsWithP4 ? 'p7' : 'p4'; + + const sim = simulateGame({ + gameKey: `${TEAM_ID}:${gameId}`, + hitters: HITTER_ORDER.map((k) => ({ key: k, skill: HITTER_SKILLS[k as Exclude] })), + starter: { key: starterKey, skill: PITCHER_SKILLS[starterKey] }, + reliever: { key: relieverKey, skill: PITCHER_SKILLS[relieverKey] }, + }); + + gamesRows.push({ + id: gameId, + team_id: TEAM_ID, + game_date: dateStr, + game_type: g.gameType, + opponent_name: g.opponent, + location: g.homeAway === 'home' ? 'Demo Field' : `${g.opponent} Ballpark`, + home_away: g.homeAway, + our_score: sim.ourScore, + opponent_score: sim.opponentScore, + innings_played: 9, + status: 'completed', + created_by: COACH_ID, + }); + + for (const line of sim.batting) { + battingRows.push({ + id: detId(`bat:${index}:${line.key}`), + game_id: gameId, + player_id: playerIdByKey[line.key as RosterKey], + team_id: TEAM_ID, + batting_order: line.battingOrder, + ab: line.ab, + r: line.r, + h: line.h, + doubles: line.doubles, + triples: line.triples, + hr: line.hr, + rbi: line.rbi, + bb: line.bb, + k: line.k, + sb: line.sb, + cs: line.cs, + hbp: line.hbp, + sac: line.sac, + sf: line.sf, + def_position: posByKey[line.key as RosterKey], + }); + } + for (const line of sim.pitching) { + pitchingRows.push({ + id: detId(`pit:${index}:${line.key}`), + game_id: gameId, + player_id: playerIdByKey[line.key as RosterKey], + team_id: TEAM_ID, + ip: line.ip, + h: line.h, + r: line.r, + er: line.er, + bb: line.bb, + k: line.k, + hr: line.hr, + result: line.result, + gs: line.isStarter ? 1 : 0, + gf: line.isStarter ? 0 : 1, + }); + } + }); + + await upsert('baseball_games', gamesRows); + await upsert('baseball_box_score_batting', battingRows, 'game_id,player_id'); + await upsert('baseball_box_score_pitching', pitchingRows, 'game_id,player_id'); + + if (!DRY) { + for (const p of ROSTER) { + const { error } = await supabase.rpc('recalculate_baseball_season_stats', { + p_player_id: playerIdByKey[p.key], + p_team_id: TEAM_ID, + p_season_year: SEASON_YEAR, + }); + if (error) { + console.warn(` ⚠ season recalc failed for ${p.key} (${p.first} ${p.last}): ${error.message}`); + } + } + } else { + console.log(` [DRY RUN] would recalc season stats for ${ROSTER.length} players (season ${SEASON_YEAR})`); + } + + // ========================================================================== + // SECTION B — Risk flags: 3 open (status='active') coach insights. + // ========================================================================== + await upsert('baseball_coach_insights', [ + { + id: detId('insight:workload'), + team_id: TEAM_ID, + coach_id: COACH_ID, + player_id: playerIdByKey.p7, + insight_type: 'workload', + title: 'Aiden Clark’s workload is climbing', + body: 'Innings and pitch counts over recent relief outings are trending up while his walk rate has crept alongside them — worth a lighter bullpen day before the next series.', + priority: 'high', + status: 'active', + source_refs: [ + { table: 'baseball_box_score_pitching', sample_n: 10, confidence: 0.72, label: 'Season pitching lines (box score)' }, + ], + confidence: 0.72, + lifecycle_state: 'detected', + player_visible: false, + generated_by: 'demo_seed.workload_trend', + dedupe_key: `${TEAM_ID}:workload_trend:${playerIdByKey.p7}`, + last_generated_at: isoDaysAgo(0), + metadata: {}, + }, + { + id: detId('insight:cooling'), + team_id: TEAM_ID, + coach_id: COACH_ID, + player_id: playerIdByKey.p8, + insight_type: 'performance_trend', + title: 'Owen Davis has cooled at the plate', + body: 'Batting average over the last several logged games is running below his season line — a mechanics check or a lineup breather may help reset the swing.', + priority: 'medium', + status: 'active', + source_refs: [ + { table: 'baseball_box_score_batting', sample_n: 5, confidence: 0.68, label: 'Last 5 games (box score)' }, + ], + confidence: 0.68, + lifecycle_state: 'detected', + player_visible: false, + generated_by: 'demo_seed.performance_trend', + dedupe_key: `${TEAM_ID}:performance_trend:${playerIdByKey.p8}`, + last_generated_at: isoDaysAgo(1), + metadata: {}, + }, + { + id: detId('insight:recruiting-stale'), + team_id: TEAM_ID, + coach_id: COACH_ID, + player_id: null, + insight_type: 'recruiting', + title: 'Two recruits have gone quiet', + body: 'Jordan Blake and Kevin Nakamura haven’t been contacted in over two weeks and are both still in an early pipeline stage — reach out before the file goes cold.', + priority: 'medium', + status: 'active', + source_refs: [ + { table: 'baseball_watchlists', sample_n: 2, confidence: 0.6, label: 'Pipeline last-contact dates' }, + ], + confidence: 0.6, + lifecycle_state: 'detected', + player_visible: false, + generated_by: 'demo_seed.recruiting_stale_contact', + dedupe_key: `${TEAM_ID}:recruiting_stale_contact`, + last_generated_at: isoDaysAgo(0), + metadata: {}, + }, + ]); + + // ========================================================================== + // SECTION C — Recruiting board: 3 feeder programs + 8 recruits + watchlist. + // ========================================================================== + const feederOrgId: Record = { + hs: detId('org:feeder-hs'), + sc: detId('org:feeder-sc'), + jc: detId('org:feeder-jc'), + }; + const feederTeamId: Record = { + hs: detId('team:feeder-hs'), + sc: detId('team:feeder-sc'), + jc: detId('team:feeder-jc'), + }; + + await upsert( + 'organizations', + (Object.keys(FEEDER_ORGS) as FeederOrgKey[]).map((k) => ({ + id: feederOrgId[k], + name: FEEDER_ORGS[k].name, + type: FEEDER_ORGS[k].type, + location_city: FEEDER_ORGS[k].city, + location_state: FEEDER_ORGS[k].state, + description: 'BaseballHelm demo feeder program (recruiting-board source, safe to ignore in production lists).', + })), + ); + await upsert( + 'baseball_teams', + (Object.keys(FEEDER_ORGS) as FeederOrgKey[]).map((k) => ({ + id: feederTeamId[k], + organization_id: feederOrgId[k], + name: FEEDER_ORGS[k].teamName, + team_type: FEEDER_ORGS[k].type, + join_code: FEEDER_ORGS[k].joinCode, + })), + ); + + const recruitPlayerRows: Record[] = []; + const recruitMemberRows: Record[] = []; + const watchlistRows: Record[] = []; + + for (const r of RECRUITS) { + const email = `recruit-${r.key}@${DEMO_DOMAIN}`; + const auth = await ensureAuthUser(email); + const pid = detId(`recruit:${r.key}`); + + recruitPlayerRows.push({ + id: pid, + user_id: auth.userId, + player_type: FEEDER_ORGS[r.org].type, + recruiting_activated: true, + recruiting_activated_at: isoDaysAgo(r.addedDaysAgo), + first_name: r.first, + last_name: r.last, + email, + primary_position: r.pos, + bats: r.bats, + throws: r.throws, + grad_year: r.gradYear, + exit_velo: r.exitVelo ?? null, + pitch_velo: r.pitchVelo ?? null, + sixty_time: r.sixtyTime ?? null, + pop_time: r.popTime ?? null, + arm_strength: r.armStrength ?? null, + gpa: r.gpa, + city: FEEDER_ORGS[r.org].city, + state: FEEDER_ORGS[r.org].state, + high_school_name: r.org === 'jc' ? null : FEEDER_ORGS[r.org].name, + high_school_city: r.org === 'jc' ? null : FEEDER_ORGS[r.org].city, + high_school_state: r.org === 'jc' ? null : FEEDER_ORGS[r.org].state, + about_me: r.notes, + onboarding_completed: true, + profile_completion_percent: 90, + }); + recruitMemberRows.push({ + id: detId(`recruit-member:${r.key}`), + team_id: feederTeamId[r.org], + player_id: pid, + status: 'active', + jersey_number: r.jersey, + position: r.pos, + joined_at: isoDaysAgo(r.addedDaysAgo + 30), + }); + watchlistRows.push({ + id: detId(`watchlist:${r.key}`), + coach_id: COACH_ID, + player_id: pid, + pipeline_stage: r.stage, + priority: r.priority, + fit_score: r.fitScore, + source: r.source, + tags: r.tags, + notes: r.notes, + added_at: isoDaysAgo(r.addedDaysAgo), + last_contact: isoDaysAgo(r.lastContactDaysAgo), + }); + } + + await upsert('baseball_players', recruitPlayerRows); + await upsert('baseball_team_members', recruitMemberRows); + await upsert('baseball_watchlists', watchlistRows, 'coach_id,player_id'); + + // ========================================================================== + // SECTION D — Rolling lifting-session history (reuses Phase-1's exercises). + // ========================================================================== + const squatExerciseId = p1Id('ex:squat'); + const benchExerciseId = p1Id('ex:bench'); + const liftResultRows: Record[] = []; + for (const p of ROSTER) { + const pid = playerIdByKey[p.key]; + // 8 sessions per player, alternating squat/bench, roughly weekly for the + // last 8 weeks, with a small deterministic weight progression. + for (let week = 0; week < 8; week++) { + const isSquat = week % 2 === 0; + const exerciseId = isSquat ? squatExerciseId : benchExerciseId; + const baseWeight = isSquat ? 225 : 155; + const jitter = ((week * 7 + p.key.charCodeAt(1)) % 5) - 2; // small, deterministic -2..2 + liftResultRows.push({ + id: detId(`lift-history:${p.key}:${week}`), + team_id: TEAM_ID, + player_id: pid, + exercise_id: exerciseId, + performed_at: isoDaysAgo(7 * (8 - week) + 3), + sets: isSquat ? 4 : 3, + reps: isSquat ? 5 : 8, + weight: baseWeight + Math.floor(week / 2) * 5 + jitter, + rpe: 6.5 + ((week + (isSquat ? 0 : 1)) % 4) * 0.5, + source: 'manual', + }); + } + } + await upsert('baseball_lift_results', liftResultRows); + + // ========================================================================== + // SECTION E — Rolling calendar: more upcoming events + 2 more practices. + // ========================================================================== + const practiceEvent2Id = detId('event:practice-2'); + const practiceEvent3Id = detId('event:practice-3'); + await upsert('baseball_events', [ + { id: practiceEvent2Id, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Infield/Outfield + Live BP', event_type: 'practice', location: 'Demo Field', start_time: isoDaysFromNow(3), end_time: isoDaysFromNow(3), is_mandatory: true }, + { id: detId('event:meeting-2'), team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Meeting — Fall Program Planning', event_type: 'meeting', location: 'Film Room', start_time: isoDaysFromNow(5), end_time: isoDaysFromNow(5), is_mandatory: true }, + { id: practiceEvent3Id, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Bullpens + Baserunning', event_type: 'practice', location: 'Demo Field', start_time: isoDaysFromNow(8), end_time: isoDaysFromNow(8), is_mandatory: true }, + { id: detId('event:lift-1'), team_id: TEAM_ID, created_by: COACH_ID, title: 'Summer Strength & Conditioning', event_type: 'practice', location: 'Weight Room', start_time: isoDaysFromNow(10), end_time: isoDaysFromNow(10), is_mandatory: false }, + { id: detId('event:practice-4'), team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Defensive Fundamentals', event_type: 'practice', location: 'Demo Field', start_time: isoDaysFromNow(15), end_time: isoDaysFromNow(15), is_mandatory: true }, + { id: detId('event:practice-5'), team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Live BP + Scout Prep', event_type: 'practice', location: 'Demo Field', start_time: isoDaysFromNow(19), end_time: isoDaysFromNow(19), is_mandatory: true }, + { id: detId('event:exhibition-1'), team_id: TEAM_ID, created_by: COACH_ID, title: 'Fall Exhibition vs Westfield', event_type: 'game', location: 'Demo Field', start_time: isoDaysFromNow(26), end_time: isoDaysFromNow(26), is_mandatory: true }, + ]); + + const practice2Id = detId('practice:2'); + const practice3Id = detId('practice:3'); + await upsert('baseball_practices', [ + { id: practice2Id, team_id: TEAM_ID, event_id: practiceEvent2Id, title: 'Infield/Outfield + Live BP', focus: 'Defensive reads, live at-bats for the everyday lineup', status: 'published', published_at: isoDaysAgo(0) }, + { id: practice3Id, team_id: TEAM_ID, event_id: practiceEvent3Id, title: 'Bullpens + Baserunning', focus: 'Arm care progressions, secondary-lead work', status: 'published', published_at: isoDaysAgo(0) }, + ]); + await upsert('baseball_practice_blocks', [ + { id: detId('block:p2-1'), team_id: TEAM_ID, practice_id: practice2Id, start_offset_min: 0, duration_min: 15, activity: 'Dynamic warmup', location: 'Outfield', coach_owner_id: COACH_ID }, + { id: detId('block:p2-2'), team_id: TEAM_ID, practice_id: practice2Id, start_offset_min: 15, duration_min: 40, activity: 'Infield/outfield defense', location: 'Infield', coach_owner_id: COACH_ID }, + { id: detId('block:p2-3'), team_id: TEAM_ID, practice_id: practice2Id, start_offset_min: 55, duration_min: 45, activity: 'Live batting practice', location: 'Cage + field', coach_owner_id: COACH_ID }, + { id: detId('block:p3-1'), team_id: TEAM_ID, practice_id: practice3Id, start_offset_min: 0, duration_min: 15, activity: 'Dynamic warmup + arm care', location: 'Outfield', coach_owner_id: COACH_ID }, + { id: detId('block:p3-2'), team_id: TEAM_ID, practice_id: practice3Id, start_offset_min: 15, duration_min: 40, activity: 'Bullpens', location: 'Pen', coach_owner_id: COACH_ID }, + { id: detId('block:p3-3'), team_id: TEAM_ID, practice_id: practice3Id, start_offset_min: 55, duration_min: 30, activity: 'Baserunning + secondary leads', location: 'Infield', coach_owner_id: COACH_ID }, + ]); + + // --- Report ------------------------------------------------------------- + console.log(`\n${DRY ? '[DRY RUN] would seed' : 'Seeded'} rows:`); + for (const [t, n] of Object.entries(counts)) console.log(` ${t.padEnd(34)} ${n}`); + if (skipped.length) { + console.log('\nSkipped (schema not present yet):'); + for (const s of skipped) console.log(` - ${s}`); + } + console.log(`\nSeason: ${SEASON_YEAR} | ${SCHEDULE.filter((g) => g.gameType === 'game').length} official games, ${SCHEDULE.filter((g) => g.gameType === 'scrimmage').length} scrimmages`); + console.log(`Team: Demo University Baseball (${TEAM_ID})`); +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/scripts/verify-baseball-demo-coverage.ts b/scripts/verify-baseball-demo-coverage.ts index 2e2b203c8..af4a3f81c 100644 --- a/scripts/verify-baseball-demo-coverage.ts +++ b/scripts/verify-baseball-demo-coverage.ts @@ -70,6 +70,14 @@ const PHASE1_SURFACE_COVERAGE: readonly CoverageEntry[] = [ { table: 'baseball_stat_uploads', route: '/baseball/dashboard/import', scopeColumn: 'team_id', required: true }, { table: 'baseball_player_stats', route: '/baseball/dashboard/stats', scopeColumn: 'team_id', required: true }, { table: 'baseball_player_aggregates', route: '/baseball/dashboard/stats', scopeColumn: 'team_id', required: true }, + // Populated by scripts/seed-baseball-demo-program.ts (Phase 4, #912) — a + // believable completed season with box-score-level stats + a recruiting + // board, so these moved out of INTENTIONALLY_EMPTY below once that script + // had a real row to point at. + { table: 'baseball_games', route: '/baseball/dashboard/stats/games', scopeColumn: 'team_id', required: true }, + { table: 'baseball_box_score_batting', route: '/baseball/dashboard/stats/games/[gameId]', scopeColumn: 'team_id', required: true }, + { table: 'baseball_box_score_pitching', route: '/baseball/dashboard/stats/games/[gameId]', scopeColumn: 'team_id', required: true }, + { table: 'baseball_watchlists', route: '/baseball/dashboard/pipeline', scopeColumn: 'coach_id', required: true }, ] as const; const RINI_EXTRA_SURFACE_COVERAGE: readonly CoverageEntry[] = [ @@ -105,8 +113,12 @@ export const SURFACE_COVERAGE: readonly CoverageEntry[] = * "Intentionally-empty surfaces"). Printed for visibility, never required. */ export const INTENTIONALLY_EMPTY: readonly { table: string; reason: string }[] = [ + // baseball_watchlists moved to PHASE1_SURFACE_COVERAGE (required, scoped by + // coach_id) — scripts/seed-baseball-demo-program.ts (#912) now populates a + // recruiting board. baseball_recruiting_interests is a SEPARATE table (a + // different, org-scoped recruiting-interest concept the demo doesn't touch) + // and stays intentionally empty. { table: 'baseball_recruiting_interests', reason: 'demo team is a college roster; college players never activate recruiting' }, - { table: 'baseball_watchlists', reason: 'demo team is a college roster; no recruiting pipeline to populate' }, { table: 'baseball_decision_log', reason: 'Decision Room is a separate workflow, out of scope for this demo-coverage pass' }, { table: 'baseball_meeting_items', reason: 'Decision Room is a separate workflow, out of scope for this demo-coverage pass' }, { table: 'baseball_signals', reason: 'Decision Room is a separate workflow, out of scope for this demo-coverage pass' }, diff --git a/src/components/baseball/command-center/CommandCenterFairway.tsx b/src/components/baseball/command-center/CommandCenterFairway.tsx index 8d8140406..419117dbb 100644 --- a/src/components/baseball/command-center/CommandCenterFairway.tsx +++ b/src/components/baseball/command-center/CommandCenterFairway.tsx @@ -286,10 +286,15 @@ export function CommandCenterFairway({ onOpenPlayer={openPlayer} /> ) : ( + // Distinct headline from CoverHero's own "Standing by — awaiting + // first pitch." (that one is the WEEKLY COVER voice — no game on + // the schedule yet). This is the MORNING BRIEF voice — the AI + // engine, not the schedule — so the two empty-state cards never + // read as an identical pair stacked on the same page. diff --git a/src/components/golf/calendar/PremiumEventBlock.tsx b/src/components/golf/calendar/PremiumEventBlock.tsx index e763858f2..d26c3d866 100644 --- a/src/components/golf/calendar/PremiumEventBlock.tsx +++ b/src/components/golf/calendar/PremiumEventBlock.tsx @@ -92,9 +92,18 @@ export function PremiumEventBlock({ style={{ backgroundColor: getEventDotColorVar(event.event_type) }} aria-hidden="true" /> + {/* `min-w-0` here (not just on the flex-1 ancestor two levels up) + is load-bearing: as a flex item of the row above, this span's + default `min-width: auto` blocks `truncate`'s ellipsis from + ever kicking in — a long title just overflows this pill's + right edge into the day cell's neighbor instead of eliding, + since MonthView's grid columns are width-capped + (`minmax(0,1fr)`) but nothing between here and there clips + the overflow with a scrollbar; it just visually clips at the + calendar's right edge for the rightmost day-of-week column. */} - {/* Editorial title — sculptural, light weight */} -

+ {/* Editorial title — sculptural, light weight. + `min-w-0` alone (no floor) let ANY shortfall in the header's + available width collapse this all the way to 1-2 characters + ("J…") — this is the ONLY item in the row without an explicit + min-content floor, so the flex shrink algorithm dumped the ENTIRE + deficit onto it even at desktop widths where the deficit was + small. Floors from `md:` up (where the longer text-h2 label + format is also active) cap how far it can shrink WITHOUT + reintroducing the original 390px mobile bug this component + already guards against (mobile keeps the unconstrained `min-w-0` + base case). */} +

{getTitle()}

- {/* Navigation — borderless arrows */} -
+ {/* Navigation — borderless arrows. `shrink-0` so these small, + fixed-size controls are never asked to give up space to the + title — the title's own floor above is the intended pressure + valve, not these buttons shrinking unpredictably. */} +
onNavigate('prev')} @@ -897,7 +910,7 @@ function CalendarHeader({ diff --git a/src/lib/baseball/seed/__tests__/demo-program-sim.test.ts b/src/lib/baseball/seed/__tests__/demo-program-sim.test.ts new file mode 100644 index 000000000..d202d3840 --- /dev/null +++ b/src/lib/baseball/seed/__tests__/demo-program-sim.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { + simulateGame, + seedFromString, + mulberry32, + type HitterSkill, + type PitcherSkill, + type SimulateGameParams, +} from '../demo-program-sim'; + +const HITTER: HitterSkill = { + avg: 0.3, + hrPct: 0.12, + xbhPct: 0.35, + bbPct: 0.1, + kPct: 0.18, + speed: 0.3, +}; + +const FAST_HITTER: HitterSkill = { ...HITTER, speed: 0.8, avg: 0.34 }; +const WEAK_HITTER: HitterSkill = { ...HITTER, avg: 0.21, hrPct: 0.03, speed: 0.05 }; + +const STARTER: PitcherSkill = { h9: 7.8, k9: 9.4, bb9: 2.7, eraTarget: 3.1 }; +const RELIEVER: PitcherSkill = { h9: 8.3, k9: 8.0, bb9: 3.3, eraTarget: 3.9 }; + +function buildParams(gameKey: string): SimulateGameParams { + return { + gameKey, + hitters: [ + { key: 'p1', skill: FAST_HITTER }, + { key: 'p2', skill: HITTER }, + { key: 'p3', skill: FAST_HITTER }, + { key: 'p5', skill: HITTER }, + { key: 'p6', skill: HITTER }, + { key: 'p8', skill: WEAK_HITTER }, + ], + starter: { key: 'p4', skill: STARTER }, + reliever: { key: 'p7', skill: RELIEVER }, + }; +} + +describe('mulberry32 / seedFromString', () => { + it('is deterministic for a given seed', () => { + const a = mulberry32(seedFromString('game:2026-03-14')); + const b = mulberry32(seedFromString('game:2026-03-14')); + const seqA = Array.from({ length: 10 }, () => a()); + const seqB = Array.from({ length: 10 }, () => b()); + expect(seqA).toEqual(seqB); + }); + + it('produces different sequences for different seeds', () => { + const a = mulberry32(seedFromString('game:2026-03-14')); + const b = mulberry32(seedFromString('game:2026-03-21')); + expect(a()).not.toEqual(b()); + }); +}); + +describe('simulateGame', () => { + it('is a pure function of its inputs (idempotent across repeated calls)', () => { + const params = buildParams('demo-team:2026-03-14'); + const first = simulateGame(params); + const second = simulateGame(params); + expect(second).toEqual(first); + }); + + it('produces a different game for a different gameKey', () => { + const g1 = simulateGame(buildParams('demo-team:2026-03-14')); + const g2 = simulateGame(buildParams('demo-team:2026-03-21')); + expect(g1).not.toEqual(g2); + }); + + it('never ends in a tie', () => { + for (const key of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']) { + const sim = simulateGame(buildParams(`tie-check:${key}`)); + expect(sim.ourScore).not.toBe(sim.opponentScore); + } + }); + + it('batting R sums to ourScore (invariant, not just spot-checked)', () => { + for (const key of ['a', 'b', 'c', 'd', 'e']) { + const sim = simulateGame(buildParams(`sum-check:${key}`)); + const battingRuns = sim.batting.reduce((sum, b) => sum + b.r, 0); + expect(battingRuns).toBe(sim.ourScore); + } + }); + + it('pitching R sums to opponentScore (invariant)', () => { + for (const key of ['a', 'b', 'c', 'd', 'e']) { + const sim = simulateGame(buildParams(`sum-check-pitch:${key}`)); + const pitchingRuns = sim.pitching.reduce((sum, p) => sum + p.r, 0); + expect(pitchingRuns).toBe(sim.opponentScore); + } + }); + + it('IP always sums to exactly 9 across the two pitchers', () => { + const sim = simulateGame(buildParams('ip-check')); + const totalIp = sim.pitching.reduce((sum, p) => sum + p.ip, 0); + expect(totalIp).toBe(9); + }); + + it('every batting line is internally consistent (h <= ab, extra bases <= h - hr)', () => { + for (const key of ['a', 'b', 'c', 'd', 'e', 'f']) { + const sim = simulateGame(buildParams(`consistency:${key}`)); + for (const b of sim.batting) { + expect(b.h).toBeLessThanOrEqual(b.ab); + expect(b.doubles + b.triples + b.hr).toBeLessThanOrEqual(b.h); + expect(b.hr).toBeLessThanOrEqual(b.h); + expect(b.ab).toBeGreaterThanOrEqual(3); + expect(b.ab).toBeLessThanOrEqual(5); + } + } + }); + + it('every pitching line has er <= r (unearned runs never exceed total runs)', () => { + for (const key of ['a', 'b', 'c', 'd', 'e']) { + const sim = simulateGame(buildParams(`er-check:${key}`)); + for (const p of sim.pitching) { + expect(p.er).toBeLessThanOrEqual(p.r); + } + } + }); + + it('exactly one pitcher is marked as starter and gs/gf-style roles are distinct', () => { + const sim = simulateGame(buildParams('roles')); + const starters = sim.pitching.filter((p) => p.isStarter); + expect(starters).toHaveLength(1); + expect(sim.pitching).toHaveLength(2); + }); + + it('assigns the win to the pitcher on the winning side and a save only to a non-losing reliever', () => { + for (const key of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) { + const sim = simulateGame(buildParams(`decision:${key}`)); + const starterLine = sim.pitching.find((p) => p.isStarter); + const relieverLine = sim.pitching.find((p) => !p.isStarter); + expect(starterLine).toBeDefined(); + expect(relieverLine).toBeDefined(); + const won = sim.ourScore > sim.opponentScore; + expect(starterLine?.result).toBe(won ? 'W' : 'L'); + if (relieverLine?.result === 'S') { + expect(won).toBe(true); + expect(sim.ourScore - sim.opponentScore).toBeLessThanOrEqual(3); + } + } + }); +}); diff --git a/src/lib/baseball/seed/demo-program-sim.ts b/src/lib/baseball/seed/demo-program-sim.ts new file mode 100644 index 000000000..f15876968 --- /dev/null +++ b/src/lib/baseball/seed/demo-program-sim.ts @@ -0,0 +1,323 @@ +/** + * demo-program-sim.ts — pure, deterministic box-score simulation used by + * scripts/seed-baseball-demo-program.ts (Fixes #912). + * + * WHY THIS LIVES UNDER src/lib (not inline in the script): + * scripts/*.ts files are never exercised by `npm test` (vitest's "unit" + * project only globs `src/**`, see vitest.config.ts) — the box-score math + * is the one part of that script worth real, running test coverage, so it + * lives here as a pure module the script imports, with a companion test at + * `__tests__/demo-program-sim.test.ts`. + * + * PURE: no Supabase, no Date.now(), no I/O. Every function is a deterministic + * transform of its inputs — same inputs always produce the same output, so + * re-running the seed script never regenerates different numbers for the + * same game. + * + * INVARIANTS (enforced by construction, not asserted after the fact): + * - Team runs scored == sum of each batter's `r`. + * - Runs allowed == sum of each pitcher's `r`. + * - IP always sums to exactly 9 across the two pitchers (starter 6, the + * other pitcher 3) — no partial-inning "outs" notation needed. + * - `doubles + triples <= h - hr` (singles is never negative). + */ + +// ----------------------------------------------------------------------------- +// Seeded PRNG — mulberry32, seeded from a string via a small FNV-1a hash. +// Same algorithm shape used by scripts/seed-baseball-box-scores.mjs. +// ----------------------------------------------------------------------------- + +export function seedFromString(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return function random() { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ----------------------------------------------------------------------------- +// Skill profiles — inputs, not outputs. +// ----------------------------------------------------------------------------- + +export interface HitterSkill { + /** Target batting average, roughly. */ + avg: number; + /** Chance a hit is a home run. */ + hrPct: number; + /** Chance a non-HR hit goes for extra bases. */ + xbhPct: number; + /** Walk tendency. */ + bbPct: number; + /** Strikeout tendency (share of outs that are Ks). */ + kPct: number; + /** 0 (no speed) .. 1 (burner) — drives SB attempts + triples. */ + speed: number; +} + +export interface PitcherSkill { + h9: number; + k9: number; + bb9: number; + /** Target ERA — drives how many runs this arm tends to allow. */ + eraTarget: number; +} + +export interface BattingLine { + key: string; + battingOrder: number; + ab: number; + r: number; + h: number; + doubles: number; + triples: number; + hr: number; + rbi: number; + bb: number; + k: number; + sb: number; + cs: number; + hbp: number; + sac: number; + sf: number; +} + +export interface PitchingLine { + key: string; + ip: number; + h: number; + r: number; + er: number; + bb: number; + k: number; + hr: number; + result: 'W' | 'L' | 'S' | null; + isStarter: boolean; +} + +export interface GameSim { + ourScore: number; + opponentScore: number; + batting: BattingLine[]; + pitching: PitchingLine[]; +} + +export interface SimulateGameParams { + /** Unique, stable per-game key (e.g. `${teamId}:${gameDate}`). */ + gameKey: string; + hitters: { key: string; skill: HitterSkill }[]; + starter: { key: string; skill: PitcherSkill }; + reliever: { key: string; skill: PitcherSkill }; +} + +const STARTER_IP = 6; +const RELIEVER_IP = 3; + +/** + * Simulate one game's batting + pitching lines. Deterministic: calling this + * twice with identical `params` always returns identical output. + */ +export function simulateGame(params: SimulateGameParams): GameSim { + const { gameKey, hitters, starter, reliever } = params; + const rng = mulberry32(seedFromString(gameKey)); + + // ---- Batting: per-hitter AB/H/2B/3B/HR/BB/HBP/K/SF/SAC/SB/CS ---- + const lineup = hitters.map((entry, index) => { + const skill = entry.skill; + const ab = 3 + (rng() < 0.55 ? 1 : 0) + (rng() < 0.15 ? 1 : 0); + let h = 0; + for (let i = 0; i < ab; i++) { + if (rng() < skill.avg + (rng() - 0.5) * 0.1) h++; + } + h = Math.min(h, ab); + const hr = h > 0 && rng() < skill.hrPct ? 1 : 0; + let doubles = 0; + let triples = 0; + for (let i = 0; i < h - hr; i++) { + const x = rng(); + if (x < skill.xbhPct * 0.3) doubles++; + else if (x < skill.xbhPct * 0.3 + skill.speed * 0.06) triples++; + } + const bb = rng() < skill.bbPct * 2.2 ? 1 : 0; + const hbp = rng() < 0.04 ? 1 : 0; + const outs = ab - h; + let k = 0; + for (let i = 0; i < outs; i++) { + if (rng() < skill.kPct) k++; + } + const sf = h === 0 && outs > 0 && rng() < 0.05 ? 1 : 0; + const sac = rng() < 0.04 ? 1 : 0; + const sb = skill.speed > 0.3 && rng() < skill.speed * 0.35 ? 1 : 0; + const cs = sb === 0 && skill.speed > 0.3 && rng() < 0.05 ? 1 : 0; + const reached = h + bb + hbp; + return { + key: entry.key, + battingOrder: index + 1, + ab, + h, + doubles, + triples, + hr, + bb, + hbp, + k, + sf, + sac, + sb, + cs, + reached, + r: hr > 0 ? 1 : 0, // every HR is an automatic run + rbi: hr > 0 ? 1 : 0, // and an automatic RBI (solo minimum) + }; + }); + + // Distribute EXTRA runs (beyond the automatic HR run) among hitters who + // reached base and still have "room" (r < reached this game) — this makes + // `ourScore` a byproduct of the loop below, not an independently-picked + // target, so batting R always sums to ourScore by construction. + const nonHrReachedCapacity = lineup.reduce( + (sum, b) => sum + Math.max(0, b.reached - b.r), + 0, + ); + let extraRunsLeft = Math.min( + nonHrReachedCapacity, + Math.round(nonHrReachedCapacity * (0.4 + rng() * 0.24)), + ); + let guard = 0; + while (extraRunsLeft > 0 && guard++ < 200) { + const candidates = lineup + .filter((b) => b.r < b.reached) + .sort((a, b) => b.reached - b.r - (a.reached - a.r) || rng() - 0.5); + if (candidates.length === 0) break; + const chosen = candidates[Math.floor(rng() * Math.min(candidates.length, 3))]; + if (!chosen) break; + chosen.r += 1; + extraRunsLeft -= 1; + } + // ---- Pitching: runs allowed derived from skill, IP fixed 6 + 3 = 9 ---- + function runsAllowed(skill: PitcherSkill, ip: number): number { + const expected = (skill.eraTarget / 9) * ip; + const perInningChance = Math.min(0.85, expected / Math.max(ip, 1)); + let runs = 0; + for (let i = 0; i < ip; i++) { + if (rng() < perInningChance) runs += rng() < 0.7 ? 1 : 2; + } + return runs; + } + function counts(skill: PitcherSkill, ip: number, r: number) { + const h = Math.max(r > 0 ? 1 : 0, Math.round((skill.h9 / 9) * ip + (rng() - 0.5) * 1.2)); + const bb = Math.max(0, Math.round((skill.bb9 / 9) * ip + (rng() - 0.5))); + const k = Math.max(0, Math.round((skill.k9 / 9) * ip + (rng() - 0.5) * 1.2)); + const hr = r > 0 && rng() < 0.3 ? 1 : 0; + const er = Math.max(0, r - (rng() < 0.25 ? 1 : 0)); + return { h, bb, k, hr, er }; + } + + const starterR = runsAllowed(starter.skill, STARTER_IP); + let relieverR = runsAllowed(reliever.skill, RELIEVER_IP); + + // Baseball games never end tied — when the two independently-derived + // totals land equal, nudge exactly ONE underlying source up by a run + // (deterministically, via the same rng stream) BEFORE any per-line/derived + // total is read, so the "sum of parts == total" invariant never breaks: + // bump a batter's `r` (recomputing ourScore from the lineup after) or bump + // `relieverR` (which pitching.r is built from below) — never the summary + // number in isolation. + if (lineup.reduce((sum, b) => sum + b.r, 0) === starterR + relieverR) { + if (rng() < 0.5) { + const target = + [...lineup].sort((a, b) => b.reached - a.reached)[0] ?? lineup[0]; + if (target) target.r += 1; + } else { + relieverR += 1; + } + } + const ourScore = lineup.reduce((sum, b) => sum + b.r, 0); + const opponentScore = starterR + relieverR; + + // RBI: usually tracks runs 1:1, occasionally one short (a run scored on an + // error/wild pitch/etc. carries no RBI) — matches real box-score texture. + const rbiTarget = Math.max(0, ourScore - (ourScore >= 5 && rng() < 0.5 ? 1 : 0)); + let rbiLeft = rbiTarget - lineup.reduce((sum, b) => sum + b.rbi, 0); + guard = 0; + const rbiCap = (b: (typeof lineup)[number]) => b.h * 2 + b.hr + (b.sf ? 1 : 0); + while (rbiLeft > 0 && guard++ < 200) { + const candidates = lineup + .filter((b) => b.rbi < rbiCap(b) && (b.h > 0 || b.sf > 0)) + .sort((a, b) => b.h + b.hr * 2 - (a.h + a.hr * 2) || rng() - 0.5); + if (candidates.length === 0) break; + const chosen = candidates[Math.floor(rng() * Math.min(candidates.length, 3))]; + if (!chosen) break; + chosen.rbi += 1; + rbiLeft -= 1; + } + + const batting: BattingLine[] = lineup.map((b) => ({ + key: b.key, + battingOrder: b.battingOrder, + ab: b.ab, + r: b.r, + h: b.h, + doubles: b.doubles, + triples: b.triples, + hr: b.hr, + rbi: b.rbi, + bb: b.bb, + k: b.k, + sb: b.sb, + cs: b.cs, + hbp: b.hbp, + sac: b.sac, + sf: b.sf, + })); + + const starterCounts = counts(starter.skill, STARTER_IP, starterR); + const relieverCounts = counts(reliever.skill, RELIEVER_IP, relieverR); + const won = ourScore > opponentScore; + const margin = ourScore - opponentScore; + + const pitching: PitchingLine[] = [ + { + key: starter.key, + ip: STARTER_IP, + h: starterCounts.h, + r: starterR, + er: starterCounts.er, + bb: starterCounts.bb, + k: starterCounts.k, + hr: starterCounts.hr, + result: won ? 'W' : 'L', + isStarter: true, + }, + { + key: reliever.key, + ip: RELIEVER_IP, + h: relieverCounts.h, + r: relieverR, + er: relieverCounts.er, + bb: relieverCounts.bb, + k: relieverCounts.k, + hr: relieverCounts.hr, + result: won && margin > 0 && margin <= 3 ? 'S' : null, + isStarter: false, + }, + ]; + + return { + ourScore, + opponentScore, + batting, + pitching, + }; +}