diff --git a/docs/baseball/COHERENCE_RULING_2026-07-08.md b/docs/baseball/COHERENCE_RULING_2026-07-08.md new file mode 100644 index 000000000..5f129ff0d --- /dev/null +++ b/docs/baseball/COHERENCE_RULING_2026-07-08.md @@ -0,0 +1,88 @@ +# BaseballHelm Coherence Ruling — 2026-07-08 (overnight consolidation) + +**Authority:** Commander decision doc for the one-night coherence mission. Supersedes conflicting +guidance in CANONICAL_SPEC §2.1 (stale 10-item nav). Builds on COACH_NAV_8TAB_PROPOSAL.md +(owner-approved 2026-07-01) and the 2026-06-30 shell postmortem's unexecuted recommendations. +Baseline: `origin/main` e63de6044 — tsc/lint/ratchet/unit all green. + +## Ruling 1 — ONE shell: BaseballFairwayShell, unconditional + +`NEXT_PUBLIC_REDESIGN=true` is what prod serves. The legacy fork is pure regression risk +(`.env.example` defaults it off). Therefore: + +- `(dashboard)/layout.tsx` and `(player-dashboard)/player/layout.tsx` render + `BaseballFairwayShell` **unconditionally** — delete the `isRedesignEnabled()` forks + (baseball layouts only; golf untouched). +- Delete `src/app/baseball/(coach-dashboard)/` entirely (zero page.tsx, confirmed dead). +- Remove the **baseball** nav paths from `src/components/layout/sidebar.tsx` + (5 legacy arrays: collegeTeamNav/hsCoachTeamNav/jucoTeamNav/showcaseOrgNav/playerTeamNav + + `buildCondensedBaseballNavigation`); the golf branch stays byte-identical. +- Delete `BaseballShellLayout`/`BaseballDashboardShell` baseball render path once unreferenced. +- Route groups `(dashboard)` + `(player-dashboard)` both stay (URLs are load-bearing: + PWA start_url, bookmarks). Folding them is deferred — not tonight's risk. + +## Ruling 2 — Navigation IA: ≤8 primary, ≤3 subtabs per primary (hard caps) + +Top level keeps the owner-approved hub set (College sees 7, HS 6, JUCO 8 — all ≤8): +**Dashboard · Team · Messages · Stats & Performance · Development · Recruiting · +Academics (JUCO) · Management**. The fix is inside the hubs — every destination stays +reachable ≤2 clicks (subtab landing pages surface deeper routes as cards/CTAs; command +palette stays flat with everything). Deep routes keep their URLs; `resolve-active-hub` +maps them to the owning subtab for highlight + breadcrumbs. + +| Hub | Subtabs (≤3) | Folded in (reachable from landing) | +|---|---|---| +| Dashboard | Overview · Signals | — | +| Team | Roster · Calendar · Operations | Operations = new landing: Documents, Travel, Practice Planner, Practice Effectiveness | +| Messages | Messages · Announcements | announcements moves here from Team (it's comms) | +| Stats & Performance | Stats Center · Games · Postgame | Season = view inside Stats Center; Upload + Import Center = CTAs inside Stats Center | +| Development | Dev Plans · Training · Videos | Training = existing /dashboard/performance landing → Programs, Live Weight Room, Builder, Groups | +| Recruiting | Pipeline · Discover · Scouting | Scouting = new landing: Watchlist, Compare, Saved Comparisons, Scout Packets, Camps | +| Academics | (single page) | JUCO only | +| Management | Decision Room · Settings · Organization | Settings = existing card-grid landing (KEEP grid, DELETE the 9-tab splice from COACH_MANAGEMENT_TABS); Organization = org/teams/events (Showcase types) | + +Player nav (Fairway): Today · Schedule · My Stats · Development · Team · Messages · +My Profile (+ Recruiting when activated) — already ≤8; enforce ≤3 subtabs per hub the same way. +Players hitting `/baseball/dashboard/practice` redirect to `/baseball/player/practice` (canonical +player practice surface). + +Nav-manifest test extended: every coach/both registry entry maps to exactly one hub, and no +hub resolves >3 subtabs for any coach type. That test is the anti-regression lock. + +## Ruling 3 — ONE Lift Lab + +Canonical = `src/components/lifting/*` + `helm_lifting_*`. Repoint the 6 baseball +performance routes at the canonical components; delete `src/components/baseball/performance/*` +(23-file legacy tree, GolfHelm-palette, writes legacy `baseball_lift_*`). Completes the +in-flight unification train. + +## Ruling 4 — Data honesty & correctness cluster + +- "Today" is **team-local** everywhere (`resolveTeamTimezone` + `todayIsoInTz`), never server-UTC: + readiness page, command-center read-model, player-today read-model. +- Calendar: null `end_time` renders start + 1h default (never zero-duration); events query gets + lookback bound + limit; badge labels pluralize. +- Academics eligibility is tri-state: `null` = gray "Not on file"; red "Ineligible" only for real `false`. +- Roster: drop EXIT V column (column doesn't exist in schema, no write path — honest UI); + backfill career_obp/slg/ops for existing rows (prod data op). +- Breadcrumbs: UUID-shaped segments never title-cased; dynamic routes supply real names + (players/[id], stats/games/[id], dev-plans/[id]). +- `createBaseballEvent` game-insert errors checked, not swallowed. +- E2E: spec cleans up its own rows (service-role delete in teardown); prod junk rows + (`E2E Created Opponent%`) deleted as a data op; isolated E2E project documented as follow-up + needing owner (new Supabase project). +- Seed script gets realistic event times (practice 15:30–17:30, games 13:00–16:00, meetings 12:00–13:00 team-local); + demo team's polluted event rows corrected in place. + +## Ruling 5 — Dead code deleted, not layered over + +`(coach-dashboard)`; legacy sidebar baseball arrays; legacy shell baseball path; legacy Lift Lab +tree; `players/[id]/profile` duplicate page (canonical = `players/[id]` PlayerProfileClient); +knip-confirmed orphans (MatchScoreBadge, match-calculator, dashboard-types); 5 orphaned +`baseballhelm-*.{mjs,workflow.js}` scripts (superseded by Helm Bridge). + +## Out of scope tonight (documented, not forgotten) + +Route-group merge of `(player-dashboard)` into `(dashboard)`; dedicated E2E Supabase project; +PlayerPassportCard→Fairway preview swap if polish wave runs out of clock; full 3-lane +Living-Annual masthead vision (ui-migration-map L56) — the 8-hub IA is the stepping stone. diff --git a/e2e/README.md b/e2e/README.md index c4ae0de1b..e571d891a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -188,17 +188,32 @@ present (it isn't, on fork PRs, matching the existing `E2E_GOLF_*` secret pattern in that workflow). When the secret is absent, the seed step is skipped entirely and the three specs above self-skip rather than fail. -### Known limitation: "create game" / "create camp" specs are non-destructive only on the create side - -`GameCard`'s delete affordance is intentionally hidden in the UI (a -permanent `hidden` class), so the box-score spec's "create a new game" test -cannot clean up the game row it creates via the UI — each CI run adds one -new `baseball_games` row with a unique opponent name -(`E2E Created Opponent ${Date.now()}`) to the test database. This is a -known, accepted trade-off (documented here rather than worked around) since -the created row doesn't affect any other spec's assertions. The Camps -spec's create/delete round-trip *is* fully self-cleaning, since the Camps -UI does expose a working delete action. +### Cleanup: "create game" spec self-cleans via a service-role teardown (not the UI) + +`GameCard`'s delete affordance is still intentionally hidden in the UI (a +permanent `hidden` class), so `baseball-box-score.spec.ts`'s "should create +a new game and redirect to its box-score entry page" test cannot clean up +the `baseball_games` row it creates through the app UI. It creates a real +row (plus a linked `baseball_events` row, since the create form defaults +`create_calendar_event` to `true`) tagged with a unique opponent name +(`E2E Created Opponent ${Date.now()}`). + +Rather than accept that as permanent test-database pollution, the +`Coach - Create New Game` describe block now has a `test.afterAll` teardown +that deletes exactly the `baseball_games` row(s) (and their linked +`baseball_events` row(s)) it created, by opponent name, via a service-role +Supabase client — the same construction pattern +`scripts/seed-baseball-e2e.ts` uses for seeding +(`NEXT_PUBLIC_SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY`, session-less). +The teardown is a silent no-op (never a failure) when the service-role key +isn't present in the environment, matching every other seed/cleanup path in +this suite. + +This test had no cleanup prior to 2026-07-08 and had accumulated ~160 junk +`baseball_games` rows in the shared database, purged via a one-off SQL +cleanup that same night. The Camps spec's create/delete round-trip was +already fully self-cleaning via its working UI delete action and is +unaffected by this change. ## CI/CD Integration diff --git a/e2e/baseball-box-score.spec.ts b/e2e/baseball-box-score.spec.ts index e6fde3a70..4cb5ef63a 100644 --- a/e2e/baseball-box-score.spec.ts +++ b/e2e/baseball-box-score.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; +import { getE2eAdminClient } from '../scripts/e2e-supabase-admin'; import { loginAsCoach, loginAsPlayer } from './helpers/auth'; import { waitForPageLoad } from './helpers/common'; @@ -23,10 +24,13 @@ import { waitForPageLoad } from './helpers/common'; * including `status`), per the "destructive tests rely on seed reset" * pattern used throughout this fixture. * - * The "create game" test adds one extra (non-seeded) game row each run — - * there is currently no reachable delete affordance in the UI (GamesList's - * delete button is shipped with a permanent `hidden` class), so this is a - * known, documented trade-off rather than an oversight. See e2e/README.md. + * The "create game" test adds one extra (non-seeded) game row each run. + * `GamesList`'s delete button is still shipped with a permanent `hidden` + * class, so there is no reachable UI delete affordance — cleanup instead + * happens directly against the database: a `test.afterAll` below deletes + * every `baseball_games` row (+ its linked `baseball_events` row) this file + * created, via the same service-role client construction + * `scripts/seed-baseball-e2e.ts` uses for seeding. See e2e/README.md. * * Gated on PLAYWRIGHT_BASEBALL_SEEDED=1, with a per-test self-skip if the * login fixture is unavailable in the target environment — same pattern as @@ -42,6 +46,18 @@ const SEEDED = const SCHEDULED_OPPONENT = 'Riverside University'; const COMPLETED_OPPONENT = 'Eastview College'; +/** + * Service-role Supabase client for teardown-only writes (deleting rows this + * spec itself created) — provided by scripts/e2e-supabase-admin.ts so this + * spec never references the service-role env var directly (ast-grep rule + * helmv3-no-service-role-key). Returns `null` (teardown becomes a no-op) + * rather than throwing when the env is missing — cleanup must never fail an + * otherwise-passing run. + */ +function getServiceRoleClient() { + return getE2eAdminClient(); +} + async function loginCoachOrSkip(page: Page) { try { await loginAsCoach(page); @@ -65,12 +81,43 @@ async function loginPlayerOrSkip(page: Page) { test.describe('Coach - Create New Game', () => { test.skip(!SEEDED, 'no seeded baseball team fixture (set PLAYWRIGHT_BASEBALL_SEEDED=1)'); + // Opponent names created by the "create new game" test below — tracked so + // the afterAll teardown can delete exactly (and only) the rows this file + // created, never touching the seeded fixture games. + const createdOpponents: string[] = []; + test.beforeEach(async ({ page }) => { await loginCoachOrSkip(page); await page.goto('/baseball/dashboard/stats/games/create'); await waitForPageLoad(page); }); + test.afterAll(async () => { + if (createdOpponents.length === 0) return; + const supabase = getServiceRoleClient(); + if (!supabase) return; + + // The create form defaults `create_calendar_event` to true, so each + // created game also has a linked baseball_events row (event_id) — + // delete both so no orphaned event survives the game's deletion. + const { data: games } = await supabase + .from('baseball_games') + .select('id, event_id') + .in('opponent_name', createdOpponents); + + const gameIds = (games ?? []).map((g) => g.id); + const eventIds = (games ?? []) + .map((g) => g.event_id) + .filter((id): id is string => Boolean(id)); + + if (gameIds.length > 0) { + await supabase.from('baseball_games').delete().in('id', gameIds); + } + if (eventIds.length > 0) { + await supabase.from('baseball_events').delete().in('id', eventIds); + } + }); + test('should display the new game form with date, opponent, and venue fields', async ({ page }) => { await expect(page.getByRole('heading', { name: /Add Game/i })).toBeVisible(); await expect(page.locator('#new-game-date')).toBeVisible(); @@ -92,6 +139,7 @@ test.describe('Coach - Create New Game', () => { test('should create a new game and redirect to its box-score entry page', async ({ page }) => { const opponent = `E2E Created Opponent ${Date.now()}`; + createdOpponents.push(opponent); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 45); const dateStr = futureDate.toISOString().slice(0, 10); diff --git a/memory/projects/golfhelm.md b/memory/projects/golfhelm.md index 41a5f4218..0e01f71b0 100644 --- a/memory/projects/golfhelm.md +++ b/memory/projects/golfhelm.md @@ -264,7 +264,7 @@ src/lib/coachhelm/ -**231 routes** (source: `src/app/**/page.tsx`). +**224 routes** (source: `src/app/**/page.tsx`).
Full alphabetical route list @@ -273,6 +273,7 @@ src/lib/coachhelm/ - `/admin/activity` - `/admin/auth` - `/admin/baseball` +- `/admin/ben-leah` - `/admin/deploys` - `/admin/errors` - `/admin/errors/[fingerprint]` @@ -284,14 +285,11 @@ src/lib/coachhelm/ - `/admin/users` - `/admin/users/[id]` - `/admin/users/[id]/view-as` +- `/admin/work` - `/baseball` - `/baseball/admin/demo-sessions` - `/baseball/coach` - `/baseball/coach-onboarding` -- `/baseball/coach/college` -- `/baseball/coach/high-school` -- `/baseball/coach/juco` -- `/baseball/coach/showcase` - `/baseball/complete-signup` - `/baseball/dashboard` - `/baseball/dashboard/academics` @@ -320,6 +318,7 @@ src/lib/coachhelm/ - `/baseball/dashboard/messages` - `/baseball/dashboard/messages/[id]` - `/baseball/dashboard/my-stats` +- `/baseball/dashboard/operations` - `/baseball/dashboard/organization` - `/baseball/dashboard/performance` - `/baseball/dashboard/performance/builder` @@ -331,7 +330,6 @@ src/lib/coachhelm/ - `/baseball/dashboard/pipeline` - `/baseball/dashboard/players/[id]` - `/baseball/dashboard/players/[id]/passport` -- `/baseball/dashboard/players/[id]/profile` - `/baseball/dashboard/players/[id]/scout-packet` - `/baseball/dashboard/players/[id]/scout-packet/preview` - `/baseball/dashboard/players/[id]/stats` @@ -343,6 +341,7 @@ src/lib/coachhelm/ - `/baseball/dashboard/readiness` - `/baseball/dashboard/roster` - `/baseball/dashboard/scout-packets` +- `/baseball/dashboard/scouting` - `/baseball/dashboard/settings` - `/baseball/dashboard/settings/ai` - `/baseball/dashboard/settings/appearance` @@ -370,12 +369,10 @@ src/lib/coachhelm/ - `/baseball/dashboard/stats/games` - `/baseball/dashboard/stats/games/[gameId]` - `/baseball/dashboard/stats/games/create` -- `/baseball/dashboard/stats/games/new` - `/baseball/dashboard/stats/season` - `/baseball/dashboard/stats/upload` - `/baseball/dashboard/tasks` - `/baseball/dashboard/team` -- `/baseball/dashboard/team/high-school` - `/baseball/dashboard/teams` - `/baseball/dashboard/travel` - `/baseball/dashboard/videos` @@ -389,12 +386,8 @@ src/lib/coachhelm/ - `/baseball/packet/[token]` - `/baseball/player` - `/baseball/player/[id]` -- `/baseball/player/college` -- `/baseball/player/high-school` -- `/baseball/player/juco` - `/baseball/player/passport` - `/baseball/player/practice` -- `/baseball/player/showcase` - `/baseball/player/timeline` - `/baseball/player/today` - `/baseball/program/[id]` diff --git a/scripts/backfill-baseball-slash-lines.ts b/scripts/backfill-baseball-slash-lines.ts new file mode 100644 index 000000000..4a1e14cec --- /dev/null +++ b/scripts/backfill-baseball-slash-lines.ts @@ -0,0 +1,182 @@ +/** + * backfill-baseball-slash-lines.ts — one-time backfill for + * `baseball_player_aggregates.career_obp` / `career_slg` / `career_ops`. + * + * Migration 20260701020000 added the three columns (BaseballHelm #436) but + * shipped with NO backfill — "the columns fill in on the next aggregate + * recalculation for each player." Any player whose aggregates haven't been + * recalculated since then (no new stat session logged) is stuck with + * `career_avg` populated and `career_obp`/`career_slg`/`career_ops` null, + * which is exactly the "AVG shows, SLG/OPS show '—'" honesty bug Ruling 4 + * calls out on the roster wall. + * + * Reuses the SAME pure compute helper `recalculatePlayerAggregates` + * (src/app/baseball/actions/stats.ts) calls — `computeCareerSlashLine` + * (src/lib/baseball/aggregates/career-slash-line.ts, unit-tested) — so this + * script never re-derives the OBP/SLG/OPS formulas itself. For every + * (player_id, team_id) already in `baseball_player_aggregates`, it re-reads + * that pair's `baseball_player_stats` rows (the same source table the app's + * own recalc reads) and UPDATEs only the three slash-line columns. + * + * Idempotent: the computed value is a pure function of the current + * `baseball_player_stats` rows, so re-running always converges to the same + * result — safe to run again after new stats are logged. + * + * Safe: UPDATE only (`career_obp`, `career_slg`, `career_ops`) on rows that + * already exist. No inserts, no deletes, no other columns touched. + * + * Connection modeled EXACTLY on scripts/seed-rini-baseball-demo.ts: env from + * .env.local, `createClient(url, key, { auth: { persistSession: false, + * autoRefreshToken: false } })`. Dry-run by default (prints the plan, writes + * nothing) — pass --confirm to write, matching the sibling seed scripts' + * safety convention. + * + * Run: + * DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/backfill-baseball-slash-lines.ts # dry run + * DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/backfill-baseball-slash-lines.ts --confirm # write + * + * (`npx tsx scripts/backfill-baseball-slash-lines.ts` also works standalone — + * the script loads .env.local itself via `dotenv` below.) + * + * Requires env: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +import { config as loadEnv } from 'dotenv'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { computeCareerSlashLine, type SlashLineStatRow } from '../src/lib/baseball/aggregates/career-slash-line'; + +loadEnv({ path: '.env.local' }); + +const DRY = !process.argv.includes('--confirm'); + +interface AggregateRow { + player_id: string; + team_id: string; + career_avg: number | null; + career_obp: number | null; + career_slg: number | null; + career_ops: number | null; +} + +function fmt(v: number | null): string { + return v == null ? '—' : v.toFixed(3); +} + +function valuesEqual(a: number | null, b: number | null): boolean { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + // Compare at the same 3-decimal precision the column/formula use. + return Math.abs(a - b) < 0.0005; +} + +async function main() { + const url = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').trim(); + const key = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? '').trim(); + if (!url || !key) throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY'); + const supabase: SupabaseClient = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + console.log(`${DRY ? '[DRY RUN] printing plan, writing NOTHING. Re-run with --confirm.\n' : ''}Backfilling baseball_player_aggregates.career_obp/slg/ops...\n`); + + const { data: rows, error: fetchError } = await supabase + .from('baseball_player_aggregates') + .select('player_id, team_id, career_avg, career_obp, career_slg, career_ops') + .returns(); + + if (fetchError) throw fetchError; + if (!rows || rows.length === 0) { + console.log('No baseball_player_aggregates rows found — nothing to backfill.'); + return; + } + + console.log(`Found ${rows.length} aggregate row(s).\n`); + + let scanned = 0; + let changed = 0; + let unchanged = 0; + let errors = 0; + const samples: Array<{ playerId: string; teamId: string; before: AggregateRow; after: { career_obp: number | null; career_slg: number | null; career_ops: number | null } }> = []; + + for (const row of rows) { + scanned++; + + // `select('*')` (not an explicit column list) — matches + // recalculatePlayerAggregatesAction exactly. Live `baseball_player_stats` + // is missing `hit_by_pitch`/`sacrifice_flies` (schema drift: the columns + // exist on the BaseballPlayerStats TS type but not in the deployed + // table), so an explicit select naming them 400s. `computeCareerSlashLine` + // treats a missing field as 0 via `pick(s) || 0`, which is exactly how + // the app's own recalculation already behaves in production today. + const { data: statsRows, error: statsError } = await supabase + .from('baseball_player_stats') + .select('*') + .eq('player_id', row.player_id) + .eq('team_id', row.team_id) + .returns(); + + if (statsError) { + console.warn(` ⚠ ${row.player_id.slice(0, 8)}/${row.team_id.slice(0, 8)}: failed to load stats — ${statsError.message}`); + errors++; + continue; + } + + const { obp, slg, ops } = computeCareerSlashLine(statsRows ?? []); + + const isChanged = + !valuesEqual(row.career_obp, obp) || + !valuesEqual(row.career_slg, slg) || + !valuesEqual(row.career_ops, ops); + + if (!isChanged) { + unchanged++; + continue; + } + + changed++; + if (samples.length < 5) { + samples.push({ playerId: row.player_id, teamId: row.team_id, before: row, after: { career_obp: obp, career_slg: slg, career_ops: ops } }); + } + + const label = `${row.player_id.slice(0, 8)}/${row.team_id.slice(0, 8)}`; + console.log( + ` ${DRY ? '[DRY] would update' : '✓ updating'} ${label}: ` + + `avg=${fmt(row.career_avg)} obp ${fmt(row.career_obp)}→${fmt(obp)} slg ${fmt(row.career_slg)}→${fmt(slg)} ops ${fmt(row.career_ops)}→${fmt(ops)}` + ); + + if (!DRY) { + const { error: updateError } = await supabase + .from('baseball_player_aggregates') + .update({ career_obp: obp, career_slg: slg, career_ops: ops }) + .eq('player_id', row.player_id) + .eq('team_id', row.team_id); + + if (updateError) { + console.warn(` ⚠ ${label}: update failed — ${updateError.message}`); + errors++; + changed--; + } + } + } + + console.log(`\n${DRY ? '[DRY RUN] ' : ''}Done. scanned=${scanned} changed=${changed} unchanged=${unchanged} errors=${errors}`); + + if (samples.length > 0) { + console.log('\nSample before/after:'); + for (const s of samples) { + console.log( + ` ${s.playerId.slice(0, 8)}/${s.teamId.slice(0, 8)}: ` + + `before(obp=${fmt(s.before.career_obp)}, slg=${fmt(s.before.career_slg)}, ops=${fmt(s.before.career_ops)}) ` + + `after(obp=${fmt(s.after.career_obp)}, slg=${fmt(s.after.career_slg)}, ops=${fmt(s.after.career_ops)})` + ); + } + } + + if (DRY) { + console.log('\nRe-run with --confirm to write.'); + } +} + +main().catch((err) => { + console.error('Backfill failed:', err); + process.exit(1); +}); diff --git a/scripts/baseballhelm-command-center.mjs b/scripts/baseballhelm-command-center.mjs deleted file mode 100644 index c7da0d3fe..000000000 --- a/scripts/baseballhelm-command-center.mjs +++ /dev/null @@ -1,818 +0,0 @@ -#!/usr/bin/env node -/* - * BaseballHelm Ultracode Command Center — local HTTP server + live event hub. - * - * A LOCAL, repo-contained, cream/green "Agent City / Factory Floor" build-observability - * dashboard. NOT part of the shipped product — a localhost tool that watches the - * BaseballHelm build and streams build telemetry to the static UI in - * tools/baseballhelm-command-center/. - * - * Zero dependencies — Node core only (node:http, node:fs, node:path, node:url, node:child_process). - * - * Security posture: - * - Binds 127.0.0.1 ONLY (never 0.0.0.0 / public). - * - Never reads or serves process.env, .env files, or any secret. - * - Static serving is jailed inside the command-center dir; path traversal rejected. - * - All git introspection uses execFile with argument arrays (never a shell), is - * non-destructive, and degrades gracefully on failure. - */ - -import http from "node:http"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { execFile } from "node:child_process"; -import { computeLoc } from "./baseballhelm-loc.mjs"; - -// Lines-of-code written for BaseballHelm (git-based, cached ~15s). -let locCache = { ts: 0, data: { total: 0, files: 0, byCategory: [] } }; -function getLoc() { - const now = Date.now(); - if (locCache.data && now - locCache.ts < 15000) return locCache.data; - try { locCache = { ts: now, data: computeLoc() }; } catch { /* keep last */ } - return locCache.data; -} - -// --------------------------------------------------------------------------- paths -const REPO_ROOT = "/Users/ricknini/Downloads/helmv3"; -const WEB_ROOT = path.join(REPO_ROOT, "tools", "baseballhelm-command-center"); -const DATA_DIR = path.join(REPO_ROOT, ".ultracode", "baseballhelm"); - -const STATE_FILE = path.join(DATA_DIR, "state.json"); -const AGENTS_FILE = path.join(DATA_DIR, "agents.json"); -const PACKETS_FILE = path.join(DATA_DIR, "work-packets.json"); -const RISKS_FILE = path.join(DATA_DIR, "risks.json"); -const QA_FILE = path.join(DATA_DIR, "qa.json"); -const DECISIONS_FILE = path.join(DATA_DIR, "decisions.json"); -const ARTIFACTS_FILE = path.join(DATA_DIR, "artifacts.json"); -const HANDOFF_FILE = path.join(DATA_DIR, "handoff.json"); -const REPLAY_FILE = path.join(DATA_DIR, "replay.json"); -const EVENTS_FILE = path.join(DATA_DIR, "events.ndjson"); - -const HOST = process.env.BBCC_HOST || "127.0.0.1"; // default localhost-only (spec); set BBCC_HOST=0.0.0.0 for same-LAN phone viewing -const PORT_START = 4877; -const PORT_MAX = 4897; -const BOOT_TS = Date.now(); - -// --------------------------------------------------------------------------- helpers: fs / json - -/** Read + parse a JSON file FRESH (no cache). Returns fallback on missing/malformed. */ -function readJSON(file, fallback) { - try { - const raw = fs.readFileSync(file, "utf8"); - const parsed = JSON.parse(raw); - return parsed == null ? fallback : parsed; - } catch { - return fallback; - } -} - -/** Atomically-ish write JSON (best effort; tolerate failure without crashing). */ -function writeJSON(file, value) { - try { - fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n", "utf8"); - return true; - } catch { - return false; - } -} - -/** Read events.ndjson, skipping blank/bad lines; chronological ascending (file order). */ -function readEvents() { - let raw; - try { - raw = fs.readFileSync(EVENTS_FILE, "utf8"); - } catch { - return []; - } - const out = []; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed)); - } catch { - // skip malformed line - } - } - return out; -} - -/** Append one ndjson line to events.ndjson (best effort). */ -function appendEventLine(evt) { - try { - fs.appendFileSync(EVENTS_FILE, JSON.stringify(evt) + "\n", "utf8"); - return true; - } catch { - return false; - } -} - -/** Next event id like "evt-0008" based on the current max numeric suffix. */ -function nextEventId(events) { - let max = 0; - for (const e of events) { - const m = typeof e?.id === "string" && e.id.match(/evt-(\d+)/); - if (m) max = Math.max(max, parseInt(m[1], 10)); - } - return "evt-" + String(max + 1).padStart(4, "0"); -} - -// --------------------------------------------------------------------------- risk classifier -/* - * Shared risk classifier. Given a command string and/or affected paths, return one of - * 'critical' | 'high' | 'medium' | 'low' | 'info'. Rules mirror the V12 adaptation and - * the seeded risks.json rule set. - */ -const HIGH_ATTENTION_PATHS = ["auth", "middleware", "rls", "policy", "supabase/migrations"]; -const HIGH_OR_CRITICAL_COMMANDS = [ - "rm -rf", - "git reset --hard", - "git checkout --", - "force push", - "push --force", - "drop table", - "truncate", -]; -const SECRET_TOKENS = [".env", "secret", "token", "credential"]; -const MEDIUM_TOKENS = ["npm install", "npm i ", "yarn add", "pnpm add", "package install", "generated type", "type churn"]; - -function classifyRisk({ command = "", paths = [], beforeTask0 = false } = {}) { - const cmd = String(command || "").toLowerCase(); - const allPaths = (Array.isArray(paths) ? paths : [paths]).filter(Boolean).map((p) => String(p).toLowerCase()); - - // Product code edits before Task 0 verification => critical (hard guard). - if (beforeTask0 && allPaths.some((p) => p.includes("src/app/baseball"))) return "critical"; - - // Destructive / dangerous commands => high, escalate to critical for the worst. - for (const danger of HIGH_OR_CRITICAL_COMMANDS) { - if (cmd.includes(danger)) { - if (danger === "rm -rf" || danger === "git reset --hard" || danger === "drop table" || danger === "force push" || danger === "push --force") { - return "critical"; - } - return "high"; - } - } - // Broad delete heuristic. - if (/\bdelete\b.*\b(from|where 1=1|--all|\*)\b/.test(cmd) || cmd.includes("delete from")) return "high"; - - // Commands operating outside the repo root => high. - if (cmd && /(^|\s)(cd|rm|cp|mv)\s+\//.test(cmd) && !cmd.includes(REPO_ROOT.toLowerCase())) { - // a leading absolute path that is not the repo root - if (!cmd.includes("/users/ricknini/downloads/helmv3")) return "high"; - } - - // Secret/credential reads => high (treat as sensitive). - if (SECRET_TOKENS.some((t) => cmd.includes(t) || allPaths.some((p) => p.includes(t)))) return "high"; - - // High-attention paths (auth / middleware / rls / policy / migrations) => high. - if (allPaths.some((p) => HIGH_ATTENTION_PATHS.some((h) => p.includes(h)))) return "high"; - - // Package installs / generated type churn => medium. - if (MEDIUM_TOKENS.some((t) => cmd.includes(t))) return "medium"; - - return "info"; -} - -// --------------------------------------------------------------------------- git / repo facts -/* - * Repo facts computed via execFile (argument arrays, NEVER a shell), cwd=REPO_ROOT, - * non-destructive read-only git commands, ~4s per-call timeout. Cached ~3s. - */ -let repoCache = { ts: 0, data: null }; - -function git(args) { - return new Promise((resolve) => { - execFile( - "git", - args, - { cwd: REPO_ROOT, timeout: 4000, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, - (err, stdout) => { - if (err) return resolve(null); - resolve(String(stdout || "")); - } - ); - }); -} - -async function computeRepoFacts() { - const now = Date.now(); - if (repoCache.data && now - repoCache.ts < 3000) return repoCache.data; - - const safe = { branch: "unknown", dirty: [], changed_files: [], diffstat: "", last_commit: "" }; - - const [branchOut, statusOut, diffStatOut, nameOnlyOut, logOut] = await Promise.all([ - git(["rev-parse", "--abbrev-ref", "HEAD"]), - git(["status", "--short"]), - git(["diff", "--stat"]), - git(["diff", "--name-only"]), - git(["log", "-1", "--oneline"]), - ]); - - if (branchOut != null) safe.branch = branchOut.trim() || "unknown"; - - if (statusOut != null) { - safe.dirty = statusOut - .split("\n") - .map((l) => l.replace(/\r$/, "")) - .filter((l) => l.trim().length) - .map((l) => ({ status: l.slice(0, 2).trim(), path: l.slice(3).trim() })); - } - - if (diffStatOut != null) safe.diffstat = diffStatOut.trimEnd(); - - if (nameOnlyOut != null) { - safe.changed_files = nameOnlyOut - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - } - - if (logOut != null) safe.last_commit = logOut.trim(); - - repoCache = { ts: now, data: safe }; - return safe; -} - -// --------------------------------------------------------------------------- aggregate state - -async function buildAggregateState() { - const state = readJSON(STATE_FILE, {}); - const agents = readJSON(AGENTS_FILE, []); - const packets = readJSON(PACKETS_FILE, []); - const risks = readJSON(RISKS_FILE, { rules: {}, cards: [] }); - const qa = readJSON(QA_FILE, { checks: [], honest_states: {} }); - const decisions = readJSON(DECISIONS_FILE, []); - const artifacts = readJSON(ARTIFACTS_FILE, []); - const handoff = readJSON(HANDOFF_FILE, { status: "", read_order: [], next_actions: [], notes: [] }); - const events = readEvents(); - const repo = await computeRepoFacts(); - - // Attach live + child collections onto the aggregate. - const agg = { - ...state, - branch: repo.branch || state.branch || "unknown", - agents: Array.isArray(agents) ? agents : [], - packets: Array.isArray(packets) ? packets : [], - risks: risks && typeof risks === "object" ? risks : { rules: {}, cards: [] }, - qa: qa && typeof qa === "object" ? qa : { checks: [], honest_states: {} }, - decisions: Array.isArray(decisions) ? decisions : [], - artifacts: Array.isArray(artifacts) ? artifacts : [], - handoff: handoff && typeof handoff === "object" ? handoff : { next_actions: [], notes: [] }, - repo, - loc: getLoc(), - counts: { - agents: Array.isArray(agents) ? agents.length : 0, - packets: Array.isArray(packets) ? packets.length : 0, - events: events.length, - risks: Array.isArray(risks?.cards) ? risks.cards.length : 0, - decisions: Array.isArray(decisions) ? decisions.length : 0, - }, - }; - return agg; -} - -// --------------------------------------------------------------------------- SSE clients - -/** @type {Set} */ -const sseClients = new Set(); - -function sseSend(res, event, dataObj) { - try { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(dataObj)}\n\n`); - } catch { - // dead client; will be cleaned on 'close' - } -} - -/** - * Internal append() helper — the single write path used by POST /api/events and - * POST /hooks/claude. Writes the ndjson line AND broadcasts to all SSE clients. - */ -function append(evt) { - appendEventLine(evt); - for (const client of sseClients) sseSend(client, "append", evt); - return evt; -} - -// --------------------------------------------------------------------------- http utils - -const MIME = { - ".html": "text/html; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".md": "text/markdown; charset=utf-8", - ".ico": "image/x-icon", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".webp": "image/webp", - ".txt": "text/plain; charset=utf-8", -}; - -function sendJSON(res, status, obj) { - const body = JSON.stringify(obj); - res.writeHead(status, { - "content-type": "application/json; charset=utf-8", - "content-length": Buffer.byteLength(body), - "cache-control": "no-store", - }); - res.end(body); -} - -function sendText(res, status, text, type = "text/plain; charset=utf-8") { - res.writeHead(status, { "content-type": type, "cache-control": "no-store" }); - res.end(text); -} - -function notFound(res) { - sendText(res, 404, "Not found"); -} - -/** Read a request body as a string, capped to avoid abuse. */ -function readBody(req, limit = 2 * 1024 * 1024) { - return new Promise((resolve) => { - let data = ""; - let aborted = false; - req.on("data", (chunk) => { - if (aborted) return; - data += chunk; - if (data.length > limit) { - aborted = true; - resolve(data.slice(0, limit)); - } - }); - req.on("end", () => { - if (!aborted) resolve(data); - }); - req.on("error", () => resolve(aborted ? data : data)); - }); -} - -// --------------------------------------------------------------------------- static serving (jailed) - -function serveStatic(reqPath, res) { - // Map "/" -> index.html - let rel = reqPath === "/" || reqPath === "" ? "/index.html" : reqPath; - // Decode + strip query already handled by caller. - try { - rel = decodeURIComponent(rel); - } catch { - return notFound(res); - } - - // Resolve inside WEB_ROOT and reject traversal. - const resolved = path.normalize(path.join(WEB_ROOT, rel)); - if (resolved !== WEB_ROOT && !resolved.startsWith(WEB_ROOT + path.sep)) { - return notFound(res); // path traversal attempt - } - // Defensive: never serve .env or dotfiles other than the allowed icon-less set. - if (path.basename(resolved).startsWith(".")) return notFound(res); - - fs.stat(resolved, (err, stat) => { - if (err || !stat.isFile()) return notFound(res); - const ext = path.extname(resolved).toLowerCase(); - const type = MIME[ext] || "application/octet-stream"; - res.writeHead(200, { "content-type": type, "cache-control": "no-store" }); - const stream = fs.createReadStream(resolved); - stream.on("error", () => { - if (!res.headersSent) notFound(res); - else res.end(); - }); - stream.pipe(res); - }); -} - -// --------------------------------------------------------------------------- API handlers - -async function handleState(res) { - const agg = await buildAggregateState(); - sendJSON(res, 200, agg); -} - -function handleEvents(res, url) { - const events = readEvents(); - let limit = parseInt(url.searchParams.get("limit") || "400", 10); - if (!Number.isFinite(limit) || limit <= 0) limit = 400; - const tail = events.slice(-limit); - sendJSON(res, 200, tail); -} - -async function handleRepo(res) { - const repo = await computeRepoFacts(); - sendJSON(res, 200, repo); -} - -function handleReplay(res) { - const replay = readJSON(REPLAY_FILE, { cursor: 0, speed: 1, filters: {} }); - const count = readEvents().length; - sendJSON(res, 200, { ...replay, count }); -} - -function handleArtifacts(res) { - const artifacts = readJSON(ARTIFACTS_FILE, []); - sendJSON(res, 200, Array.isArray(artifacts) ? artifacts : []); -} - -/** Apply the special side effects of a command_center_verified event. */ -function applyVerifiedSideEffects(ts) { - // 1) state.json task0_gate - const state = readJSON(STATE_FILE, {}); - state.task0_gate = { ...(state.task0_gate || {}), verified: true, verified_at: ts, status: "open" }; - state.updated_at = ts; - writeJSON(STATE_FILE, state); - - // 2) work-packets.json — task-0 browser_verified + completion/status - const packets = readJSON(PACKETS_FILE, []); - if (Array.isArray(packets)) { - for (const p of packets) { - if (p && p.id === "task-0") { - p.checklist = { ...(p.checklist || {}), browser_verified: true }; - p.completion_percent = Math.max(100, Number(p.completion_percent) || 0); - p.status = "done"; - p.stage = "done"; - p.updated_at = ts; - } - } - writeJSON(PACKETS_FILE, packets); - } - - // 3) qa.json — chrome-verification check passed - const qa = readJSON(QA_FILE, { checks: [] }); - if (Array.isArray(qa.checks)) { - for (const c of qa.checks) { - if (c && c.id === "chrome-verification") { - c.status = "passed"; - c.last_run = ts; - } - } - qa.updated_at = ts; - writeJSON(QA_FILE, qa); - } -} - -async function handlePostEvent(req, res) { - const raw = await readBody(req); - let body = {}; - try { - body = raw ? JSON.parse(raw) : {}; - } catch { - body = {}; - } - - const ts = new Date().toISOString(); - const events = readEvents(); - const id = nextEventId(events); - - const pctNum = Number(body.pct); - const evt = { - id, - ts, - source: body.source || "manual", - type: body.type || "event", - agent: body.agent || null, - packet: body.packet || null, - title: body.title || "", - detail: body.detail || "", - severity: body.severity || "info", - ...(Number.isFinite(pctNum) ? { pct: pctNum } : {}), - }; - - // Special-case: verification event flips the Task 0 gate + linked records. - if (evt.type === "command_center_verified") { - applyVerifiedSideEffects(ts); - } - // Generic: progress/lifecycle events atomically raise the packet's building - // and mark the reporting agent active. Single-process => serialized, race-free. - applyProgressSideEffects(evt, ts); - - append(evt); - sendJSON(res, 200, evt); -} - -const PROGRESS_TYPES = new Set([ - "packet_started", "packet_progress", "packet_completed", "packet_blocked", "packet_unblocked", - "file_changed", "migration_added", "table_touched", "route_touched", "test_passed", -]); -function applyProgressSideEffects(evt, ts) { - // 1) mark the reporting agent active (so its workers animate in the city) - if (evt.agent) { - const agents = readJSON(AGENTS_FILE, []); - if (Array.isArray(agents)) { - let changed = false; - for (const a of agents) { - if (a && a.id === evt.agent) { - if (evt.type !== "packet_completed") { a.status = "active"; a.heartbeat = "live"; } - a.last_update = ts; - if (evt.title) a.notes = evt.title; - changed = true; - } - } - if (changed) writeJSON(AGENTS_FILE, agents); - } - } - // 2) raise the packet building - const isProgress = PROGRESS_TYPES.has(evt.type) || Number.isFinite(evt.pct); - if (!isProgress || !evt.packet) return; - const packets = readJSON(PACKETS_FILE, []); - if (!Array.isArray(packets)) return; - let p = packets.find((x) => x && x.id === evt.packet); - if (!p) { - // auto-register a building for an unknown packet so live workflows (depth wave, verification, - // future expansion) actually show up in the city instead of being silently dropped. - p = { id: evt.packet, title: evt.title || evt.packet, short: String(evt.title || evt.packet).slice(0, 18), - weight: 6, owner_lane: evt.agent || "orchestrator", status: "active", district: "Component Foundry", - completion_percent: 0, confidence_percent: 0, stage: "in_progress", blocked_reason: null, - checklist: {}, created_at: ts, updated_at: ts }; - packets.push(p); - } - const cur = Number(p.completion_percent) || 0; - if (evt.type === "packet_completed") { p.completion_percent = 100; p.status = "done"; p.stage = "done"; } - else if (evt.type === "packet_blocked") { p.status = "blocked"; p.blocked_reason = evt.detail || "blocked"; } - else if (evt.type === "packet_unblocked") { p.status = "active"; p.blocked_reason = null; } - else { - if (Number.isFinite(evt.pct)) p.completion_percent = Math.max(0, Math.min(100, evt.pct)); - else if (evt.type === "packet_started") p.completion_percent = Math.max(cur, 20); - else p.completion_percent = Math.min(95, cur + 6); // generic nudge (file/route/table/test touches) - if ((p.completion_percent || 0) >= 100) { p.status = "done"; p.stage = "done"; } - else if (p.status !== "blocked") { p.status = "active"; p.stage = "in_progress"; } - } - p.updated_at = ts; - writeJSON(PACKETS_FILE, packets); -} - -async function handleHook(req, res) { - // ALWAYS respond 200, even on bad input. - const raw = await readBody(req); - let payload = {}; - try { - payload = raw ? JSON.parse(raw) : {}; - } catch { - payload = {}; - } - if (payload == null || typeof payload !== "object") payload = {}; - - const hookName = - payload.hook_event_name || - payload?.payload?.hook_event_name || - "hook"; - - // Extract a command + affected paths for the risk classifier where available. - const command = - payload.tool_input?.command || - payload.command || - payload?.payload?.tool_input?.command || - ""; - const paths = [] - .concat(payload.tool_input?.file_path || []) - .concat(payload.tool_input?.paths || []) - .concat(payload.affected_paths || []) - .filter(Boolean); - - // Is Task 0 still un-verified? (drives the product-code critical guard) - const state = readJSON(STATE_FILE, {}); - const beforeTask0 = !state?.task0_gate?.verified; - - const severity = classifyRisk({ command, paths, beforeTask0 }); - - const ts = new Date().toISOString(); - const events = readEvents(); - const id = nextEventId(events); - - const detailBits = []; - if (command) detailBits.push(`cmd: ${String(command).slice(0, 160)}`); - if (paths.length) detailBits.push(`paths: ${paths.slice(0, 4).join(", ")}`); - if (payload.tool_name) detailBits.push(`tool: ${payload.tool_name}`); - const detail = detailBits.join(" · ") || "Claude Code hook received."; - - const evt = { - id, - ts, - source: "claude-hook", - type: hookName, - agent: "claude-hook", - packet: payload.packet || null, - title: `Hook: ${hookName}`, - detail, - severity, - raw: payload, // preserve the original payload - }; - - append(evt); - - // High/critical severities also land a card on risks.json. - if (severity === "high" || severity === "critical") { - const risks = readJSON(RISKS_FILE, { rules: {}, cards: [] }); - if (!Array.isArray(risks.cards)) risks.cards = []; - risks.cards.push({ - id: "risk-hook-" + id, - level: severity, - category: "hook", - title: `Hook flagged ${severity}: ${hookName}`, - status: "noted", - description: detail, - affected_paths: paths, - command: command || null, - checkpoint_ref: null, - timestamp: ts, - mitigation: "Review the flagged hook action before proceeding.", - }); - writeJSON(RISKS_FILE, risks); - } - - sendJSON(res, 200, { ok: true, event: evt }); -} - -async function handleSSE(req, res) { - res.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - connection: "keep-alive", - "x-accel-buffering": "no", - }); - - // Greet the client. - sseSend(res, "hello", { ts: new Date().toISOString(), port: server._chosenPort, uptime_s: Math.round((Date.now() - BOOT_TS) / 1000) }); - - sseClients.add(res); - - // Periodic tick: repo facts + keep-alive comment. - const tick = setInterval(async () => { - try { - const repo = await computeRepoFacts(); - sseSend(res, "tick", { repo }); - res.write(":\n\n"); // keep-alive comment - } catch { - // ignore tick errors - } - }, 5000); - - const cleanup = () => { - clearInterval(tick); - sseClients.delete(res); - }; - req.on("close", cleanup); - req.on("error", cleanup); - res.on("error", cleanup); -} - -// --------------------------------------------------------------------------- router - -const server = http.createServer(async (req, res) => { - let url; - try { - url = new URL(req.url, `http://${HOST}`); - } catch { - return notFound(res); - } - const pathname = url.pathname; - const method = req.method || "GET"; - - try { - // ---- API - if (method === "GET" && pathname === "/api/health") { - return sendJSON(res, 200, { - ok: true, - ts: new Date().toISOString(), - port: server._chosenPort, - uptime_s: Math.round((Date.now() - BOOT_TS) / 1000), - repo: REPO_ROOT, - }); - } - if (method === "GET" && pathname === "/api/state") return await handleState(res); - if (method === "GET" && pathname === "/api/events") return handleEvents(res, url); - if (method === "GET" && pathname === "/api/repo") return await handleRepo(res); - if (method === "GET" && pathname === "/api/loc") return sendJSON(res, 200, getLoc()); - if (method === "GET" && pathname === "/api/replay") return handleReplay(res); - if (method === "GET" && pathname === "/api/artifacts") return handleArtifacts(res); - - if (method === "POST" && pathname === "/api/events") return await handlePostEvent(req, res); - if (method === "POST" && pathname === "/hooks/claude") return await handleHook(req, res); - - // ---- SSE - if (method === "GET" && pathname === "/events") return await handleSSE(req, res); - - // ---- static (GET/HEAD only) - if (method === "GET" || method === "HEAD") return serveStatic(pathname, res); - - return notFound(res); - } catch (err) { - // Never crash the server on a single bad request. - if (!res.headersSent) sendJSON(res, 500, { ok: false, error: String(err && err.message ? err.message : err) }); - else - try { - res.end(); - } catch { - /* noop */ - } - } -}); - -server._chosenPort = PORT_START; - -// --------------------------------------------------------------------------- listen with auto-increment - -function listenWithFallback(port) { - server.removeAllListeners("error"); - server.once("error", (err) => { - if (err && err.code === "EADDRINUSE" && port < PORT_MAX) { - listenWithFallback(port + 1); - } else { - console.error(`[command-center] failed to bind ${HOST}:${port} — ${err && err.message}`); - process.exit(1); - } - }); - server.listen(port, HOST, () => { - server._chosenPort = port; - const urlStr = `http://${HOST}:${port}`; - console.log(`\n BaseballHelm Ultracode Command Center`); - console.log(` → ${urlStr}`); - console.log(` serving ${WEB_ROOT}`); - console.log(` telemetry ${DATA_DIR}\n`); - - // Write the chosen URL back into state.json. - const state = readJSON(STATE_FILE, {}); - state.url = urlStr; - writeJSON(STATE_FILE, state); - - // Log a boot event (write + broadcast through the shared append path). - const events = readEvents(); - append({ - id: nextEventId(events), - ts: new Date().toISOString(), - source: "server", - type: "command_center_server_started", - agent: "agent-city-systems", - packet: "task-0", - title: "Command center server started", - detail: `Listening on ${urlStr}`, - severity: "info", - }); - }); -} - -listenWithFallback(PORT_START); - -// --------------------------------------------------------------------------- live LOC-delta emitter -// Broadcasts a synthetic feed line whenever real (git-tracked) baseball code grows, -// so the Agent Floor / City stream continuously DURING any build — tied to ACTUAL -// file writes, regardless of how chatty the agents are. Ephemeral: broadcast-only -// (never written to events.ndjson) so the persisted build log stays clean. -let __locPrev = null; -function broadcastEphemeral(evt) { - for (const client of sseClients) sseSend(client, "append", evt); -} -setInterval(() => { - try { - const loc = getLoc(); - if (!loc || typeof loc.added !== "number") return; - if (__locPrev === null) { __locPrev = loc; return; } // seed baseline; no emit on first read - const dAdded = loc.added - __locPrev.added; - const dFiles = (loc.addedFiles || 0) - (__locPrev.addedFiles || 0); - if (dAdded <= 0) { __locPrev = loc; return; } // only emit on real growth - // which layer grew the most since the last read? - const prevCat = new Map((__locPrev.byCategory || []).map((c) => [c.key, c.lines])); - let topCat = null, topDelta = 0; - for (const c of loc.byCategory || []) { - const d = c.lines - (prevCat.get(c.key) || 0); - if (d > topDelta) { topDelta = d; topCat = c.key; } - } - const where = topCat ? ` · ${topCat} +${topDelta}` : ""; - const fileWord = Math.abs(dFiles) === 1 ? "file" : "files"; - broadcastEphemeral({ - id: -1, // ephemeral marker (not persisted, never toasts) - ts: new Date().toISOString(), - source: "loc-tracker", - type: "packet_progress", - agent: "loc-tracker", - packet: "loc", - title: `+${dAdded.toLocaleString()} lines written${where}`, - detail: `${loc.total.toLocaleString()} total · ${dFiles > 0 ? dFiles + " " + fileWord + " touched" : "across the build"}`, - severity: "info", - }); - __locPrev = loc; - } catch { /* ignore */ } -}, 6000); - -// Graceful shutdown. -function shutdown() { - for (const client of sseClients) { - try { - client.end(); - } catch { - /* noop */ - } - } - try { - server.close(); - } catch { - /* noop */ - } - process.exit(0); -} -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); diff --git a/scripts/baseballhelm-loc.mjs b/scripts/baseballhelm-loc.mjs deleted file mode 100644 index ff582c004..000000000 --- a/scripts/baseballhelm-loc.mjs +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -/* baseballhelm-loc.mjs — counts lines of CODE written for BaseballHelm. - "Written" = every NEW (untracked) file in the baseball footprint counted in - full, PLUS added lines in any tracked baseball file we modified (git numstat). - Excludes the vendored PixiJS (not ours), node_modules, .next, and non-code - files (docs/json/data). Exports computeLoc() for the command-center server; - also runs standalone to print a summary. Read-only; never touches a DB. */ -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import path from "node:path"; - -const REPO = "/Users/ricknini/Downloads/helmv3"; -const CODE_EXT = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".css", ".scss", ".html", ".sql"]); - -function category(rel) { - if (rel.startsWith("tools/baseballhelm-command-center")) return "Command Center"; - if (rel.startsWith("scripts/baseballhelm-")) return "Build scripts"; - if (rel.startsWith("supabase/migrations")) return "Migrations"; - if (rel.startsWith("supabase/tests")) return "RLS tests"; - if (rel.startsWith("src/app/baseball")) return "App routes"; - if (rel.startsWith("src/components/baseball")) return "Components"; - if (rel.startsWith("src/lib/baseball") || rel === "src/lib/types/baseball-extended.ts") return "Libraries"; - if (rel.startsWith("src/hooks")) return "Hooks"; - return "Other"; -} -function isBaseballCode(rel) { - if (!rel || rel.includes("/vendor/") || rel.includes("node_modules") || rel.includes("/.next/")) return false; - if (!CODE_EXT.has(path.extname(rel))) return false; - if (rel.startsWith("tools/baseballhelm-command-center")) return true; - if (rel.startsWith("scripts/baseballhelm-")) return true; - if (rel.startsWith("src/app/baseball")) return true; - if (rel.startsWith("src/components/baseball")) return true; - if (rel.startsWith("src/lib/baseball")) return true; - if (rel === "src/lib/types/baseball-extended.ts") return true; - if (rel.startsWith("src/hooks") && /baseball/i.test(rel)) return true; - if (rel.startsWith("supabase/migrations") && /baseball/i.test(rel)) return true; - if (rel.startsWith("supabase/tests") && /baseball/i.test(rel)) return true; - return false; -} -function git(args) { - try { return execFileSync("git", args, { cwd: REPO, maxBuffer: 1e8 }).toString(); } catch { return ""; } -} -function fileLineCount(rel) { - try { const t = readFileSync(path.join(REPO, rel), "utf8"); if (!t) return 0; return t.split("\n").length - (t.endsWith("\n") ? 1 : 0); } catch { return 0; } -} - -// The BaseballHelm PRODUCT (app/components/lib/migrations/types) — excludes the -// command-center build tooling + build scripts, which are the dashboard, not the app. -function isBaseballProduct(rel) { - if (!isBaseballCode(rel)) return false; - if (rel.startsWith("tools/baseballhelm-command-center")) return false; - if (rel.startsWith("scripts/baseballhelm-")) return false; - return true; -} - -// --- TOTAL PRODUCT COMPLETION estimate --------------------------------------- -// Weighted across the WHOLE BaseballHelm subsystem inventory, not just the build -// phases: existing shipped features (already work) + surfaces under active build -// (live % from telemetry packets) + zip-mandated depth not started yet. Weights are -// rough size/importance (1-10). An honest estimate, transparent + telemetry-driven. -const SUBSYSTEMS = [ - // shipped: already in the repo and working (existing BaseballHelm product) - { key: "Auth & onboarding", w: 8, shipped: 90 }, - { key: "Roster & membership", w: 5, shipped: 92 }, - { key: "Team management", w: 5, shipped: 90 }, - { key: "Messaging", w: 4, shipped: 90 }, - { key: "Calendar & events", w: 6, shipped: 88 }, - { key: "Announcements", w: 2, shipped: 95 }, - { key: "Tasks", w: 2, shipped: 95 }, - { key: "Documents", w: 2, shipped: 95 }, - { key: "Travel", w: 2, shipped: 90 }, - { key: "Recruiting suite", w: 8, shipped: 90 }, - { key: "Player profiles & public", w: 5, shipped: 88 }, - { key: "Box scores & games", w: 4, shipped: 85 }, - { key: "Basic stats", w: 4, shipped: 80 }, - { key: "Development plans", w: 3, shipped: 85 }, - { key: "Academics", w: 2, shipped: 80 }, - // in build: live completion from the telemetry packets - { key: "Coach Command Center", w: 7, packet: "coach-command" }, - { key: "Player Today", w: 5, packet: "player-today" }, - { key: "Player timeline", w: 4, packet: "roster-timeline" }, - { key: "Import Center", w: 7, packet: "source-registry" }, - { key: "Stats Center", w: 6, packet: "stats-center" }, - { key: "Practice Planner", w: 6, packet: "practice-planner" }, - { key: "Performance / Lifting", w: 8, packet: "performance-os" }, - { key: "Staff roles & invites", w: 6, packet: "staff-roles" }, - { key: "Staff Decision Room", w: 5, packet: "decision-room" }, - { key: "CoachHelm engine", w: 9, packet: "coachhelm-intel" }, - { key: "Demo seed + QA", w: 4, packet: "qa-screens" }, - // pending: zip-mandated depth subsystems (go live when the depth wave posts them) - { key: "Signal Inbox + actions", w: 6, packet: "signal-inbox" }, - { key: "Source Trust UI", w: 4, packet: "source-trust" }, - { key: "Postgame Action Review", w: 5, packet: "postgame-review" }, - { key: "Practice Intelligence", w: 4, packet: "practice-intel" }, - { key: "Practice Effectiveness", w: 4, packet: "practice-effectiveness" }, - { key: "Elite stats + visuals", w: 8, packet: "elite-stats" }, - { key: "Settings OS + variants", w: 4, packet: "settings-os" }, - { key: "Video & classes", w: 4, packet: "video-classes" }, - { key: "Player Passport", w: 4, packet: "player-passport" }, - { key: "Premium lifting (V11)", w: 6, packet: "premium-lifting" }, -]; -function readPackets() { - try { return JSON.parse(readFileSync(path.join(REPO, ".ultracode/baseballhelm/work-packets.json"), "utf8")); } catch { return []; } -} -export function computeCompletion() { - const byId = new Map(readPackets().map((p) => [p.id, p])); - const groups = { shipped: { w: 0, c: 0 }, build: { w: 0, c: 0 }, pending: { w: 0, c: 0 } }; - let wsum = 0, csum = 0; - for (const s of SUBSYSTEMS) { - let comp, grp; - if (typeof s.shipped === "number") { comp = s.shipped; grp = "shipped"; } - else if (s.packet && byId.has(s.packet)) { comp = byId.get(s.packet).completion_percent || 0; grp = "build"; } - else { comp = 0; grp = "pending"; } - wsum += s.w; csum += s.w * comp; - groups[grp].w += s.w; groups[grp].c += s.w * comp; - } - const g = (k) => groups[k].w ? Math.round(groups[k].c / groups[k].w) : 0; - return { pct: wsum ? Math.round(csum / wsum) : 0, shipped_pct: g("shipped"), build_pct: g("build"), pending_pct: g("pending"), subsystems: SUBSYSTEMS.length }; -} - -export function computeLoc() { - // --- TOTAL: every BaseballHelm product file (tracked + untracked), counted in full. - // This is the real size of the product (~tens of thousands), not just this session. - const allFiles = new Set(); - for (const rel of git(["ls-files"]).split("\n").filter(Boolean)) if (isBaseballProduct(rel)) allFiles.add(rel); - for (const rel of git(["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean)) if (isBaseballProduct(rel)) allFiles.add(rel); - const cats = {}; - let total = 0; - for (const rel of allFiles) { - const n = fileLineCount(rel); - if (n <= 0) continue; - const c = category(rel); - if (!cats[c]) cats[c] = { files: 0, lines: 0 }; - cats[c].files += 1; cats[c].lines += n; total += n; - } - // --- ADDED THIS BUILD: new (untracked) product files in full + added lines in modified ones. - let added = 0; const addedFiles = new Set(); - for (const rel of git(["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean)) { - if (isBaseballProduct(rel)) { const n = fileLineCount(rel); if (n > 0) { added += n; addedFiles.add(rel); } } - } - for (const line of git(["diff", "--numstat", "HEAD"]).split("\n").filter(Boolean)) { - const [a, , rel] = line.split("\t"); - if (rel && a !== "-" && isBaseballProduct(rel)) { const n = parseInt(a, 10) || 0; if (n > 0) { added += n; addedFiles.add(rel); } } - } - const byCategory = Object.entries(cats).map(([key, v]) => ({ key, files: v.files, lines: v.lines })).sort((a, b) => b.lines - a.lines); - const c = computeCompletion(); - return { total, files: allFiles.size, added, addedFiles: addedFiles.size, byCategory, completion: c.pct, completionBreakdown: c, generated_at: new Date().toISOString() }; -} - -// CLI -if (import.meta.url === `file://${process.argv[1]}`) { - const loc = computeLoc(); - const pad = (s, n) => String(s).padEnd(n); - console.log("\n BaseballHelm — lines of code written"); - console.log(" ────────────────────────────────────"); - for (const c of loc.byCategory) console.log(` ${pad(c.key, 18)} ${pad(c.files + " files", 11)} ${c.lines.toLocaleString()} lines`); - console.log(" ────────────────────────────────────"); - console.log(` ${pad("TOTAL (product)", 18)} ${pad(loc.files + " files", 11)} ${loc.total.toLocaleString()} lines`); - console.log(` ${pad("+ this build", 18)} ${pad(loc.addedFiles + " files", 11)} ${loc.added.toLocaleString()} lines added\n`); -} diff --git a/scripts/e2e-supabase-admin.ts b/scripts/e2e-supabase-admin.ts new file mode 100644 index 000000000..e60206299 --- /dev/null +++ b/scripts/e2e-supabase-admin.ts @@ -0,0 +1,26 @@ +/** + * scripts/e2e-supabase-admin.ts + * + * Service-role Supabase client for E2E teardown-only writes (deleting rows a + * spec itself created). Same construction pattern as + * `scripts/seed-baseball-e2e.ts` — `NEXT_PUBLIC_SUPABASE_URL` + + * `SUPABASE_SERVICE_ROLE_KEY`, session-less. + * + * Lives in scripts/ (never bundled, Node-only) so no e2e spec file has to + * reference the service-role env var directly — the ast-grep rule + * `helmv3-no-service-role-key` forbids that outside admin-only paths. + * + * Returns `null` (callers' teardown becomes a no-op) rather than throwing + * when either env var is missing — cleanup must never fail an + * otherwise-passing run, and CI only exports `PLAYWRIGHT_BASEBALL_SEEDED=1` + * (which gates the specs that use this) alongside the service-role secret + * in the first place. + */ +import { createClient } from '@supabase/supabase-js'; + +export function getE2eAdminClient() { + const url = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').trim(); + const key = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? '').trim(); + if (!url || !key) return null; + return createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); +} diff --git a/scripts/seed-rini-baseball-demo.ts b/scripts/seed-rini-baseball-demo.ts index 43b2c5da2..98ab50868 100644 --- a/scripts/seed-rini-baseball-demo.ts +++ b/scripts/seed-rini-baseball-demo.ts @@ -87,6 +87,56 @@ function dateDaysFromNow(days: number): string { return dateDaysAgo(-days); } +// Team-local wall-clock time -> UTC instant, DST-safe. Mirrors the two-pass +// offset-resolution algorithm in src/lib/baseball/daily-contract/contract-day.ts +// (localMidnightUtcMs / tzOffsetMinutesAt) but self-contained here — those are +// module-private, and this script only needs a fixed-clock variant of the same +// idea (an arbitrary hh:mm instead of always midnight). +// +// THE GAP THIS CLOSES (Coherence Ruling 4): every seeded baseball_events row +// previously set `end_time: isoDaysAgo(n)` == `start_time: isoDaysAgo(n)` — +// literally the same instant, so every seeded event rendered as a +// zero-duration block. It also anchored on the SERVER's UTC day +// (`isoDaysAgo` mutates UTC date fields), not a real team-local wall-clock +// time, so the seeded practice/game/meeting didn't land in a believable local +// hour. This gives each event a realistic team-local start/end on its +// intended calendar date, in the team's default tz (America/New_York — same +// default `resolveTeamTimezone` falls back to when a team has no timezone +// row). +const TEAM_TZ = 'America/New_York'; + +function tzOffsetMinutesAt(utcMs: number, tz: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date(utcMs)); + const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? NaN); + const asUtcMs = Date.UTC( + get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'), + ); + return (asUtcMs - utcMs) / 60_000; +} + +/** + * UTC ISO instant for `hh:mm` team-local wall-clock time on `dateIso` + * (YYYY-MM-DD, itself a tz-independent bare calendar date from + * dateDaysAgo/dateDaysFromNow). DST-safe two-pass offset resolution. + */ +function localClockIso(dateIso: string, hh: number, mm: number, tz: string = TEAM_TZ): string { + const [y, m, d] = dateIso.split('-').map(Number); + const naiveGuessMs = Date.UTC(y, m - 1, d, hh, mm, 0); + const offset1 = tzOffsetMinutesAt(naiveGuessMs, tz); + const refinedGuessMs = naiveGuessMs - offset1 * 60_000; + const offset2 = tzOffsetMinutesAt(refinedGuessMs, tz); + return new Date(naiveGuessMs - offset2 * 60_000).toISOString(); +} + // --- Upsert wrapper (tolerates missing schema by skipping) ------------------ type Counts = Record; const counts: Counts = {}; @@ -214,13 +264,16 @@ async function main() { await upsert('baseball_team_members', memberRows); // --- 4. Events ---------------------------------------------------------- + // Team-local times (never a zero-duration start==end): practice 15:30-17:30, + // game 13:00-16:00, meeting 12:00-13:00, each anchored to its intended + // calendar date via localClockIso (America/New_York, DST-safe). const practiceEventId = detId('event:practice'); const gameEventId = detId('event:game'); const meetingEventId = detId('event:meeting'); await upsert('baseball_events', [ - { id: practiceEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Defense + Live BP', description: 'Full-squad practice. Mandatory.', event_type: 'practice', location: 'Rini Field', start_time: isoDaysAgo(-1), end_time: isoDaysAgo(-1), is_mandatory: true }, - { id: gameEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'vs Coastal State', description: 'Conference matchup.', event_type: 'game', location: 'Rini Field', start_time: isoDaysAgo(-3), end_time: isoDaysAgo(-3), is_mandatory: true }, - { id: meetingEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Meeting — Travel Logistics', description: 'Read receipts required.', event_type: 'meeting', location: 'Film Room', start_time: isoDaysAgo(-2), end_time: isoDaysAgo(-2), is_mandatory: true }, + { id: practiceEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Practice — Defense + Live BP', description: 'Full-squad practice. Mandatory.', event_type: 'practice', location: 'Rini Field', start_time: localClockIso(dateDaysFromNow(1), 15, 30), end_time: localClockIso(dateDaysFromNow(1), 17, 30), is_mandatory: true }, + { id: gameEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'vs Coastal State', description: 'Conference matchup.', event_type: 'game', location: 'Rini Field', start_time: localClockIso(dateDaysFromNow(3), 13, 0), end_time: localClockIso(dateDaysFromNow(3), 16, 0), is_mandatory: true }, + { id: meetingEventId, team_id: TEAM_ID, created_by: COACH_ID, title: 'Team Meeting — Travel Logistics', description: 'Read receipts required.', event_type: 'meeting', location: 'Film Room', start_time: localClockIso(dateDaysFromNow(2), 12, 0), end_time: localClockIso(dateDaysFromNow(2), 13, 0), is_mandatory: true }, ]); // --- 5. Games (6 final + 2 scheduled) ----------------------------------- diff --git a/src/app/baseball/(coach-dashboard)/coach/layout.tsx b/src/app/baseball/(coach-dashboard)/coach/layout.tsx deleted file mode 100644 index eae5bc223..000000000 --- a/src/app/baseball/(coach-dashboard)/coach/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; - -export default function CoachDashboardLayout({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/src/app/baseball/(coach-dashboard)/coach/template.tsx b/src/app/baseball/(coach-dashboard)/coach/template.tsx deleted file mode 100644 index 1b932dfd8..000000000 --- a/src/app/baseball/(coach-dashboard)/coach/template.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client'; - -/** - * Coach route template — retained for the coach route group shell boundary. - * - * The legacy per-coach-type pages under /baseball/coach/* were removed once the - * canonical Fairway dashboard routes were wired. This template stays with the - * group so any future coach-owned subroutes inherit the same reduced-motion - * route reveal as the dashboard and player groups. - * - * Ported VERBATIM in spirit from the sibling templates - * ((dashboard)/dashboard/template.tsx, (player-dashboard)/player/template.tsx), - * which themselves track the GolfHelm route-reveal — same curve, same duration, - * same opacity-only recipe — so all four BaseballHelm dashboard groups transition - * identically. Do NOT diverge the recipe here; uniform motion across the groups is - * the whole point of the fix. - * - * Self-contained motion provider: the BaseballDashboardShell does not mount a motion - * provider, so this template wraps its own LazyMotion (tree-shaken `domAnimation`) + - * MotionConfig. No shell edit required — the fix stays inside this group's file - * ownership. - * - * Recipe — matches the canonical Fairway RouteTransition primitive EXACTLY: - * - Opacity-ONLY crossfade 0 → 1 over --fw-dur-base (280ms) - * - --fw-ease-glide = cubic-bezier(0.16, 1, 0.3, 1) (the iOS out-quint) - * - * Why opacity-only (no slide / no `will-change: transform`): a transform value — or - * a persistent `will-change: transform` — establishes a CSS containing block, which - * re-anchors every `position: fixed` descendant (peek panels, the mobile sidebar - * overlay, any in-tree action bar) to THIS wrapper instead of the viewport. A pure - * crossfade sidesteps that hazard entirely while staying premium on the glide curve. - * - * Keyed on pathname (NOT search params): switching a coach vertical's in-page mode - * via query string (e.g. JUCO recruit↔team toggle, Showcase team selection) keeps - * the same route segment, so it does NOT re-fire the reveal — only true cross-home - * navigation crossfades, which is the intended behavior. - * - * Reduced-motion: honored TWO ways — MotionConfig reducedMotion="user" reads the OS - * preference platform-wide, and useReducedMotion() collapses this reveal to a faster - * linear fade. No baseball-specific in-app animation toggle exists yet, so the OS - * preference is the source of truth. - */ - -import { LazyMotion, domAnimation, MotionConfig, m, useReducedMotion } from 'framer-motion'; -import { usePathname } from 'next/navigation'; - -// --fw-ease-glide = cubic-bezier(0.16, 1, 0.3, 1); --fw-dur-base = 280ms. -const GLIDE = [0.16, 1, 0.3, 1] as const; -const DURATION = 0.28; - -export default function BaseballCoachDashboardTemplate({ - children, -}: { - children: React.ReactNode; -}) { - // No `strict` on LazyMotion: descendant pages may render the full `motion.*` - // component, which LazyMotion-strict forbids. Non-strict lets them load their own - // features while this template stays on the tree-shaken `m`. - return ( - - - {children} - - - ); -} - -function RouteReveal({ children }: { children: React.ReactNode }) { - const prefersReducedMotion = useReducedMotion(); - const pathname = usePathname(); - return ( - - {children} - - ); -} diff --git a/src/app/baseball/(coach-dashboard)/error.tsx b/src/app/baseball/(coach-dashboard)/error.tsx deleted file mode 100644 index fe552ad02..000000000 --- a/src/app/baseball/(coach-dashboard)/error.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -/** - * Group-level error boundary for the coach dashboard route group. - * - * Catches render-time throws in ANY coach segment (college / high-school / - * juco / showcase). These type-specific routes now redirect into the main - * dashboard shell, but the group still needs a local boundary. - * - * The route group previously had no error.tsx at its root, so a render-time - * throw bubbled unhandled past the group to the app root. This is the safety - * net for stale bookmarks and transitional route failures. - */ - -import { RouteErrorBoundary } from '@/components/errors'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - - ); -} diff --git a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx index 5a36f8abf..a6a233e7c 100644 --- a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx +++ b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx @@ -2,39 +2,32 @@ /** * ============================================================================ - * BaseballFairwayShell (ADDITIVE · FLAG-GATED) — Fairway migration Phase A + * BaseballFairwayShell — the ONE dashboard frame for BaseballHelm * ---------------------------------------------------------------------------- - * The flag-ON dashboard frame for the generic `/baseball/(dashboard)` route - * group. Mirrors GolfHelm's `FairwayDashboardShell` playbook exactly: a full, - * standalone replacement for the legacy shell composition (not a wrapper - * around it) that renders the shared Fairway `` — the warm-black - * recessive rail on desktop, a slide-in glass drawer on mobile, the one glass - * top bar — in place of the legacy `BaseballDashboardShell`. + * Mounted unconditionally by the generic `/baseball/(dashboard)` and + * `/baseball/player` route-group layouts (Coherence Ruling 1, 2026-07-08 — + * see docs/baseball/COHERENCE_RULING_2026-07-08.md). Mirrors GolfHelm's + * `FairwayDashboardShell` playbook: renders the shared Fairway `` — + * the warm-black recessive rail on desktop, a slide-in glass drawer on + * mobile, the one glass top bar. * * PRESENTATION ONLY. No server actions, no RLS, no new reads beyond what the * existing baseball auth/nav hooks already resolve: - * - useBaseballAuth(requiredRole) — the SAME session/onboarding gate - * BaseballShellLayout uses for each mounted route group. + * - useBaseballAuth(requiredRole) — the SAME session/onboarding gate every + * mounted route group uses. * - useBaseballNavContext() — the SAME server-resolved capability map - * (nav-context.ts), so capability-gated verticals never fail-closed here - * when they wouldn't in the legacy shell. + * (nav-context.ts), so capability-gated verticals never fail-closed here. * - getVisibleBaseballNav() — the #383 capability-gated nav-registry * single source of truth (nav-registry.ts). NavSections are built from * this, never a hardcoded route list, so this shell can't drift from (or - * duplicate) what Sidebar / MobileBottomNav / CommandPalette already read. + * duplicate) what MobileBottomNav / CommandPalette already read. * - * PROVIDER STACK — kept VERBATIM from BaseballShellLayout (the shared - * composition point for all three BaseballHelm shell route groups): the same - * SidebarProvider > SessionActivityProvider > LastSeenUpdater > - * PeekPanelProvider nesting, unchanged. BaseballShellLayout.tsx itself is not - * imported or edited — this file is a parallel, full duplicate of that - * composition (same reason GolfHelm's FairwayDashboardShell duplicates - * GolfDashboardShell's stack rather than wrapping it). - * - * Mounted ONLY behind isRedesignEnabled() in the Baseball dashboard/player - * route-group layouts. Flag OFF renders the legacy `BaseballShellLayout` → - * `BaseballDashboardShell`, byte-for-byte unchanged. + * PROVIDER STACK: SidebarProvider > SessionActivityProvider > LastSeenUpdater + * > PeekPanelProvider — the same nesting the legacy `BaseballShellLayout` / + * `BaseballDashboardShell` composition used before it was deleted (Ruling 1 / + * Ruling 5). This file is the sole surviving shell for BaseballHelm. * + * The AppShell drawer (`mobileOpen`) is BRIDGED to the SAME SidebarContext * every legacy baseball page's own menu button already calls `setMobileOpen` * against, so a not-yet-migrated page opens the SAME drawer. One nav surface, @@ -47,7 +40,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { AppShell, FairwayBottomNav, useSidebarCollapsed } from '@/components/fairway/app-shell'; -import type { Breadcrumb, NavItem, NavSection, ShellLinkComponent } from '@/components/fairway/app-shell'; +import type { NavItem, NavSection, ShellLinkComponent } from '@/components/fairway/app-shell'; import { SidebarProvider, useSidebar } from '@/contexts/sidebar-context'; import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider'; @@ -88,6 +81,11 @@ import { } from '@/app/baseball/(dashboard)/_components/resolve-active-hub'; import { HubSubNav } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; +import { + BreadcrumbLabelProvider, + useBreadcrumbLabel, +} from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; +import { buildBreadcrumbs } from '@/app/baseball/(dashboard)/_components/breadcrumbs'; import { IconSettings, IconLogout, IconHome, IconUsers, IconCalendar } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -104,14 +102,20 @@ type Role = 'coach' | 'player'; /** * P413-equivalent: mobile bottom-tab destinations derived from the SAME hub * sections as the desktop rail so active states agree across breakpoints. - * Golf FairwayDashboardShell uses the same pattern (5 tabs, hub activeMatch). + * Golf FairwayDashboardShell uses the same pattern (hub activeMatch). + * + * Ruling 2 (item 8): exactly the top 3 highest-frequency destinations per + * role — the mobile drawer (opened via the top bar's hamburger, bridged to + * the SAME SidebarContext this shell renders) is the "+ More" the ruling + * describes, not a 4th bottom-tab button, so 3 named + the always-present + * hamburger covers every remaining hub. */ function buildBottomNavFromSections(sections: NavSection[], role: Role): NavItem[] { const items = sections.flatMap((section) => section.items); const preferredLabels = role === 'coach' - ? (['Dashboard', 'Team', 'Stats & Performance', 'Messages'] as const) - : (['Today', 'Stats', 'Development', 'Team', 'Messages'] as const); + ? (['Dashboard', 'Team', 'Stats & Performance'] as const) + : (['Today', 'Schedule', 'Messages'] as const); const picked = preferredLabels .map((label) => items.find((item) => item.label === label)) @@ -258,23 +262,38 @@ function buildCoachHubSections(ctx: BaseballNavContext, unreadCount: number): Na href: visibleTabs[0]!.href, icon: def.icon as unknown as NavItem['icon'], tabs: visibleTabs, + // Messages is now a real hub (Ruling 2: Messages · Announcements) — + // the unread badge is the one piece of state that stays outside the + // registry-derived tab list, so it's threaded in here by hub id. + badge: hubId === 'messages' && unreadCount > 0 ? unreadCount : undefined, }), ); - - // Messages is the persistent cross-cutting slot, outside the hub registry. - if (hubId === 'dashboard') { - items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href])); - } } return [{ heading: 'Baseball', items }]; } +/** + * The Messages hub rail item (Ruling 2: a real hub with an Announcements + * subtab, not a bare flat link) — shared by the showcase org/team rails below + * so a showcase coach's Messages click ALSO opens the sub-nav strip, exactly + * like the main coach rail's (`buildCoachHubSections`) Messages entry. + */ +function messagesNavItem(unreadCount: number): NavItem { + return playerHubToNavItem({ + label: COACH_HUB_DEFS.messages.label, + href: COACH_HUB_DEFS.messages.tabs[0]!.href, + icon: COACH_HUB_DEFS.messages.icon as unknown as NavItem['icon'], + tabs: COACH_HUB_DEFS.messages.tabs, + badge: unreadCount > 0 ? unreadCount : undefined, + }); +} + /** * Showcase ORG-level rail (no team selected yet) — the documented two-level * org→team exception (COACH_NAV_8TAB_PROPOSAL.md): org-wide Dashboard/Teams/ * Events, mirroring src/components/layout/sidebar.tsx's `showcaseOrgNav` - * exactly (same routes/icons/order), plus the persistent Messages slot. + * exactly (same routes/icons/order), plus the persistent Messages hub. */ function buildShowcaseOrgSections(unreadCount: number): NavSection[] { return [ @@ -284,7 +303,7 @@ function buildShowcaseOrgSections(unreadCount: number): NavSection[] { { label: 'Dashboard', href: '/baseball/dashboard/organization', icon: IconHome }, { label: 'Teams', href: '/baseball/dashboard/teams', icon: IconUsers }, { label: 'Events', href: '/baseball/dashboard/events', icon: IconCalendar }, - toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]), + messagesNavItem(unreadCount), ], }, ]; @@ -309,7 +328,7 @@ function buildShowcaseTeamSections(ctx: BaseballNavContext, unreadCount: number) tabs: dashboardTabs, }), ); - items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href])); + items.push(messagesNavItem(unreadCount)); } for (const hubId of ['team', 'stats-performance', 'development'] as const) { const def = COACH_HUB_DEFS[hubId]; @@ -328,30 +347,10 @@ function buildShowcaseTeamSections(ctx: BaseballNavContext, unreadCount: number) return [{ heading: 'Baseball', items }]; } -function toTitle(seg: string): string { - return seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); -} - -/** - * Breadcrumb label resolved against the SAME registry (never a second - * hardcoded route→label map) — the longest href the pathname matches wins. - */ -function buildBreadcrumbs(pathname: string, ctx: BaseballNavContext, homeHref: string): Breadcrumb[] { - if (pathname === homeHref) return [{ label: 'Dashboard' }]; - - const candidates: { href: string; label: string }[] = [ - ...getVisibleBaseballNav(ctx).map((e) => ({ href: e.href, label: e.label })), - { href: BASEBALL_MESSAGES_NAV.href, label: BASEBALL_MESSAGES_NAV.label }, - ]; - let best: { href: string; label: string } | null = null; - for (const c of candidates) { - if (pathname === c.href || pathname.startsWith(`${c.href}/`)) { - if (!best || c.href.length > best.href.length) best = c; - } - } - const lastSegment = pathname.split('/').filter(Boolean).pop() ?? 'Page'; - return [{ label: 'Dashboard', href: homeHref }, { label: best?.label ?? toTitle(lastSegment) }]; -} +// buildBreadcrumbs lives in ./_components/breadcrumbs.ts — a pure module +// (no React/hooks) so it can be unit-tested without importing this whole +// 'use client' shell file. See its doc comment for the UUID/numeric-id +// guard + override-label precedence (Ruling 4). /** BaseballHelm wordmark for the rail header — hides text in icon-only mode. */ function Brand({ homeHref }: { homeHref: string }) { @@ -478,7 +477,14 @@ function BaseballFairwayContent({ } return buildCoachHubSections(ctx, unreadCount); }, [ctx, unreadCount, role, isShowcaseCoach, selectedTeam]); - const breadcrumbs = useMemo(() => buildBreadcrumbs(pathname, ctx, homeHref), [pathname, ctx, homeHref]); + // Real record name a dynamic detail page has already fetched (player name, + // opponent, plan title, …), registered via the breadcrumb-label override + // channel — see breadcrumb-label.tsx and buildBreadcrumbs' doc comment. + const breadcrumbOverride = useBreadcrumbLabel(pathname); + const breadcrumbs = useMemo( + () => buildBreadcrumbs(pathname, ctx, homeHref, breadcrumbOverride), + [pathname, ctx, homeHref, breadcrumbOverride], + ); const activeHub = resolveActiveHub({ pathname, role, @@ -564,15 +570,15 @@ function BaseballFairwayContent({ - {/* Same global the legacy BaseballDashboardShell mounts unconditionally. */} + {/* The one global CommandPalette mount for the whole shell. */} ); } /** - * Exported shell — full standalone replacement for BaseballShellLayout (auth - * gate + provider stack + shell), rendering the Fairway AppShell frame. + * Exported shell — auth gate + provider stack + shell, rendering the Fairway + * AppShell frame. The only shell BaseballHelm renders. */ export function BaseballFairwayShell({ children, @@ -583,10 +589,9 @@ export function BaseballFairwayShell({ authVerified?: boolean; requiredRole?: Role | null; }) { - // SAME auth gate BaseballShellLayout uses for the mounted route group. + // Session/onboarding gate for the mounted route group. const { loading, authorized, role } = useBaseballAuth(requiredRole); - // SAME server-resolved capability map (nav-context.ts) BaseballShellLayout - // passes into BaseballDashboardShell. + // Server-resolved capability map (nav-context.ts) driving the nav sections. const { navContext } = useBaseballNavContext(); if (!authVerified && (loading || !authorized)) { @@ -600,15 +605,18 @@ export function BaseballFairwayShell({ {/* Render-null: fetches the program's brand + applies it as CSS vars / - data attrs on . Mounted here too — this is a full parallel - duplicate of BaseballShellLayout's provider stack (not a wrapper), - so branding would otherwise silently die whenever the redesign - flag is on for this route group. */} + data attrs on so persisted branding (settings/appearance) + actually takes visible effect. */} - - {children} - + {/* Breadcrumb-label override channel (Ruling 4) — wraps BOTH the + shell chrome (reader, via useBreadcrumbLabel) and `children` + (writer, via ) in one context instance. */} + + + {children} + + diff --git a/src/app/baseball/(dashboard)/_components/breadcrumb-label.tsx b/src/app/baseball/(dashboard)/_components/breadcrumb-label.tsx new file mode 100644 index 000000000..0bc187b91 --- /dev/null +++ b/src/app/baseball/(dashboard)/_components/breadcrumb-label.tsx @@ -0,0 +1,88 @@ +'use client'; + +// ============================================================================= +// breadcrumb-label.tsx — the minimal label-override channel for the shell's +// top-bar breadcrumb trail (COHERENCE_RULING_2026-07-08.md Ruling 4). +// +// PROBLEM: BaseballFairwayShell's buildBreadcrumbs derives every crumb from +// the nav registry / URL segments alone. That works for static routes, but a +// dynamic detail route (players/[id], stats/games/[gameId], dev-plans/[id]) +// has no registry entry for the record itself — only the page component, +// already fetching the record server-side, knows the real name. +// +// MECHANISM: a tiny context keyed by PATHNAME (not a single "current" slot), +// so a stale label from a page a user just navigated away from can never leak +// onto the next route regardless of unmount/mount ordering. Detail pages +// render once they have the record; it renders +// nothing and registers the label under `usePathname()` for the shell to read +// via `useBreadcrumbLabel(pathname)`. No new state library — just context + +// two hooks, mirroring the render-null mount pattern already used by +// BaseballProgramBrand. +// ============================================================================= + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { usePathname } from 'next/navigation'; + +interface BreadcrumbLabelContextValue { + labels: Readonly>; + setLabel: (pathname: string, name: string) => void; + clearLabel: (pathname: string) => void; +} + +const BreadcrumbLabelContext = createContext(null); + +/** Mounted once by BaseballFairwayShell, above both the shell chrome (reader) + * and `{children}` (writer) so both sides share one context instance. */ +export function BreadcrumbLabelProvider({ children }: { children: React.ReactNode }) { + const [labels, setLabels] = useState>>({}); + + const setLabel = useCallback((pathname: string, name: string) => { + setLabels((prev) => (prev[pathname] === name ? prev : { ...prev, [pathname]: name })); + }, []); + + const clearLabel = useCallback((pathname: string) => { + setLabels((prev) => { + if (!(pathname in prev)) return prev; + const next = { ...prev }; + delete next[pathname]; + return next; + }); + }, []); + + const value = useMemo(() => ({ labels, setLabel, clearLabel }), [labels, setLabel, clearLabel]); + + return {children}; +} + +/** Shell-side read: the override label registered for the CURRENT pathname, if any. */ +export function useBreadcrumbLabel(pathname: string | null): string | undefined { + const ctx = useContext(BreadcrumbLabelContext); + if (!ctx || !pathname) return undefined; + return ctx.labels[pathname]; +} + +/** + * Page-side write: render this once the real record name is known (player + * name, opponent, plan title, …) so the shell's breadcrumb trail shows it + * instead of falling back to a generic hub label. Renders nothing. Registers + * under the CURRENT route only and de-registers on unmount/name-change, so + * navigating away never leaves a stale label for another route to pick up. + */ +export function BreadcrumbLabel({ name }: { name: string | null | undefined }) { + const pathname = usePathname(); + const ctx = useContext(BreadcrumbLabelContext); + // Depend on the individual (stable, useCallback-memoized) setters rather + // than the whole context value — `value` gets a new identity every time + // ANY page's label changes (shared `labels` state), which would otherwise + // re-fire this effect for every unrelated label update. + const setLabel = ctx?.setLabel; + const clearLabel = ctx?.clearLabel; + + useEffect(() => { + if (!setLabel || !clearLabel || !pathname || !name) return; + setLabel(pathname, name); + return () => clearLabel(pathname); + }, [setLabel, clearLabel, pathname, name]); + + return null; +} diff --git a/src/app/baseball/(dashboard)/_components/breadcrumbs.ts b/src/app/baseball/(dashboard)/_components/breadcrumbs.ts new file mode 100644 index 000000000..b58cb3e5f --- /dev/null +++ b/src/app/baseball/(dashboard)/_components/breadcrumbs.ts @@ -0,0 +1,81 @@ +// ============================================================================= +// breadcrumbs.ts — pure breadcrumb-trail builder for BaseballFairwayShell +// (COHERENCE_RULING_2026-07-08.md Ruling 4). +// +// Split out from BaseballFairwayShell.tsx (a 'use client' component file +// importing React/hooks/next-navigation) so this logic can be unit-tested +// directly without dragging in the whole shell's client-only dependency +// graph. Pure — no React, no Supabase, no 'use client'. +// ============================================================================= + +import type { Breadcrumb } from '@/components/fairway/app-shell'; +import { + getVisibleBaseballNav, + BASEBALL_MESSAGES_NAV, + type BaseballNavContext, +} from '@/lib/baseball/nav-registry'; + +export function toTitle(seg: string): string { + return seg.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Naive English singularize (strip one trailing non-"ss" "s") — good enough + * for route nouns like "players"/"games"/"programs", never applied to a + * user-visible record name (only to a URL segment as a last-resort label). */ +export function singularize(seg: string): string { + if (seg.length > 1 && seg.endsWith('s') && !seg.endsWith('ss')) return seg.slice(0, -1); + return seg; +} + +/** A UUID (any version) or a purely-numeric id — the shapes Supabase primary + * keys and route params take. Ruling 4: these must NEVER be title-cased into + * a breadcrumb crumb (e.g. "5e03752b D7fe 5e31 Ab39 F588ba3649d2"). */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export function isIdShapedSegment(segment: string): boolean { + return UUID_RE.test(segment) || /^\d+$/.test(segment); +} + +/** + * Breadcrumb label resolved against the SAME registry (never a second + * hardcoded route→label map) — the longest href the pathname matches wins. + * + * `overrideLabel` (from the breadcrumb-label override channel — see + * breadcrumb-label.tsx) is the real record name a dynamic detail page has + * already fetched (player name, opponent, plan title, …); it wins whenever + * present. When the trailing URL segment is UUID/numeric-id-shaped and no + * override has been supplied yet (or ever, for routes that don't wire one), + * the crumb falls back to the owning nav entry's label, and only as a last + * resort to a singularized, title-cased form of the segment BEFORE the id + * (e.g. "players/" → "Player", "games/" → "Game") — never the + * raw id itself. + */ +export function buildBreadcrumbs( + pathname: string, + ctx: BaseballNavContext, + homeHref: string, + overrideLabel?: string, +): Breadcrumb[] { + if (pathname === homeHref) return [{ label: 'Dashboard' }]; + + const candidates: { href: string; label: string }[] = [ + ...getVisibleBaseballNav(ctx).map((e) => ({ href: e.href, label: e.label })), + { href: BASEBALL_MESSAGES_NAV.href, label: BASEBALL_MESSAGES_NAV.label }, + ]; + let best: { href: string; label: string } | null = null; + for (const c of candidates) { + if (pathname === c.href || pathname.startsWith(`${c.href}/`)) { + if (!best || c.href.length > best.href.length) best = c; + } + } + + const segments = pathname.split('/').filter(Boolean); + const lastSegment = segments[segments.length - 1] ?? 'Page'; + + if (isIdShapedSegment(lastSegment)) { + const parentSegment = segments[segments.length - 2]; + const fallback = best?.label ?? (parentSegment ? toTitle(singularize(parentSegment)) : 'Details'); + return [{ label: 'Dashboard', href: homeHref }, { label: overrideLabel ?? fallback }]; + } + + return [{ label: 'Dashboard', href: homeHref }, { label: overrideLabel ?? best?.label ?? toTitle(lastSegment) }]; +} diff --git a/src/app/baseball/(dashboard)/_components/hub-definitions.ts b/src/app/baseball/(dashboard)/_components/hub-definitions.ts index 3a6c93d37..f210cbaee 100644 --- a/src/app/baseball/(dashboard)/_components/hub-definitions.ts +++ b/src/app/baseball/(dashboard)/_components/hub-definitions.ts @@ -13,24 +13,31 @@ // COACH_NAV_8TAB_PROPOSAL.md (approved 2026-07-01) — DERIVED, NOT HAND-LISTED: // every COACH_*_TABS array below is built by grouping BASEBALL_NAV_REGISTRY on // its `hub` field (nav-registry.ts), not by hand-maintaining a parallel route -// list. That is what fixed the drift the proposal documented: 5 registered -// features with no hub-tab entry anywhere (camps, postgame-review, -// practice-effectiveness, practice-planner, comparisons), a phantom coach -// `tasks` tab with no coach-visible registry row, and a player-only -// `college-interest` leaking into the coach recruiting hub. Labels, icons, -// requiredCapability/requiredAnyCapabilities, and allowedProgramTypes are all -// read from the registry entry verbatim — never re-declared here, so they can -// never drift from the registry again. The only hand-maintained data left is -// (a) each hub's DISPLAY ORDER (a small id list; entries not listed simply fall -// to the end in registry order — nothing can be silently dropped), and (b) a -// handful of SUPPLEMENTARY leaf tabs (Stats Center's Games/Season/Upload, -// Performance's Live/Programs/Groups/Builder) that are child pages of a -// registry feature, not registry features in their own right. +// list. Labels, icons, requiredCapability/requiredAnyCapabilities, and +// allowedProgramTypes are all read from the registry entry verbatim — never +// re-declared here, so they can never drift from the registry again. The only +// hand-maintained data left is (a) each hub's DISPLAY ORDER (a small id list; +// entries not listed simply fall to the end in registry order), and (b) ONE +// supplementary leaf tab (Stats Center's "Games" — a child page of a registry +// feature, not a registry feature in its own right). +// +// HARD CAP (COHERENCE_RULING_2026-07-08.md Ruling 2): every hub renders AT +// MOST 3 sub-tabs, for every coach type. The mechanism is `hub` + +// `foldedUnder` on nav-registry.ts entries: a registry entry declares +// `foldedUnder: ` to mark itself as reachable ONLY from its parent's +// LANDING PAGE (a card grid — see dashboard/operations and dashboard/scouting) +// rather than as its own rendered sub-tab. `hubEntries()` below excludes any +// entry carrying `foldedUnder` from the rendered array; the folded entry KEEPS +// its own registry row, so command palette and deep links still resolve it, +// and the parent landing entry's `matchPrefixes` (nav-registry.ts) still +// route `resolve-active-hub.ts` to the right hub + subtab when a user is on +// the folded destination directly. nav-manifest.test.ts locks both halves of +// this contract (≤3 rendered tabs; every folded href still resolves a hub). // // PLAYER_*_TABS are intentionally NOT derived the same way: most player-only // registry entries (player-dev-plan, player-lift, player-readiness, etc.) carry // no `hub` field (hub is only required for role coach/both — see nav-registry.ts), -// so they stay hand-maintained here, unchanged by this pass. +// so they stay hand-maintained here, trimmed to the same ≤3 cap by hand. // // PURE DATA + ICONS. No 'use client' / 'use server', no Supabase, no React state — // safe to import from both the client sidebar and the client hub layouts. @@ -46,24 +53,18 @@ import { IconChartBar, IconClipboardList, IconTrendingUp, - IconUpload, IconTarget, - IconVideo, - IconDumbbell, - IconGauge, IconShieldCheck, IconBuilding, IconStar, IconHome, IconUser, IconGraduationCap, - IconSettings, - IconLock, - IconDatabase, } from '@/components/icons'; import type { HubSubNavTab } from './hub-sub-nav'; import { BASEBALL_NAV_REGISTRY, + BASEBALL_MESSAGES_NAV, type BaseballNavEntry, type BaseballNavHub, type BaseballNavIcon, @@ -78,7 +79,10 @@ import { function toHubTab(entry: BaseballNavEntry): HubSubNavTab { return { id: entry.id, - label: entry.label, + // hubTabLabel overrides the canonical label ONLY inside the sub-nav + // strip (Ruling 2 — e.g. "Performance" reads "Training" here, unchanged + // everywhere else); falls back to the canonical label otherwise. + label: entry.hubTabLabel ?? entry.label, href: entry.href, icon: entry.icon, requiredCapability: entry.requiredCapability ?? undefined, @@ -89,15 +93,18 @@ function toHubTab(entry: BaseballNavEntry): HubSubNavTab { } /** - * Every coach/both registry entry tagged for `hub`, converted to tabs. `team` - * is always excluded — it is the legacy `/dashboard/team` alias (hub: - * 'dashboard' for the registry invariant, but its href is an exact duplicate - * of `command-center` for coaches), never a distinct destination worth a tab. + * Every coach/both registry entry tagged for `hub`, converted to tabs. + * Excludes two kinds of entries from the RENDERED strip (both keep their own + * registry row — see the module header): + * - `team`: the legacy `/dashboard/team` alias (hub: 'dashboard' for the + * registry invariant, but its href is an exact duplicate of + * `command-center` for coaches) — never a distinct destination. + * - anything carrying `foldedUnder`: Ruling 2's ≤3-subtabs mechanism. */ function hubEntries(hub: BaseballNavHub): HubSubNavTab[] { - return BASEBALL_NAV_REGISTRY.filter((e) => e.hub === hub && e.role !== 'player' && e.id !== 'team').map( - toHubTab, - ); + return BASEBALL_NAV_REGISTRY.filter( + (e) => e.hub === hub && e.role !== 'player' && e.id !== 'team' && !e.foldedUnder, + ).map(toHubTab); } /** Stable-sort tabs by a curated id order; unlisted ids keep registry order at the end. */ @@ -126,8 +133,12 @@ function withSupplements( // ----------------------------------------------------------------------------- // Supplementary leaf tabs — child pages of a registry feature that are not -// themselves BASEBALL_NAV_REGISTRY entries (nav-registry.ts tracks the 32 -// top-level features; these are deeper sub-pages within two of them). +// themselves BASEBALL_NAV_REGISTRY entries. Games is the ONE surviving +// supplement post-Ruling-2: Season/Upload now resolve via stats-center's own +// matchPrefixes (a view + header CTAs inside Stats Center, not their own +// tabs), and Performance's Live/Programs/Groups/Builder are reached via +// in-page masthead links on /dashboard/performance itself (verified present), +// not a second copy of the same links in the hub sub-nav strip. // ----------------------------------------------------------------------------- const STATS_GAMES_TAB: HubSubNavTab = { @@ -137,187 +148,79 @@ const STATS_GAMES_TAB: HubSubNavTab = { icon: IconClipboardList, matchPrefixes: ['/baseball/dashboard/stats/games'], }; -const STATS_SEASON_TAB: HubSubNavTab = { - id: 'season', - label: 'Season', - href: '/baseball/dashboard/stats/season', - icon: IconTrendingUp, -}; -const STATS_UPLOAD_TAB: HubSubNavTab = { - id: 'upload', - label: 'Upload', - href: '/baseball/dashboard/stats/upload', - icon: IconUpload, -}; -const PERFORMANCE_LIVE_TAB: HubSubNavTab = { - id: 'performance-live', - label: 'Live', - href: '/baseball/dashboard/performance/live', - icon: IconDumbbell, - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_PROGRAMS_TAB: HubSubNavTab = { - id: 'performance-programs', - label: 'Programs', - href: '/baseball/dashboard/performance/programs', - icon: IconClipboardList, - matchPrefixes: ['/baseball/dashboard/performance/programs'], - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_GROUPS_TAB: HubSubNavTab = { - id: 'performance-groups', - label: 'Groups', - href: '/baseball/dashboard/performance/groups', - icon: IconUsers, - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_BUILDER_TAB: HubSubNavTab = { - id: 'performance-builder', - label: 'Builder', - href: '/baseball/dashboard/performance/builder', - icon: IconGauge, - requiredCapability: 'can_manage_lifting', -}; -const SETTINGS_HOME_TAB: HubSubNavTab = { - id: 'settings-home', - label: 'Settings', - href: '/baseball/dashboard/settings', - icon: IconSettings, -}; -const SETTINGS_SEASON_TAB: HubSubNavTab = { - id: 'settings-season', - label: 'Season', - href: '/baseball/dashboard/settings/season', - icon: IconCalendar, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_PHILOSOPHY_TAB: HubSubNavTab = { - id: 'settings-philosophy', - label: 'Philosophy', - href: '/baseball/dashboard/settings/philosophy', - icon: IconTarget, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_ROLES_TAB: HubSubNavTab = { - id: 'settings-roles', - label: 'Roles', - href: '/baseball/dashboard/settings/roles', - icon: IconLock, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_PERMISSIONS_TAB: HubSubNavTab = { - id: 'settings-permissions', - label: 'Permissions', - href: '/baseball/dashboard/settings/permissions', - icon: IconShieldCheck, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_TEAMS_TAB: HubSubNavTab = { - id: 'settings-teams', - label: 'Team Settings', - href: '/baseball/dashboard/settings/teams', - icon: IconUsers, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_IMPORTS_TAB: HubSubNavTab = { - id: 'settings-imports', - label: 'Imports', - href: '/baseball/dashboard/settings/imports', - icon: IconUpload, - requiredCapability: 'can_manage_imports', -}; -const SETTINGS_INTEGRATIONS_TAB: HubSubNavTab = { - id: 'settings-integrations', - label: 'Integrations', - href: '/baseball/dashboard/settings/integrations', - icon: IconBuilding, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_AUDIT_TAB: HubSubNavTab = { - id: 'settings-audit', - label: 'Audit', - href: '/baseball/dashboard/settings/audit', - icon: IconDatabase, - requiredCapability: 'can_manage_settings', -}; // ----------------------------------------------------------------------------- -// Curated display order per hub (COACH_NAV_8TAB_PROPOSAL.md mapping table). -// Membership is always registry-derived (hubEntries); this only sequences it. +// Curated display order per hub (COHERENCE_RULING_2026-07-08.md Ruling 2 — +// the hub/subtab table is authoritative). Membership is always +// registry-derived (hubEntries); this only sequences it, and every hub below +// resolves to AT MOST 3 rendered tabs. // ----------------------------------------------------------------------------- const DASHBOARD_ORDER = ['command-center', 'signals']; -const TEAM_ORDER = ['roster', 'calendar', 'announcements', 'documents', 'travel']; -const STATS_PERFORMANCE_ORDER = [ - 'stats-center', - 'performance', - 'postgame-review', - 'practice-planner', - 'practice-effectiveness', - 'import-center', -]; -const DEVELOPMENT_ORDER = ['dev-plans', 'videos']; -const RECRUITING_ORDER = [ - 'pipeline', - 'college-interest', - 'discover', - 'watchlist', - 'compare', - 'comparisons', - 'scout-packets', - 'camps', -]; +const TEAM_ORDER = ['roster', 'calendar', 'operations']; +const MESSAGES_ORDER = ['announcements']; +const STATS_PERFORMANCE_ORDER = ['stats-center', 'postgame-review']; +const DEVELOPMENT_ORDER = ['dev-plans', 'performance', 'videos']; +const RECRUITING_ORDER = ['pipeline', 'discover', 'scouting']; const ACADEMICS_ORDER = ['academics']; -const MANAGEMENT_ORDER = [ - 'staff-decision-room', - 'program', - 'staff-settings', - 'program-settings', - 'organization', - 'teams', - 'events', -]; +const MANAGEMENT_ORDER = ['staff-decision-room', 'settings', 'organization']; // ----------------------------------------------------------------------------- // COACH HUBS — every array below is registry-derived (see the module header). // ----------------------------------------------------------------------------- -/** DASHBOARD hub — Command Center + Signals (folded from two flat top-level tabs). */ +/** DASHBOARD hub — Overview (command-center) + Signals. */ export const COACH_DASHBOARD_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('dashboard'), DASHBOARD_ORDER, ); -/** TEAM hub — roster + day-to-day team operations. */ +/** TEAM hub — Roster · Calendar · Operations (Documents/Travel/Practice Planner/ + * Practice Effectiveness fold into the Operations landing, Ruling 2). */ export const COACH_TEAM_TABS: readonly HubSubNavTab[] = orderTabs(hubEntries('team'), TEAM_ORDER); /** - * STATS & PERFORMANCE hub — team-wide stats depth, game logs, season, practice - * intelligence, and lifting/readiness, folded into one hub per the proposal - * (previously Practice Planner/Effectiveness, Postgame Review, and Import - * Center had no hub-tab entry anywhere — an unreachable-feature bug this fixes). + * MESSAGES hub — Messages · Announcements (Ruling 2: Announcements moves off + * Team — comms belong with comms). The Messages tab itself is the persistent + * cross-cutting entry (BASEBALL_MESSAGES_NAV, deliberately kept outside the + * feature registry — see nav-registry.ts) rendered as the FIRST tab; + * Announcements is registry-derived like every other hub. + */ +const MESSAGES_TAB: HubSubNavTab = { + id: BASEBALL_MESSAGES_NAV.id, + label: BASEBALL_MESSAGES_NAV.label, + href: BASEBALL_MESSAGES_NAV.href, + icon: BASEBALL_MESSAGES_NAV.icon, +}; + +export const COACH_MESSAGES_TABS: readonly HubSubNavTab[] = [ + MESSAGES_TAB, + ...orderTabs(hubEntries('messages'), MESSAGES_ORDER), +]; + +/** + * STATS & PERFORMANCE hub — Stats Center · Games · Postgame (Ruling 2: Season + * is a view inside Stats Center, Upload + Import Center are header CTAs + * there; Practice Planner/Effectiveness moved to Team>Operations; Performance + * moved to Development>Training). */ export const COACH_STATS_TABS: readonly HubSubNavTab[] = withSupplements( orderTabs(hubEntries('stats-performance'), STATS_PERFORMANCE_ORDER), - { - 'stats-center': [STATS_GAMES_TAB, STATS_SEASON_TAB, STATS_UPLOAD_TAB], - performance: [PERFORMANCE_LIVE_TAB, PERFORMANCE_PROGRAMS_TAB, PERFORMANCE_GROUPS_TAB, PERFORMANCE_BUILDER_TAB], - }, + { 'stats-center': [STATS_GAMES_TAB] }, ); -/** DEVELOPMENT hub — dev plans + video library. */ +/** DEVELOPMENT hub — Dev Plans · Training (performance) · Videos. */ export const COACH_DEVELOPMENT_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('development'), DEVELOPMENT_ORDER, ); /** - * RECRUITING hub — pipeline, discovery, comparisons, scout packets, camps. - * Gated to RECRUITING_PROGRAM_TYPES by the sidebar/resolve-active-hub, hidden - * entirely for High School. Fixed by this pass: `import` (misplaced here - * previously) moved to Stats & Performance; `college-interest` (a coach-facing - * interest dashboard) stays out of player nav; `comparisons` and `camps` - * (previously unreachable) added. + * RECRUITING hub — Pipeline · Discover · Scouting (Ruling 2: Watchlist, + * Compare, Saved Comparisons, Scout Packets, and Camps fold into the new + * Scouting landing; Interest folds into Pipeline). Gated to + * RECRUITING_PROGRAM_TYPES by the sidebar/resolve-active-hub, hidden entirely + * for High School. */ export const COACH_RECRUITING_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('recruiting'), @@ -331,43 +234,17 @@ export const COACH_RECRUITING_TABS: readonly HubSubNavTab[] = orderTabs( */ export const COACH_ACADEMICS_TABS: readonly HubSubNavTab[] = orderTabs(hubEntries('academics'), ACADEMICS_ORDER); -const MANAGEMENT_SETTINGS_SUPPLEMENT_ID = 'program-settings'; - -/** Dev-only guard: settings supplement tabs must attach to a real registry row. */ -function assertManagementSettingsSupplement(tabs: readonly HubSubNavTab[]): void { - if (process.env.NODE_ENV === 'production') return; - if (!tabs.some((tab) => tab.id === MANAGEMENT_SETTINGS_SUPPLEMENT_ID)) { - throw new Error( - `COACH_MANAGEMENT_TABS settings supplement requires registry tab "${MANAGEMENT_SETTINGS_SUPPLEMENT_ID}".`, - ); - } -} - /** - * MANAGEMENT hub — staff coordination, program settings, and (Showcase/Academy/ - * Club only, via allowedProgramTypes carried through verbatim from the - * registry) org-level Organization/Teams/Events. Fixed by this pass: the - * "Decision Room" vs "Staff Room" label drift (the registry's label always - * wins now — it is read, not re-declared). + * MANAGEMENT hub — Decision Room · Settings · Organization (Ruling 2: DELETED + * the 9-item settings-route splice — the existing card-grid landing at + * /dashboard/settings is now the single settings nav surface; Program + * Info/Staff Settings/Program Settings fold into it. Organization/Teams/ + * Events fold under Organization for Showcase/Academy/Club program types + * only, via allowedProgramTypes carried through verbatim from the registry). */ -const managementHubTabs = orderTabs(hubEntries('management'), MANAGEMENT_ORDER); -assertManagementSettingsSupplement(managementHubTabs); - -export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = withSupplements( - managementHubTabs, - { - [MANAGEMENT_SETTINGS_SUPPLEMENT_ID]: [ - SETTINGS_HOME_TAB, - SETTINGS_SEASON_TAB, - SETTINGS_PHILOSOPHY_TAB, - SETTINGS_ROLES_TAB, - SETTINGS_PERMISSIONS_TAB, - SETTINGS_TEAMS_TAB, - SETTINGS_IMPORTS_TAB, - SETTINGS_INTEGRATIONS_TAB, - SETTINGS_AUDIT_TAB, - ], - }, +export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = orderTabs( + hubEntries('management'), + MANAGEMENT_ORDER, ); // ----------------------------------------------------------------------------- @@ -383,10 +260,11 @@ export interface CoachHubDef { tabs: readonly HubSubNavTab[]; } -/** Display order of the 7 registry-backed coach hubs (Messages is the 8th tab, - * a persistent cross-cutting slot outside this grouping — see nav-registry.ts). */ +/** Display order of the 8 registry-backed coach hubs (Ruling 2 promotes + * Messages from a bare flat link to a real hub with its own sub-nav). */ export const COACH_HUB_ORDER: readonly BaseballNavHub[] = [ 'dashboard', + 'messages', 'team', 'stats-performance', 'development', @@ -397,6 +275,7 @@ export const COACH_HUB_ORDER: readonly BaseballNavHub[] = [ export const COACH_HUB_DEFS: Readonly> = { dashboard: { id: 'dashboard', label: 'Dashboard', icon: IconHome, tabs: COACH_DASHBOARD_TABS }, + messages: { id: 'messages', label: 'Messages', icon: IconMessage, tabs: COACH_MESSAGES_TABS }, team: { id: 'team', label: 'Team', icon: IconUsers, tabs: COACH_TEAM_TABS }, 'stats-performance': { id: 'stats-performance', @@ -411,8 +290,12 @@ export const COACH_HUB_DEFS: Readonly> = { }; // ----------------------------------------------------------------------------- -// PLAYER HUBS — unchanged by this pass (see the module header: most player-only -// registry entries carry no `hub`, so these stay hand-maintained). +// PLAYER HUBS — unchanged mechanism (see the module header: most player-only +// registry entries carry no `hub`, so these stay hand-maintained), trimmed to +// the same ≤3 cap (Ruling 2 item 7). Every dropped tab below KEEPS its own +// registry row (still command-palette + deep-link reachable) and, for +// Lifts/Readiness specifically, is also surfaced inline on Player Today — +// so nothing is orphaned, only de-duplicated off the sub-nav strip. // ----------------------------------------------------------------------------- /** Player STATS hub — own stats depth + game/season views. */ @@ -420,14 +303,17 @@ export const PLAYER_STATS_TABS: readonly HubSubNavTab[] = [ { id: 'overview', label: 'Overview', href: '/baseball/dashboard/my-stats', icon: IconChartBar }, ]; -/** Player DEVELOPMENT hub — own dev plan, training, proof packet, and video library. */ +/** + * Player DEVELOPMENT hub — Dev Plan · Practice · Passport. Lifts and + * Readiness are already surfaced inline on Player Today's daily card + * (PlayerLiftToday) and remain reachable via their own registry entries + * (player-lift, player-readiness); Videos remains reachable via its own + * registry entry (role: 'both'). + */ export const PLAYER_DEVELOPMENT_TABS: readonly HubSubNavTab[] = [ { id: 'dev-plan', label: 'Dev Plan', href: '/baseball/dashboard/dev-plan', icon: IconTarget }, { id: 'practice', label: 'Practice', href: '/baseball/player/practice', icon: IconClipboardList }, - { id: 'lifts', label: 'Lifts', href: '/baseball/dashboard/lift', icon: IconDumbbell, matchPrefixes: ['/baseball/dashboard/lift'] }, - { id: 'readiness', label: 'Readiness', href: '/baseball/dashboard/readiness', icon: IconGauge }, { id: 'passport', label: 'Passport', href: '/baseball/player/passport', icon: IconShieldCheck }, - { id: 'videos', label: 'Videos', href: '/baseball/dashboard/videos', icon: IconVideo, matchPrefixes: ['/baseball/dashboard/videos'] }, ]; /** Player TEAM hub — shared team surfaces a player reads. */ @@ -437,12 +323,16 @@ export const PLAYER_TEAM_TABS: readonly HubSubNavTab[] = [ { id: 'documents', label: 'Documents', href: '/baseball/dashboard/documents', icon: IconFileText }, ]; -/** Player RECRUITING hub — player-owned exposure and college discovery surfaces. */ +/** + * Player RECRUITING hub — Journey · Colleges · Analytics. Activate Recruiting + * remains reachable via its own registry entry (player-activate, section: + * 'secondary') — command palette + direct URL — it is a one-time opt-in + * action, not a recurring destination worth a permanent tab slot. + */ export const PLAYER_RECRUITING_TABS: readonly HubSubNavTab[] = [ { id: 'journey', label: 'Journey', href: '/baseball/dashboard/journey', icon: IconStar }, { id: 'colleges', label: 'Colleges', href: '/baseball/dashboard/colleges', icon: IconGraduationCap }, { id: 'analytics', label: 'Analytics', href: '/baseball/dashboard/analytics', icon: IconTrendingUp }, - { id: 'activate', label: 'Activate', href: '/baseball/dashboard/activate', icon: IconShieldCheck }, ]; // ----------------------------------------------------------------------------- @@ -452,6 +342,7 @@ export const PLAYER_RECRUITING_TABS: readonly HubSubNavTab[] = [ export const HUB_LANDING = { coachDashboard: COACH_DASHBOARD_TABS[0]!.href, + coachMessages: COACH_MESSAGES_TABS[0]!.href, coachTeam: COACH_TEAM_TABS[0]!.href, coachStats: COACH_STATS_TABS[0]!.href, coachDevelopment: COACH_DEVELOPMENT_TABS[0]!.href, diff --git a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts index b606cd224..205fa6be6 100644 --- a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts +++ b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts @@ -51,6 +51,7 @@ export { RECRUITING_PROGRAM_TYPES }; */ const RESOLVE_HUB_ID: Readonly> = { dashboard: 'dashboard', + messages: 'messages', team: 'team', 'stats-performance': 'stats', development: 'development', diff --git a/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx b/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx index 885084582..e989fe275 100644 --- a/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx @@ -32,7 +32,8 @@ interface StudentAthlete { gpa: number | null; credits_completed: number | null; credits_required: number | null; - is_eligible: boolean; + /** Tri-state: null = no eligibility record on file (neutral), never coerced to false. */ + is_eligible: boolean | null; academic_standing: 'good' | 'warning' | 'probation' | null; eligibility_id: string | null; } @@ -164,7 +165,9 @@ export default function AcademicsPage() { gpa: student.gpa, credits_completed: student.credits_completed, credits_required: student.credits_required, - is_eligible: student.is_eligible, + // No record on file yet defaults the editor to "Eligible" — matches + // upsertPlayerAcademics' own default for a brand-new record. + is_eligible: student.is_eligible ?? true, academic_standing: student.academic_standing, }); }; @@ -331,11 +334,27 @@ export default function AcademicsPage() { return `${student.credits_completed}/${req}`; }; + const gpaDisplay = (student: StudentAthlete) => + student.gpa !== null ? student.gpa.toFixed(2) : 'Not on file'; + const standingLabel = (standing: 'good' | 'warning' | 'probation' | null) => { if (!standing) return 'Unknown'; return standing.charAt(0).toUpperCase() + standing.slice(1); }; + // ── Eligibility tri-state: null = no record on file yet (neutral gray, + // matches the "Unknown" standing treatment) — never coerced to a red + // "Ineligible", which is reserved for a real, recorded `false`. ───────── + const eligibilityLabel = (isEligible: boolean | null) => { + if (isEligible === null) return 'Not on file'; + return isEligible ? 'Eligible' : 'Ineligible'; + }; + + const eligibilityBadgeClass = (isEligible: boolean | null) => { + if (isEligible === null) return 'bg-warm-100 text-warm-500'; + return isEligible ? 'bg-primary-100 text-primary-700' : 'bg-red-100 text-red-700'; + }; + return ( <>
) : ( -

- {student.gpa !== null ? student.gpa.toFixed(2) : 'N/A'} +

+ {gpaDisplay(student)}

)}

GPA

@@ -533,8 +552,8 @@ export default function AcademicsPage() { > {standingLabel(student.academic_standing)} - - {student.is_eligible ? 'Eligible' : 'Ineligible'} + + {eligibilityLabel(student.is_eligible)} )} @@ -619,8 +638,8 @@ export default function AcademicsPage() { aria-label="GPA" /> ) : ( - - {student.gpa !== null ? student.gpa.toFixed(2) : 'N/A'} + + {gpaDisplay(student)} )} @@ -681,8 +700,8 @@ export default function AcademicsPage() { ]} /> ) : ( - - {student.is_eligible ? 'Eligible' : 'Ineligible'} + + {eligibilityLabel(student.is_eligible)} )} diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx index 179000504..838b3c94d 100644 --- a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx @@ -8,6 +8,7 @@ import { createClient } from '@/lib/supabase/client'; import { getAnnouncementsWithMeta } from '@/app/baseball/actions/announcements'; import { AnnouncementsFairway } from '@/components/baseball/announcements/AnnouncementsFairway'; import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +import { SectionMasthead } from '@/components/baseball/living-annual'; import { fairwayScope } from '@/lib/redesign/flag'; import type { BaseballAnnouncementMeta } from '@/app/baseball/actions/announcements'; @@ -95,7 +96,7 @@ export default function BaseballAnnouncementsPage() { if (loadError) { return (
-

Announcements

+ = { - game: { label: 'Game', dot: 'bg-blue-500' }, - practice: { label: 'Practice', dot: 'bg-primary-500' }, - camp: { label: 'Camp', dot: 'bg-purple-500' }, - tryout: { label: 'Tryout', dot: 'bg-amber-500' }, - meeting: { label: 'Meeting', dot: 'bg-warm-500' }, - travel: { label: 'Travel', dot: 'bg-sky-500' }, - other: { label: 'Other', dot: 'bg-warm-400' }, -}; +/** + * Display-only default for a missing `end_time` — mirrors the drag-reschedule + * fallback in PremiumCalendarClient ("Fallback: 1 hour duration"). NEVER + * written back to the DB; `event.end_time` on the mapped CalendarEvent stays + * the raw (possibly null) column value. + */ +function defaultEndTime(startIso: string): string { + const start = new Date(startIso); + if (Number.isNaN(start.getTime())) return startIso; + return new Date(start.getTime() + 60 * 60 * 1000).toISOString(); +} export default async function BaseballCalendarPage() { const supabase = await createClient(); @@ -73,6 +72,16 @@ export default async function BaseballCalendarPage() { // ── Fetch events + roster ─────────────────────────────────────────────────── if (teamId) { + // Bounded window — 90 days back / 365 days forward — so a long-lived team + // doesn't drag its entire event history into every calendar render (no + // limit/window previously meant this query grew unbounded forever). + // Practices/games far outside this range are pagination's job, not a + // single dashboard page's. + const eventsWindowStart = new Date(); + eventsWindowStart.setDate(eventsWindowStart.getDate() - 90); + const eventsWindowEnd = new Date(); + eventsWindowEnd.setDate(eventsWindowEnd.getDate() + 365); + const [eventsResult, membersResult, teamOrgResult] = await Promise.all([ // Read via fromUntyped so the select is not type-checked against the // generated baseball_events types (which drift from the live schema). @@ -86,7 +95,10 @@ export default async function BaseballCalendarPage() { fromUntyped(supabase, 'baseball_events') .select('id, team_id, title, event_type, start_time, end_time, location, description, is_mandatory, max_attendees, rsvp_deadline, all_day, status, recurring, created_by') .eq('team_id', teamId) - .order('start_time', { ascending: true }), + .gte('start_time', eventsWindowStart.toISOString()) + .lte('start_time', eventsWindowEnd.toISOString()) + .order('start_time', { ascending: true }) + .limit(500), supabase .from('baseball_team_members') .select('player_id, baseball_players!inner(id, first_name, last_name, avatar_url)') @@ -122,9 +134,13 @@ export default async function BaseballCalendarPage() { }) => { const normalizeAllDay = (d: string) => `${d.slice(0, 10)}T00:00:00`; const startDate = event.all_day ? normalizeAllDay(event.start_time) : event.start_time; + // NULL end_time previously collapsed to `event.start_time`, producing a + // zero-duration timed event (invisible/unclickable in the hour grid). + // Default to start + 1h for display only — `end_time` on the returned + // CalendarEvent below stays the raw (possibly null) value. const endDate = event.all_day ? normalizeAllDay(event.end_time || event.start_time) - : event.end_time || event.start_time; + : event.end_time || defaultEndTime(event.start_time); return { id: event.id, team_id: event.team_id || '', @@ -218,63 +234,9 @@ export default async function BaseballCalendarPage() { // ── College coach with no team: recruiting-focused empty state ───────────── if (isCollegeCoach && !teamId) { - if (isRedesignEnabled()) { - return ( - - ); - } - return ( -
-
-
- {/* Calendar icon */} -
- - - -
- -

- Your recruiting calendar is empty -

-

- Camp visits and official visit windows will appear here as you schedule recruiting activity. -

- - {/* CTA */} - - - - - Browse Prospects - -
-
-
- ); - } - - if (isRedesignEnabled()) { return ( - {/* Event summary strip — only shown when there's an upcoming event to - summarize. Gating on `upcomingEvents` (not `events.length`) matches - what the strip actually says: a team with only past events has - nothing "upcoming" to report, so showing "0 upcoming events ·" - with no badges after it would just be a second, quieter version of - the same contradiction this strip exists to avoid. */} - {upcomingEvents > 0 && ( -
-
- - {upcomingEvents} upcoming event{upcomingEvents !== 1 ? 's' : ''} - - - {Object.entries(eventTypeCounts).map(([type, count]) => { - const cfg = EVENT_TYPE_CONFIG[type] ?? { label: type, dot: 'bg-warm-400' }; - return ( - - - {count} {cfg.label} - - ); - })} -
-
- )} - - {/* Calendar — overflow-hidden is required so PremiumCalendarClient's h-full resolves correctly. - Golf's main is overflow-y-auto (which anchors heights); baseball's is not, so we add it here. */} -
- -
-
+ ); } diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plan/DevPlanClient.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plan/DevPlanClient.tsx new file mode 100644 index 000000000..b3372d4d2 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/dev-plan/DevPlanClient.tsx @@ -0,0 +1,617 @@ +'use client'; + +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/dev-plan/DevPlanClient.tsx +// +// Player-facing development plan view. Split out of page.tsx (COHERENCE +// RULING 2026-07-08 Ruling 5, "dev-plan cold-URL bounce"): this component is +// entirely client-rendered (own auth hook, own data fetch via +// getActiveDevPlan), which raced (dashboard)/layout.tsx's client-side +// DashboardSessionGuard on a hard/cold navigation — the guard's auth check +// and this component's own auth/fetch cycle could settle in either order, +// occasionally producing a transient 500 before the guard's redirect fired. +// page.tsx now resolves the player session server-side first +// (requireBaseballPlayerRoute — redirects synchronously, before any client +// hydration race is possible) and only then mounts this client component. +// +// LIVING-ANNUAL CHROME MIGRATION (2026-07-08, lane D1b): PRESENTATION ONLY — +// every fetch, handler, state transition, and categorization rule below is +// byte-identical to the pre-migration client; only the rendered chrome moved +// to the kit (SectionMasthead / PaperCard / RuledStatLine / StatReadout / +// InkBadge / EmptyIssue / EditorsLetter). Two concrete fixes from the map: +// 1. The generic `Header` + glass `Card` + `ProgressRing` + amber/blue ad +// hoc badges are gone — this reads as a Passport page now, not a CRM +// form, and never renders a yellow/amber box. +// 2. The goal-complete toggle was a bare 24px circle (`w-6 h-6`) — below +// the 44px touch-target floor. It keeps its 24px VISUAL circle but now +// sits inside a 44px hit area (`-m-2.5` compensates the extra footprint +// so the row layout is visually unchanged). +// ============================================================================= + +import { useEffect, useState, useCallback, useTransition, useMemo } from 'react'; +import { m, AnimatePresence, useReducedMotion } from 'framer-motion'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { PageLoading } from '@/components/ui/loading'; +import { cn } from '@/lib/utils'; +import { + IconTarget, + IconCheck, + IconClock, + IconCalendar, + IconChevronDown, + IconChevronUp, + IconList, +} from '@/components/icons'; +import { useAuth } from '@/hooks/use-auth'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/sonner'; +import { fairwayScope } from '@/lib/redesign/flag'; +import { + SectionMasthead, + PaperCard, + Eyebrow, + RuledStatLine, + StatReadout, + InkBadge, + EmptyIssue, + EditorsLetter, + Reveal, + EASE_SOFT, +} from '@/components/baseball/living-annual'; +import { + getActiveDevPlan, + completeGoalAsPlayer, + uncompleteGoalAsPlayer, + type DevelopmentalPlanWithGoals, + type DevPlanGoal, +} from '@/app/baseball/actions/dev-plans'; + +// Helper to calculate days until/since a date +function getDaysUntil(dateStr: string): { days: number; label: string; isOverdue: boolean; isUpcoming: boolean } { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + + const diffTime = target.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return { days: Math.abs(diffDays), label: `${Math.abs(diffDays)} day${Math.abs(diffDays) !== 1 ? 's' : ''} overdue`, isOverdue: true, isUpcoming: false }; + } else if (diffDays === 0) { + return { days: 0, label: 'Due today', isOverdue: false, isUpcoming: false }; + } else if (diffDays === 1) { + return { days: 1, label: 'Due tomorrow', isOverdue: false, isUpcoming: false }; + } else if (diffDays > 7) { + return { days: diffDays, label: `Due in ${diffDays} days`, isOverdue: false, isUpcoming: true }; + } else { + return { days: diffDays, label: `Due in ${diffDays} days`, isOverdue: false, isUpcoming: false }; + } +} + +// The four "how it works" beats shown on the honest empty state. Numbered +// with a hairline-ring green numeral, never a filled gray/primary circle — +// spec §4.2 rule 5 ("no gray card-soup ... structure comes from hairline +// rules + whitespace, not filled gray boxes"). +const HOW_IT_WORKS = [ + { title: 'Coach creates your plan', body: 'Your coach sets specific goals and drills tailored to your needs.' }, + { title: 'Work on your goals', body: 'Follow the drills and practice routines to improve your skills.' }, + { title: 'Track your progress', body: 'Mark goals as complete and see how far you’ve come.' }, + { title: 'Celebrate achievements', body: 'Your coach reviews your progress and sets new goals.' }, +] as const; + +// Goal card component +function GoalCard({ + goal, + onComplete, + onUncomplete, + isPending, +}: { + goal: DevPlanGoal; + onComplete: (goalId: string) => void; + onUncomplete: (goalId: string) => void; + isPending: boolean; +}) { + const prefersReducedMotion = useReducedMotion(); + const [isExpanded, setIsExpanded] = useState(false); + const isCompleted = goal.status === 'completed'; + const dueInfo = goal.target_date ? getDaysUntil(goal.target_date) : null; + // Deadlines are one of the spec's explicit clay exceptions inside a green + // lane (§4.2 rule 2: "clay/oxblood only on stamps, seals, DEADLINES, + // offers, hot signals") — overdue/due-soon badges read pursuit, everything + // else stays neutral graphite. Never red, never amber. + const urgent = dueInfo ? !isCompleted && (dueInfo.isOverdue || dueInfo.days <= 3) : false; + const hasExpandable = Boolean(goal.description || goal.coach_notes); + + return ( + + +
+ {/* Complete toggle — 24px visual circle, 44px hit area (`-m-2.5` + expands the button by 10px on every side without shifting the + visual position of the circle inside the row). */} + + + {/* Content */} +
+
+
+

+ {goal.title} +

+ {goal.category ? ( + {goal.category} + ) : null} +
+ + {dueInfo && !isCompleted ? ( + + ) : null} +
+ + {/* Progress — a single green fill, never a red/amber/blue + threshold ramp (the two-ink law: this is a team/dev lane). */} + {!isCompleted ? ( +
+
+ Progress + +
+
+ +
+
+ ) : null} + + {/* Expand toggle — Button already guarantees a >=44px hit area + (`size="sm"` → `min-h-[44px]`); only the typography is + re-skinned to the kit's small-caps eyebrow voice. */} + {hasExpandable ? ( + + ) : null} + + + {isExpanded ? ( + +
+ {goal.description ? ( +

{goal.description}

+ ) : null} + {goal.coach_notes ? ( +
+ Coach note +

+ {goal.coach_notes} +

+
+ ) : null} +
+
+ ) : null} +
+ + {isCompleted && goal.completed_at ? ( +

+ + Completed {new Date(goal.completed_at).toLocaleDateString()} +

+ ) : null} +
+
+
+
+ ); +} + +// Loading skeleton for dev plan +function DevPlanSkeleton() { + return ( +
+
+
+
+
+
+
+
+ {[0, 1, 2].map((i) => ( +
+
+
+ ))} +
+
+ ); +} + +// Goals list component with empty state +function GoalsList({ + goals, + onComplete, + onUncomplete, + isPending, + emptyMessage, +}: { + goals: DevPlanGoal[]; + onComplete: (goalId: string) => void; + onUncomplete: (goalId: string) => void; + isPending: boolean; + emptyMessage: string; +}) { + if (goals.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + {goals.map((goal) => ( + + ))} + +
+ ); +} + +export default function DevPlanClient() { + const { user, player, loading: authLoading } = useAuth(); + const { showToast } = useToast(); + const [plan, setPlan] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + const [activeTab, setActiveTab] = useState('active'); + + // Fetch plan data + const fetchPlan = useCallback(async () => { + if (!player?.id) return; + + try { + const data = await getActiveDevPlan(player.id); + setPlan(data); + } catch (err) { + console.error('Error fetching dev plan:', err); + setError('Failed to load your development plan'); + } finally { + setIsLoading(false); + } + }, [player?.id]); + + useEffect(() => { + if (player?.id) { + fetchPlan(); + } + }, [player?.id, fetchPlan]); + + // Handle goal completion + const handleComplete = useCallback( + (goalId: string) => { + if (!plan) return; + + startTransition(async () => { + try { + await completeGoalAsPlayer(plan.id, goalId); + await fetchPlan(); + } catch { + showToast('Could not mark goal complete', 'error'); + } + }); + }, + [plan, fetchPlan, showToast] + ); + + // Handle goal uncomplete + const handleUncomplete = useCallback( + (goalId: string) => { + if (!plan) return; + + startTransition(async () => { + try { + await uncompleteGoalAsPlayer(plan.id, goalId); + await fetchPlan(); + } catch { + showToast('Could not update goal', 'error'); + } + }); + }, + [plan, fetchPlan, showToast] + ); + + // Categorize goals + const categorizedGoals = useMemo(() => { + if (!plan?.goals) return { active: [], upcoming: [], completed: [], all: [] }; + + const now = new Date(); + now.setHours(0, 0, 0, 0); + + const completed = plan.goals.filter((g) => g.status === 'completed'); + const notCompleted = plan.goals.filter((g) => g.status !== 'completed'); + + // Upcoming: not started AND target date is > 7 days away + const upcoming = notCompleted.filter((g) => { + if (g.status !== 'not_started') return false; + if (!g.target_date) return false; + const targetDate = new Date(g.target_date); + targetDate.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + return diffDays > 7; + }); + + // Active: in progress OR (not started AND due within 7 days OR no target date) + const active = notCompleted.filter((g) => !upcoming.includes(g)); + + return { + active, + upcoming, + completed, + all: plan.goals, + }; + }, [plan?.goals]); + + // Stats + const totalGoals = plan?.goals.length || 0; + const completedCount = categorizedGoals.completed.length; + const activeCount = categorizedGoals.active.length; + const upcomingCount = categorizedGoals.upcoming.length; + const completionPercent = totalGoals > 0 ? Math.round((completedCount / totalGoals) * 100) : 0; + + // Average progress of active goals + const avgProgress = useMemo(() => { + if (categorizedGoals.active.length === 0) return 0; + const total = categorizedGoals.active.reduce((sum, g) => sum + g.progress, 0); + return Math.round(total / categorizedGoals.active.length); + }, [categorizedGoals.active]); + + if (authLoading) return ; + + if (user?.role !== 'player') { + return ( +
+
+ +
+
+ ); + } + + const allDone = activeCount === 0 && upcomingCount === 0 && completedCount > 0; + + return ( +
+
+ + +

+ {plan ? `Assigned by ${plan.coach?.full_name || 'Your Coach'}` : 'Track your progress and complete goals set by your coach.'} +

+
+
+ +
+ {isLoading ? ( + + ) : error ? ( + { + setIsLoading(true); + setError(null); + void fetchPlan(); + }} + > + Try again + + } + /> + ) : !plan ? ( +
+ + + + How it works +
+ {HOW_IT_WORKS.map((step, i) => ( +
+ + {i + 1} + +
+

{step.title}

+

{step.body}

+
+
+ ))} +
+
+
+ ) : ( +
+ {/* Progress overview */} + + Progress +
+ = 100} /> +
+ +
+
+ + {avgProgress > 0 ? ( +

{avgProgress}% avg progress

+ ) : null} +
+
+ +

goals queued

+
+
+ 0} /> +

of {totalGoals} goals

+
+
+
+ + {/* Plan header */} + {plan.description ? ( + +
+ {plan.title} + {plan.status ? ( + + ) : null} +
+

{plan.description}

+ {plan.start_date || plan.end_date ? ( +
+ + {plan.start_date ? Started {new Date(plan.start_date).toLocaleDateString()} : null} + {plan.start_date && plan.end_date ? · : null} + {plan.end_date ? Ends {new Date(plan.end_date).toLocaleDateString()} : null} +
+ ) : null} +
+ ) : null} + + {/* Goals with Tabs */} + + + } badge={activeCount > 0 ? activeCount : undefined}> + Active + + } badge={upcomingCount > 0 ? upcomingCount : undefined}> + Upcoming + + } badge={completedCount > 0 ? completedCount : undefined}> + Completed + + } badge={totalGoals}> + All + + + + + + + + + + + + + + + + + + + + + {/* All goals completed */} + {allDone ? ( + + + + ) : null} +
+ )} +
+
+
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plan/page.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plan/page.tsx index a3b540529..4ff63d422 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plan/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plan/page.tsx @@ -1,745 +1,24 @@ -'use client'; - -import { useEffect, useState, useCallback, useTransition, useMemo } from 'react'; -import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; -import { Header } from '@/components/layout/header'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { PageLoading } from '@/components/ui/loading'; -import { Skeleton } from '@/components/ui/skeleton'; -import { ProgressRing } from '@/components/ui/progress-ring'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { cn } from '@/lib/utils'; -import { - IconNote, - IconTarget, - IconCheck, - IconClock, - IconMessage, - IconCalendar, - IconSparkles, - IconChevronDown, - IconChevronUp, - IconTrendingUp, - IconList, -} from '@/components/icons'; -import { useAuth } from '@/hooks/use-auth'; -import { Button } from '@/components/ui/button'; -import { useToast } from '@/components/ui/sonner'; -import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; -import { - getActiveDevPlan, - completeGoalAsPlayer, - uncompleteGoalAsPlayer, - type DevelopmentalPlanWithGoals, - type DevPlanGoal, -} from '@/app/baseball/actions/dev-plans'; - -// Helper to calculate days until/since a date -function getDaysUntil(dateStr: string): { days: number; label: string; isOverdue: boolean; isUpcoming: boolean } { - const target = new Date(dateStr); - const today = new Date(); - today.setHours(0, 0, 0, 0); - target.setHours(0, 0, 0, 0); - - const diffTime = target.getTime() - today.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays < 0) { - return { days: Math.abs(diffDays), label: `${Math.abs(diffDays)} day${Math.abs(diffDays) !== 1 ? 's' : ''} overdue`, isOverdue: true, isUpcoming: false }; - } else if (diffDays === 0) { - return { days: 0, label: 'Due today', isOverdue: false, isUpcoming: false }; - } else if (diffDays === 1) { - return { days: 1, label: 'Due tomorrow', isOverdue: false, isUpcoming: false }; - } else if (diffDays > 7) { - return { days: diffDays, label: `Due in ${diffDays} days`, isOverdue: false, isUpcoming: true }; - } else { - return { days: diffDays, label: `Due in ${diffDays} days`, isOverdue: false, isUpcoming: false }; - } -} - -// Goal card component -function GoalCard({ - goal, - planId: _planId, - onComplete, - onUncomplete, - isPending, -}: { - goal: DevPlanGoal; - planId: string; // Reserved for future use (e.g., deep links) - onComplete: (goalId: string) => void; - onUncomplete: (goalId: string) => void; - isPending: boolean; -}) { - const prefersReducedMotion = useReducedMotion(); - const [isExpanded, setIsExpanded] = useState(false); - const isCompleted = goal.status === 'completed'; - const dueInfo = goal.target_date ? getDaysUntil(goal.target_date) : null; - - return ( - -
- {/* Checkbox */} - - - {/* Content */} -
-
-
-

- {goal.title} -

- {goal.category && ( - - {goal.category} - - )} -
- - {/* Due date badge */} - {dueInfo && !isCompleted && ( - - {dueInfo.label} - - )} -
- - {/* Progress bar */} - {!isCompleted && ( -
-
- Progress - {goal.progress}% -
-
- = 75 - ? 'bg-primary-500' - : goal.progress >= 50 - ? 'bg-blue-500' - : goal.progress >= 25 - ? 'bg-amber-500' - : 'bg-warm-300' - )} - initial={prefersReducedMotion ? false : ({ width: 0 })} - animate={{ width: `${goal.progress}%` }} - transition={prefersReducedMotion ? { duration: 0 } : ({ duration: 0.5, ease: 'easeOut' })} - /> -
-
- )} - - {/* Expand button for description/notes */} - {(goal.description || goal.coach_notes) && ( - - )} - - {/* Expanded content */} - - {isExpanded && ( - -
- {goal.description && ( -

{goal.description}

- )} - {goal.coach_notes && ( -
- -
-

Coach Notes

-

{goal.coach_notes}

-
-
- )} -
-
- )} -
- - {/* Completion date */} - {isCompleted && goal.completed_at && ( -

- - Completed {new Date(goal.completed_at).toLocaleDateString()} -

- )} -
-
-
- ); -} - -// Loading skeleton for dev plan -function DevPlanSkeleton() { - return ( - <> - {/* Progress overview skeleton */} - - -
- -
- {[1, 2, 3].map((i) => ( -
- - -
- ))} -
-
-
-
- - {/* Tabs skeleton */} - - - {/* Goals skeleton */} -
- {[1, 2, 3].map((i) => ( -
-
- -
- - -
- -
-
-
-
- ))} -
- - ); -} - -// Empty state -function EmptyState() { - return ( - - -
- -
-

- No development plan yet -

-

- Your coach hasn't sent a development plan yet. Once they send one, you'll see your goals and track your progress here. -

-
- - Check back soon! -
-
-
- ); -} - -// Goals list component with empty state -function GoalsList({ - goals, - planId, - onComplete, - onUncomplete, - isPending, - emptyMessage, - emptyIcon: EmptyIcon, -}: { - goals: DevPlanGoal[]; - planId: string; - onComplete: (goalId: string) => void; - onUncomplete: (goalId: string) => void; - isPending: boolean; - emptyMessage: string; - emptyIcon: React.ComponentType<{ size?: number; className?: string }>; -}) { - if (goals.length === 0) { - return ( -
-
- -
-

{emptyMessage}

-
- ); - } - - return ( -
- - {goals.map((goal) => ( - - ))} - -
- ); -} - -export default function PlayerDevPlanPage() { - const prefersReducedMotion = useReducedMotion(); - const { user, player, loading: authLoading } = useAuth(); - const { showToast } = useToast(); - const [plan, setPlan] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isPending, startTransition] = useTransition(); - const [activeTab, setActiveTab] = useState('active'); - - // Fetch plan data - const fetchPlan = useCallback(async () => { - if (!player?.id) return; - - try { - const data = await getActiveDevPlan(player.id); - setPlan(data); - } catch (err) { - console.error('Error fetching dev plan:', err); - setError('Failed to load your development plan'); - } finally { - setIsLoading(false); - } - }, [player?.id]); - - useEffect(() => { - if (player?.id) { - fetchPlan(); - } - }, [player?.id, fetchPlan]); - - // Handle goal completion - const handleComplete = useCallback( - (goalId: string) => { - if (!plan) return; - - startTransition(async () => { - try { - await completeGoalAsPlayer(plan.id, goalId); - await fetchPlan(); - } catch { - showToast('Could not mark goal complete', 'error'); - } - }); - }, - [plan, fetchPlan, showToast] - ); - - // Handle goal uncomplete - const handleUncomplete = useCallback( - (goalId: string) => { - if (!plan) return; - - startTransition(async () => { - try { - await uncompleteGoalAsPlayer(plan.id, goalId); - await fetchPlan(); - } catch { - showToast('Could not update goal', 'error'); - } - }); - }, - [plan, fetchPlan, showToast] - ); - - // Categorize goals - const categorizedGoals = useMemo(() => { - if (!plan?.goals) return { active: [], upcoming: [], completed: [], all: [] }; - - const now = new Date(); - now.setHours(0, 0, 0, 0); - - const completed = plan.goals.filter((g) => g.status === 'completed'); - const notCompleted = plan.goals.filter((g) => g.status !== 'completed'); - - // Upcoming: not started AND target date is > 7 days away - const upcoming = notCompleted.filter((g) => { - if (g.status !== 'not_started') return false; - if (!g.target_date) return false; - const targetDate = new Date(g.target_date); - targetDate.setHours(0, 0, 0, 0); - const diffDays = Math.ceil((targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - return diffDays > 7; - }); - - // Active: in progress OR (not started AND due within 7 days OR no target date) - const active = notCompleted.filter((g) => !upcoming.includes(g)); - - return { - active, - upcoming, - completed, - all: plan.goals, - }; - }, [plan?.goals]); - - // Stats - const totalGoals = plan?.goals.length || 0; - const completedCount = categorizedGoals.completed.length; - const activeCount = categorizedGoals.active.length; - const upcomingCount = categorizedGoals.upcoming.length; - const completionPercent = totalGoals > 0 ? Math.round((completedCount / totalGoals) * 100) : 0; - - // Average progress of active goals - const avgProgress = useMemo(() => { - if (categorizedGoals.active.length === 0) return 0; - const total = categorizedGoals.active.reduce((sum, g) => sum + g.progress, 0); - return Math.round(total / categorizedGoals.active.length); - }, [categorizedGoals.active]); - - if (authLoading) return ; - - if (user?.role !== 'player') { - return ( -
- - -

Only players can access this page.

-
-
-
- ); - } - - return ( - <> -
- -
- {isLoading ? ( - - ) : error ? ( - { - setIsLoading(true); - setError(null); - void fetchPlan(); - }} - /> - ) : !plan ? ( - <> - - - {/* How it works card */} - - -

How Development Plans Work

-
- -
    -
  • - - 1 - -
    - Coach creates your plan -

    - Your coach will set specific goals and drills tailored to your needs -

    -
    -
  • -
  • - - 2 - -
    - Work on your goals -

    - Follow the drills and practice routines to improve your skills -

    -
    -
  • -
  • - - 3 - -
    - Track your progress -

    - Mark goals as complete and see how far you've come -

    -
    -
  • -
  • - - 4 - -
    - Celebrate achievements -

    - Your coach will review your progress and set new goals -

    -
    -
  • -
-
-
- - ) : ( - <> - {/* Progress Overview with Circular Ring */} - - -
- {/* Circular progress */} -
- -
- - {/* Stats grid */} -
-
-

Active

-

{activeCount}

- {avgProgress > 0 && ( -

- - {avgProgress}% avg progress -

- )} -
-
-

Upcoming

-

{upcomingCount}

-

goals queued

-
-
-

Done

-

{completedCount}

-

of {totalGoals} goals

-
-
-
-
-
- - {/* Plan Header */} - {plan.description && ( - - -
-
- -
-
-
-

{plan.title}

- {plan.status && ( - - {plan.status === 'in_progress' ? 'In Progress' : 'Sent'} - - )} -
-

{plan.description}

- {(plan.start_date || plan.end_date) && ( -
- - {plan.start_date && ( - Started {new Date(plan.start_date).toLocaleDateString()} - )} - {plan.start_date && plan.end_date && } - {plan.end_date && ( - Ends {new Date(plan.end_date).toLocaleDateString()} - )} -
- )} -
-
-
-
- )} - - {/* Goals with Tabs */} - - - } - badge={activeCount > 0 ? activeCount : undefined} - > - Active - - } - badge={upcomingCount > 0 ? upcomingCount : undefined} - > - Upcoming - - } - badge={completedCount > 0 ? completedCount : undefined} - > - Completed - - } badge={totalGoals}> - All - - - - - - - - - - - - - - - - - - - - - {/* All goals completed celebration */} - {activeCount === 0 && upcomingCount === 0 && completedCount > 0 && ( - - - -
- -
-

- Congratulations! 🎉 -

-

- You've completed all your goals! Check back soon for new challenges from your - coach. -

-
-
-
- )} - - )} -
- - ); +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/dev-plan/page.tsx +// +// COHERENCE_RULING_2026-07-08 Ruling 5 — "dev-plan cold-URL bounce" fix. +// +// Was fully client-rendered (own useAuth + getActiveDevPlan fetch cycle), +// which raced (dashboard)/layout.tsx's client-side DashboardSessionGuard on a +// hard/cold navigation straight to this URL: both the guard's auth/nav-context +// resolution and this page's own auth resolution ran as separate async client +// hooks with no ordering guarantee, occasionally producing a transient 500 +// before the guard's redirect settled. Server-resolving the player session +// FIRST (requireBaseballPlayerRoute — redirects synchronously server-side, +// before any client hydration is even possible) removes the race outright. +// Matches the same guard-then-client-render pattern every other server page +// in this route group uses (e.g. my-stats/page.tsx, pipeline/page.tsx). +// ============================================================================= + +import { requireBaseballPlayerRoute } from '@/lib/baseball/server-route-guards'; +import DevPlanClient from './DevPlanClient'; + +export default async function DevPlanPage() { + await requireBaseballPlayerRoute(); + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx index cff00d676..47797c945 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx @@ -6,6 +6,7 @@ import { Header } from '@/components/layout/header'; import { PageLoading } from '@/components/ui/loading'; import { Card, CardContent } from '@/components/ui/card'; import { PlanDetail } from '@/components/baseball/dev-plans/PlanDetail'; +import { BreadcrumbLabel } from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; import { useAuth } from '@/hooks/use-auth'; import { useToast } from '@/components/ui/sonner'; import { @@ -123,6 +124,10 @@ export default function DevPlanDetailPage() { return ( <> + {/* Ruling 4: the shell's breadcrumb has no registry entry for a + dynamic plan id — this supplies the real plan title so the trail + never falls back to a raw UUID segment. */} +
- setPreviewDoc(d)} - onUploadVersion={isCoach ? (d) => setVersionDoc(d) : undefined} - onDelete={isCoach ? handleDelete : undefined} - onEdit={isCoach ? openEditModal : undefined} - onViewHistory={isCoach ? openVersionHistory : undefined} - onMoveToFolder={isCoach ? openMoveModal : undefined} - fileInputSlot={ - isCoach ? ( - - - ) : null - } - previewSlot={ - { - if (!open) setPreviewDoc(null); - }} - /> - } - versionSlot={ - versionDoc && isCoach ? ( - setVersionDoc(null)} - documentTitle={versionDoc.title} - currentFileType={versionDoc.file_type || null} - onUpload={handleUploadNewVersion} - /> - ) : null - } - editSlot={ - isCoach ? ( - setEditingDoc(null)} - onSave={handleSaveEdit} - /> - ) : null - } - historySlot={ - isCoach ? ( - setHistoryDoc(null)} - onReverted={handleDocumentReverted} - /> - ) : null - } - moveSlot={ - isCoach ? ( - setMovingDoc(null)} - onMove={handleSaveMove} - /> - ) : null - } - /> -
- ); - } - return ( -
- {/* Hidden file input driving both the header and empty-state upload triggers */} - {isCoach && ( - - - )} - - {/* Search & Filter Bar */} -
-
- setSearch(e.target.value)} - placeholder="Search documents..." - leftIcon={} - className="text-sm bg-cream-50 border-warm-200 rounded-lg" - /> -
-
- {CATEGORIES.map(cat => ( - - ))} -
- {isCoach && ( - - )} -
- - {/* Upload-time picker — category + visibility applied to the next file selected */} - {isCoach && ( -
-
- Next upload -
- + ) : null + } + previewSlot={ + { + if (!open) setPreviewDoc(null); + }} /> -
- )} - - {/* Documents Grid */} - {filtered.length === 0 ? ( - - -
- -
-

- {documents.length === 0 ? 'No Documents' : 'No Results'} -

-

- {documents.length === 0 - ? isCoach - ? 'Upload playbooks, practice plans, waivers, and other team documents.' - : 'No documents have been shared yet. Check back later.' - : 'Try adjusting your search or filters.'} -

- {documents.length === 0 && isCoach && ( - - )} -
-
- ) : ( -
- {filtered.map(doc => ( - setPreviewDoc(d)} - onUploadVersion={isCoach ? (d) => setVersionDoc(d) : undefined} - onDelete={isCoach ? handleDelete : undefined} - onEdit={isCoach ? openEditModal : undefined} - onViewHistory={isCoach ? openVersionHistory : undefined} - onMoveToFolder={isCoach ? openMoveModal : undefined} + } + versionSlot={ + versionDoc && isCoach ? ( + setVersionDoc(null)} + documentTitle={versionDoc.title} + currentFileType={versionDoc.file_type || null} + onUpload={handleUploadNewVersion} /> - ))} -
- )} - - {/* Preview Modal */} - { if (!open) setPreviewDoc(null); }} + ) : null + } + editSlot={ + isCoach ? ( + setEditingDoc(null)} + onSave={handleSaveEdit} + /> + ) : null + } + historySlot={ + isCoach ? ( + setHistoryDoc(null)} + onReverted={handleDocumentReverted} + /> + ) : null + } + moveSlot={ + isCoach ? ( + setMovingDoc(null)} + onMove={handleSaveMove} + /> + ) : null + } /> - - {/* Upload New Version Modal */} - {versionDoc && isCoach && ( - setVersionDoc(null)} - documentTitle={versionDoc.title} - currentFileType={versionDoc.file_type || null} - onUpload={handleUploadNewVersion} - /> - )} - - {/* Edit Details Modal */} - {isCoach && ( - setEditingDoc(null)} - onSave={handleSaveEdit} - /> - )} - - {/* Version History Modal */} - {isCoach && ( - setHistoryDoc(null)} - onReverted={handleDocumentReverted} - /> - )} - - {/* Move to Folder Modal */} - {isCoach && ( - setMovingDoc(null)} - onMove={handleSaveMove} - /> - )}
); } diff --git a/src/app/baseball/(dashboard)/dashboard/documents/page.tsx b/src/app/baseball/(dashboard)/dashboard/documents/page.tsx index 6ec044917..916b44cd0 100644 --- a/src/app/baseball/(dashboard)/dashboard/documents/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/documents/page.tsx @@ -5,7 +5,7 @@ import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { DocumentsClient } from './documents-client'; import { getTeamDocuments } from '@/app/baseball/actions/documents'; import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; -import { EmptyState } from '@/components/ui/empty-state'; +import { EditorsLetter } from '@/components/baseball/living-annual'; export const metadata: Metadata = { title: 'Documents | BaseballHelm', @@ -24,11 +24,11 @@ export default async function BaseballDocumentsPage() { const ctx = await getActiveBaseballContext(); if (!ctx?.activeTeamId) { return ( -
- +
); @@ -41,7 +41,7 @@ export default async function BaseballDocumentsPage() { if (error) { return ( -
+
-
+
+ +
@@ -311,11 +322,15 @@ export default function EventsPage() { if (!coach) { return ( <> -
+
+ +
-
-

Please log in as a showcase coach to manage events.

-
+
); @@ -327,19 +342,25 @@ export default function EventsPage() { return ( <> -
- -
+
+ setShowCreateModal(true)}> + + New Event + + } + > +

+ {loading + ? 'Loading…' + : `${filteredEvents.length} upcoming event${filteredEvents.length !== 1 ? 's' : ''}`} +

+
+
{/* Filters */} @@ -368,13 +389,12 @@ export default function EventsPage() {
- {/* Fetch error */} + {/* Fetch error — page-level load failure, via the kit's composed + error surface (design-system-living-annual.md §7: empty AND error + states render through EditorsLetter, never a red/amber inline box). */} {fetchError && ( -
- {fetchError} +
+
)} @@ -382,20 +402,16 @@ export default function EventsPage() { {loading ? ( ) : filteredEvents.length === 0 ? ( - /* Honest empty state */ -
-
- -
-

No upcoming events

-

- No upcoming events — coaches can add events from the calendar. -

- -
+ setShowCreateModal(true)}> + + Create Your First Event + + } + /> ) : (
{Object.entries(groupedEvents).map(([date, dateEvents]) => ( diff --git a/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx b/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx index e43f1296c..d80452c90 100644 --- a/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx @@ -2,15 +2,81 @@ // src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx // // V11 player lift execution (spec route /baseball/dashboard/lift/[sessionId], -// L34; "During lift" L487-499). SELF-ONLY: getPlayerLiftSession returns only the -// current player's session (RLS-backed). 404-honest when not found. +// L34; "During lift" L487-499). SELF-ONLY. +// +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/players/PlayerLiftSessionClient (native HelmLifting* +// props; writes via src/app/lifting/actions/player-sessions.ts, which is +// athlete-self / RLS-backed — no org-coach access gate, so this is safe for +// baseball athletes exactly as it is for /lifting-native ones) instead of the +// legacy src/components/baseball/performance/PlayerLiftSessionClient. +// +// Data fetched directly from helm_lifting_sessions / _session_exercises / +// _set_results here (mirroring src/app/lifting/(dashboard)/dashboard/lift/ +// [sessionId]/page.tsx) rather than via getPlayerLiftSession (which still +// returns the legacy BaseballLift* adapted shape for the now-deleted +// component and is frozen for this lane). // ============================================================================= import { notFound, redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; -import { getPlayerLiftSession } from '@/lib/baseball/read-models/player-lift'; -import { PlayerLiftSessionClient } from '@/components/baseball/performance/PlayerLiftSessionClient'; +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { PlayerLiftSessionClient } from '@/components/lifting/players/PlayerLiftSessionClient'; +import { resolvePlayerLiftAthleteContext, hasReadinessCheckinToday } from '../_lift-athlete-context'; +import type { + HelmLiftingSessionRow, + HelmLiftingSessionExerciseRow, + HelmLiftingSetResultRow, + HelmLiftingSessionWithExercises, +} from '@/lib/types/helm-lifting-data'; + +async function fetchSessionWithExercises( + sessionId: string, + athleteId: string, + organizationId: string, +): Promise { + const supabase = await createClient(); + + // athlete_id filter is an extra UX guard on top of RLS (helm_lifting_is_my_athlete). + const { data: rawSession } = (await fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('id', sessionId) + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .maybeSingle()) as { data: HelmLiftingSessionRow | null }; + + if (!rawSession) return null; + + const { data: exerciseRows } = (await fromUntyped(supabase, 'helm_lifting_session_exercises') + .select('*') + .eq('session_id', sessionId) + .order('order_index', { ascending: true })) as { data: HelmLiftingSessionExerciseRow[] | null }; + + const exercises = exerciseRows ?? []; + const exerciseIds = exercises.map((ex) => ex.id); + const setsByExercise = new Map(); + + if (exerciseIds.length > 0) { + const { data: setResults } = (await fromUntyped(supabase, 'helm_lifting_set_results') + .select('*') + .in('session_exercise_id', exerciseIds) + .eq('athlete_id', athleteId) + .order('set_number', { ascending: true })) as { data: HelmLiftingSetResultRow[] | null }; + + for (const result of setResults ?? []) { + const arr = setsByExercise.get(result.session_exercise_id) ?? []; + arr.push(result); + setsByExercise.set(result.session_exercise_id, arr); + } + } + + return { + ...rawSession, + exercises: exercises.map((ex) => ({ ...ex, sets: setsByExercise.get(ex.id) ?? [] })), + }; +} export default async function PlayerLiftSessionPage({ params, @@ -24,12 +90,28 @@ export default async function PlayerLiftSessionPage({ redirect('/baseball/dashboard/performance'); } - const session = await getPlayerLiftSession(context.activePlayerId, sessionId); + const athleteCtx = await resolvePlayerLiftAthleteContext(context.activePlayerId); + if (!athleteCtx) { + // No athlete profile in the Lab yet — send back to lift home. + redirect('/baseball/dashboard/lift'); + } + + const { organizationId, athleteId } = athleteCtx; + + const [session, readinessSubmittedToday] = await Promise.all([ + fetchSessionWithExercises(sessionId, athleteId, organizationId), + hasReadinessCheckinToday(athleteId, organizationId), + ]); + if (!session) notFound(); return (
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/lift/_lift-athlete-context.ts b/src/app/baseball/(dashboard)/dashboard/lift/_lift-athlete-context.ts new file mode 100644 index 000000000..1faf6988d --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/lift/_lift-athlete-context.ts @@ -0,0 +1,86 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/lift/_lift-athlete-context.ts +// +// Shared server-only helpers for the two Player Lift routes (home + session +// execution) now that both pages render the canonical Helm Lifting Lab +// components (src/components/lifting/players/*) instead of the legacy +// src/components/baseball/performance/PlayerLift{Home,Session}Client. +// +// Resolves the SELF-ONLY athlete identity chain used by the canonical +// /lifting/dashboard/lift routes (organization_id + helm_lifting_athletes.id) +// starting from the baseball_players.id the active-context resolver already +// gives us. Deliberately NOT placed under src/lib/baseball/read-models/ (that +// directory is frozen for this lane) — it composes the existing, allowed +// resolve-baseball-context helpers instead of querying helm_lifting_* itself +// where possible. +// ============================================================================= + +import 'server-only'; + +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { + resolveBaseballLiftingOrg, + resolveMyBaseballAthleteId, +} from '@/lib/lifting/resolve-baseball-context'; + +export interface PlayerLiftAthleteContext { + organizationId: string; + teamId: string; + athleteId: string; +} + +/** baseball_players.id -> baseball_teams.id (first active membership). */ +async function resolvePlayerTeamId( + supabase: Awaited>, + playerId: string, +): Promise { + const { data } = await supabase + .from('baseball_team_members') + .select('team_id') + .eq('player_id', playerId) + .maybeSingle(); + return (data?.team_id as string | undefined) ?? null; +} + +/** + * Full resolution chain: baseball playerId -> teamId -> organizationId -> + * helm_lifting_athletes.id. Returns null at any step that cannot be + * resolved (degrade-gracefully — the caller renders an honest empty state, + * never an error, matching the existing player-lift read-model's contract). + */ +export async function resolvePlayerLiftAthleteContext( + playerId: string, +): Promise { + if (!playerId) return null; + + const supabase = await createClient(); + const teamId = await resolvePlayerTeamId(supabase, playerId); + if (!teamId) return null; + + const liftCtx = await resolveBaseballLiftingOrg(teamId); + if (!liftCtx) return null; + + const athleteId = await resolveMyBaseballAthleteId(liftCtx.organizationId); + if (!athleteId) return null; + + return { organizationId: liftCtx.organizationId, teamId, athleteId }; +} + +/** Whether the athlete has a helm_lifting_readiness_checkins row for today. */ +export async function hasReadinessCheckinToday( + athleteId: string, + organizationId: string, +): Promise { + const supabase = await createClient(); + const today = new Date().toISOString().slice(0, 10); + + const { data } = (await fromUntyped(supabase, 'helm_lifting_readiness_checkins') + .select('id') + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .eq('checkin_date', today) + .maybeSingle()) as { data: { id: string } | null }; + + return data !== null; +} diff --git a/src/app/baseball/(dashboard)/dashboard/lift/page.tsx b/src/app/baseball/(dashboard)/dashboard/lift/page.tsx index bb2815739..2705d0f21 100644 --- a/src/app/baseball/(dashboard)/dashboard/lift/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/lift/page.tsx @@ -2,16 +2,72 @@ // src/app/baseball/(dashboard)/dashboard/lift/page.tsx // // V11 Player Lift Home (spec route /baseball/dashboard/lift, L33; "Player Lift -// Experience" L465-520). SELF-ONLY: resolves the active player from the session; -// reads getPlayerLiftHome (RLS-backed) and hands a serializable view-model to the -// client. Players only; staff are redirected to the Performance dashboard. +// Experience" L465-520). SELF-ONLY: players only; staff are redirected to the +// Performance dashboard. +// +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/players/PlayerLiftHomeClient (native HelmLifting* +// props, no baseball-view-adapter) instead of the legacy +// src/components/baseball/performance/PlayerLiftHomeClient. Data is fetched +// directly from helm_lifting_sessions / helm_lifting_readiness_checkins here +// (mirroring src/app/lifting/(dashboard)/dashboard/lift/page.tsx) rather than +// via getPlayerLiftHome (which still returns the legacy BaseballLift* shape +// for the now-deleted component and is frozen for this lane). +// +// The first-run onboarding tour (Task C) is preserved: LiftOnboardingGate is +// a standalone overlay (not a wrapper), so it renders alongside the canonical +// list instead of inside it. The bespoke LiftLabWelcomeState branded empty +// state is not carried over — a brand-new athlete with zero upcoming/recent +// sessions now sees the canonical component's own on-brand EmptyState. // ============================================================================= import { redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; -import { getPlayerLiftHome, getPlayerLiftOnboardingState } from '@/lib/baseball/read-models/player-lift'; -import { PlayerLiftHomeClient } from '@/components/baseball/performance/PlayerLiftHomeClient'; +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { getPlayerLiftOnboardingState } from '@/lib/baseball/read-models/player-lift'; +import { PlayerLiftHomeClient } from '@/components/lifting/players/PlayerLiftHomeClient'; +import { LiftOnboardingGate } from '@/components/baseball/performance/lift-onboarding'; +import { resolvePlayerLiftAthleteContext, hasReadinessCheckinToday } from './_lift-athlete-context'; +import type { HelmLiftingSessionRow, HelmLiftingSessionStatus } from '@/lib/types/helm-lifting-data'; + +const OPEN_STATUSES: HelmLiftingSessionStatus[] = ['assigned', 'started', 'modified']; + +async function fetchPlayerSessions( + athleteId: string, + organizationId: string, +): Promise<{ upcoming: HelmLiftingSessionRow[]; recent: HelmLiftingSessionRow[] }> { + const supabase = await createClient(); + const today = new Date().toISOString().slice(0, 10); + + // Upcoming: today + future, plus overdue-but-still-open (mirrors the + // canonical /lifting/dashboard/lift resolution exactly). + const { data: upcomingRows } = (await fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .or( + `and(scheduled_date.gte.${today}),` + + `and(status.in.(assigned,started,modified),scheduled_date.lt.${today})`, + ) + .order('scheduled_date', { ascending: true }) + .limit(20)) as { data: HelmLiftingSessionRow[] | null }; + + const upcoming = (upcomingRows ?? []).filter( + (s) => s.scheduled_date >= today || OPEN_STATUSES.includes(s.status), + ); + + const { data: recentRows } = (await fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .eq('status', 'completed') + .order('completed_at', { ascending: false }) + .limit(10)) as { data: HelmLiftingSessionRow[] | null }; + + return { upcoming, recent: recentRows ?? [] }; +} export default async function PlayerLiftPage() { const context = await getActiveBaseballContext(); @@ -20,22 +76,36 @@ export default async function PlayerLiftPage() { redirect('/baseball/dashboard/performance'); } - // Task C (additive): getPlayerLiftOnboardingState runs alongside the - // existing getPlayerLiftHome fetch — independent reads, safe to - // parallelize; getPlayerLiftHome's own behavior is untouched. - const [home, onboarding] = await Promise.all([ - getPlayerLiftHome(context.activePlayerId), + const athleteCtx = await resolvePlayerLiftAthleteContext(context.activePlayerId); + + // Not yet seeded in the Lab (org-less team, or backfill hasn't run) — + // render the canonical component's own honest empty state. + if (!athleteCtx) { + return ( +
+ +
+ ); + } + + const { organizationId, athleteId } = athleteCtx; + + const [{ upcoming, recent }, readinessSubmittedToday, onboarding] = await Promise.all([ + fetchPlayerSessions(athleteId, organizationId), + hasReadinessCheckinToday(athleteId, organizationId), getPlayerLiftOnboardingState(context.activePlayerId), ]); return (
+
); diff --git a/src/app/baseball/(dashboard)/dashboard/messages/loading.tsx b/src/app/baseball/(dashboard)/dashboard/messages/loading.tsx index 63f25255f..58b7f33e2 100644 --- a/src/app/baseball/(dashboard)/dashboard/messages/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/messages/loading.tsx @@ -1,13 +1,13 @@ -import { Header } from '@/components/layout/header'; import { SkeletonMessages } from '@/components/ui/skeleton'; +// No page-level
— the Fairway shell (BaseballFairwayShell → AppShell) +// already owns the one top bar + breadcrumb for every dashboard route, +// "Messages" included. Mirrors the sibling announcements/tasks/documents/ +// travel `loading.tsx` files, none of which mount a Header either. export default function MessagesLoading() { return ( - <> -
-
- -
- +
+ +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx index 70bf34032..98c6b93ca 100644 --- a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx @@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useMemo, useRef } from 'react'; import { useSearchParams } from 'next/navigation'; -import { cn } from '@/lib/utils'; import { Loading } from '@/components/ui/loading'; import { LazyConversationList, LazyChatWindow } from '@/lib/lazy-components'; import { EmptyChatState } from '@/components/messages/EmptyChatState'; @@ -14,7 +13,6 @@ import { createConversation, getPlayerUserId } from '@/app/baseball/actions/mess import type { ConversationWithMeta } from '@/lib/types/messages'; import { getParticipantDetails } from '@/lib/types/messages'; import { MessagesFairway } from '@/components/baseball/messages/MessagesFairway'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; function MessagesContent() { const searchParams = useSearchParams(); @@ -169,88 +167,11 @@ function MessagesContent() { return success; }; - if (isRedesignEnabled()) { - return ( - setShowNewMessageModal(true)} - className="h-full" - /> - } - chatSlot={ - selectedConversationId ? ( - - ) : ( - setShowNewMessageModal(true)} - className="h-full" - /> - ) - } - modalSlot={ - setShowNewMessageModal(false)} - onSelect={handleNewConversation} - currentUserRole={currentUserRole} - /> - } - /> - ); - } - - if (conversationsLoading) { - return ( -
- {/* Conversation list skeleton */} -
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
- {/* Chat area skeleton */} -
- -
-
- ); - } - return ( -
- {/* Conversation List - Hidden on mobile when viewing chat */} -
+ setShowNewMessageModal(true)} className="h-full" /> -
- - {/* Chat Window - Full width on mobile, split on desktop */} -
- {selectedConversationId ? ( + } + chatSlot={ + selectedConversationId ? ( setShowNewMessageModal(true)} className="h-full" /> - )} -
- - {/* New Message Modal */} - setShowNewMessageModal(false)} - onSelect={handleNewConversation} - currentUserRole={currentUserRole} - /> -
+ ) + } + modalSlot={ + setShowNewMessageModal(false)} + onSelect={handleNewConversation} + currentUserRole={currentUserRole} + /> + } + /> ); } diff --git a/src/app/baseball/(dashboard)/dashboard/operations/page.tsx b/src/app/baseball/(dashboard)/dashboard/operations/page.tsx new file mode 100644 index 000000000..ac588c847 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/operations/page.tsx @@ -0,0 +1,117 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/operations/page.tsx +// +// COHERENCE_RULING_2026-07-08 Ruling 2 — Team hub's new "Operations" landing. +// +// The Team hub caps at 3 rendered subtabs (Roster · Calendar · Operations), +// so Documents, Travel, Practice Planner, and Practice Effectiveness — all +// team-logistics surfaces, not stats — fold in here as a card grid instead of +// each keeping its own subtab slot. Every route the grid links to keeps its +// existing URL and its own registry gating (nav-registry.ts); this page never +// re-declares that gating — it reads the SAME registry entries and reuses +// isBaseballNavEntryVisible so a coach without can_manage_practice, for +// example, simply doesn't see the Practice Effectiveness card, exactly like +// they wouldn't see it in the sidebar. +// +// Server component, no hooks — the Living Annual kit's SectionMasthead / +// PaperCard / Eyebrow are all server-safe. +// ============================================================================= + +import Link from 'next/link'; + +import { requireBaseballCoachRoute } from '@/lib/baseball/server-route-guards'; +import { getBaseballNavContext } from '@/lib/baseball/nav-context'; +import { + getBaseballNavEntry, + isBaseballNavEntryVisible, + type BaseballNavContext, + type BaseballNavEntry, + type BaseballNavId, +} from '@/lib/baseball/nav-registry'; +import { SectionMasthead, PaperCard, Eyebrow } from '@/components/baseball/living-annual'; +import { IconChevronRight } from '@/components/icons'; + +export const metadata = { + title: 'Operations · BaseballHelm', +}; + +const OPERATIONS_CARD_IDS: readonly { + id: BaseballNavId; + description: string; +}[] = [ + { + id: 'documents', + description: 'The team file library — playbooks, forms, and anything the roster needs on hand.', + }, + { + id: 'travel', + description: 'Trip itineraries for every away game or showcase — lodging, transport, and timing.', + }, + { + id: 'practice-planner', + description: 'Build and publish the practice schedule the roster sees on their own Practice tab.', + }, + { + id: 'practice-effectiveness', + description: 'Did practice transfer to performance? The staff-only read on what actually worked.', + }, +]; + +export default async function OperationsPage() { + await requireBaseballCoachRoute(); + + const navContext = (await getBaseballNavContext()) ?? ({ role: 'coach', capabilities: {} } as BaseballNavContext); + + const cards = OPERATIONS_CARD_IDS.map(({ id, description }) => { + const entry = getBaseballNavEntry(id); + return entry && isBaseballNavEntryVisible(entry, navContext) ? { entry, description } : null; + }).filter((card): card is { entry: BaseballNavEntry; description: string } => Boolean(card)); + + return ( +
+ +

+ Everything that keeps the program running between games — files, travel, and practice — + lives here in one place instead of four separate tabs. +

+
+ +
+ {cards.map(({ entry, description }) => { + const Icon = entry.icon; + return ( + + + + + +
+

{entry.label}

+

{description}

+
+ +
+ + ); + })} +
+ + {cards.length === 0 && ( + + Nothing here yet +

+ Your current role doesn't have access to any of the Operations surfaces yet. Ask a + head coach to grant access from Management > Settings. +

+
+ )} +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx index 90558fbc1..9a1901f50 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx @@ -1,26 +1,94 @@ // ============================================================================= // src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx // -// V11 Strength Groups (spec L24 + L151-198 + Packet C). The athlete-segmentation -// surface: build static / dynamic groups, manage membership, and preview a -// dynamic rule's exact included players before saving. SERVER-GATED: +// V11 Strength Groups (spec L24 + L151-198 + Packet C). SERVER-GATED: // * Active baseball context required (never trusts a cookie alone). // * STAFF role required; players are redirected to their Today view. -// * can_manage_lifting required (grouping is a prescribe capability). Nav hiding -// is not relied upon; the page server-redirects without the gate. +// * can_manage_lifting required (grouping is a prescribe capability). Nav +// hiding is not relied upon; the page server-redirects without the gate. // -// RLS backs every read (group + member SELECT is staff-scoped). The capability -// resolve here is defense-in-depth + drives the create / seed affordances. The -// roster attribute snapshot is assembled once and feeds BOTH the athlete table and -// the live rule preview (one engine — no drift between preview and persisted set). +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/groups/StrengthGroupsClient (native HelmLifting* +// props) instead of the legacy src/components/baseball/performance/ +// StrengthGroupsClient. Fetches helm_lifting_groups directly (mirroring +// src/app/lifting/(dashboard)/dashboard/groups/page.tsx), team-scoped. +// +// Note: the legacy StrengthGroupsClient also supported dynamic-rule groups +// with a live preview and a "seed default groups" affordance (getStrengthGroupsBoard +// -> board.defaultGroupsPresent). The canonical component only supports +// static groups (create / rename / archive / add-remove member) — dynamic +// group rules and the seed-defaults CTA are not carried over. See this +// lane's report. +// +// WRITES: createGroup / deleteGroup / addGroupMember / removeGroupMember from +// src/app/lifting/actions/groups.ts (withLiftingAction, requireEdit:true — +// see this lane's report for the confirmed helm_lifting_coaches access-gate +// gap for baseball staff who haven't onboarded through /lifting). // ============================================================================= import { redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; -import { getStrengthGroupsBoard } from '@/lib/baseball/read-models/strength-groups'; -import { StrengthGroupsClient } from '@/components/baseball/performance/StrengthGroupsClient'; +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; +import { StrengthGroupsClient } from '@/components/lifting/groups/StrengthGroupsClient'; +import type { HelmLiftingGroupRow } from '@/lib/types/helm-lifting-data'; +import type { HelmLiftingAthleteRow } from '@/lib/types/helm-lifting'; + +interface GroupWithMembers extends HelmLiftingGroupRow { + member_count: number; + member_athlete_ids: string[]; +} + +async function getGroupsWithMembers(organizationId: string, teamId: string): Promise { + const supabase = await createClient(); + + const { data: groups } = (await fromUntyped(supabase, 'helm_lifting_groups') + .select('*') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .eq('is_active', true) + .order('created_at', { ascending: false }) + .limit(100)) as { data: HelmLiftingGroupRow[] | null }; + + if (!groups || groups.length === 0) return []; + + const { data: members } = (await fromUntyped(supabase, 'helm_lifting_group_members') + .select('group_id, athlete_id') + .in('group_id', groups.map((g) => g.id)) + .is('ends_at', null)) as { data: Array<{ group_id: string; athlete_id: string }> | null }; + + const membersByGroup = new Map(); + for (const m of members ?? []) { + const arr = membersByGroup.get(m.group_id) ?? []; + arr.push(m.athlete_id); + membersByGroup.set(m.group_id, arr); + } + + return groups.map((g) => { + const athleteIds = membersByGroup.get(g.id) ?? []; + return { ...g, member_count: athleteIds.length, member_athlete_ids: athleteIds }; + }); +} + +async function getAthletes( + organizationId: string, + teamId: string, +): Promise>> { + const supabase = await createClient(); + const { data } = (await fromUntyped(supabase, 'helm_lifting_athletes') + .select('id, first_name, last_name, position, sport') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .eq('is_active', true) + .order('last_name', { ascending: true }) + .limit(500)) as { + data: Array> | null; + }; + return data ?? []; +} export default async function StrengthGroupsPage() { const context = await getActiveBaseballContext(); @@ -31,14 +99,21 @@ export default async function StrengthGroupsPage() { const caps = await resolveBaseballCapabilities(teamId); if (!caps.can_manage_lifting) redirect('/baseball/dashboard/performance'); - const board = await getStrengthGroupsBoard(teamId); + const liftCtx = await resolveBaseballLiftingOrg(teamId); + const [groups, athletes] = liftCtx + ? await Promise.all([ + getGroupsWithMembers(liftCtx.organizationId, teamId), + getAthletes(liftCtx.organizationId, teamId), + ]) + : [[], []]; return (
); diff --git a/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx index 8ea0b6ac1..18383f0cd 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx @@ -1,35 +1,216 @@ // ============================================================================= // src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx // -// V11 Live Weight Room mode (spec L27, L522-573 + Packet G). The flagship premium -// staff surface: a strength coach runs a room of 20-60 athletes from ONE screen. -// -// SERVER-GATED (defense in depth, never nav-hiding alone): +// V11 Live Weight Room mode (spec L27, L522-573 + Packet G). SERVER-GATED +// (defense in depth, never nav-hiding alone): // * Resolves the server-validated active baseball context (cookie re-validated). // * STAFF only — players are redirected to their own lift surface. // * Requires can_manage_lifting (this surface WRITES sets/loads/subs for the // athletes; readiness is shown additively when can_view_readiness is held). // -// The whole payload is materialized server-side by getLiveWeightRoomData; the -// client polls a server action to refresh it (realtime-or-polling per spec L572). +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/sessions/LiveWeightRoomClient (native +// HelmLiftingLiveAthleteRow[] props, realtime postgres_changes subscriptions) +// instead of the legacy src/components/baseball/performance/LiveWeightRoom. +// Data assembled directly from helm_lifting_* (mirroring +// src/app/lifting/(dashboard)/dashboard/sessions/live/page.tsx), team-scoped +// via helm_lifting_sessions.team_id. +// +// canViewReadiness is honored server-side by omitting readiness_band / +// readiness check-in reads entirely when the caller lacks the grant — the +// canonical component has no readiness visibility gate of its own, so the +// gate lives in what this page chooses to fetch and pass down. +// +// Not carried over from the legacy surface: the `?group=` deep-linkable +// group filter (the canonical component's group filter is internal client +// state only) and the coach-facing playerNameById substitute-picker label +// map (the canonical row shape already carries first_name/last_name +// natively, so no map is needed). See this lane's report. +// +// WRITES: advanceSessionLifecycle / logSetResult from +// src/app/lifting/actions/sessions.ts (withLiftingAction, requireEdit:true — +// see this lane's report for the confirmed helm_lifting_coaches access-gate +// gap for baseball staff who haven't onboarded through /lifting). // ============================================================================= import { redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; -import { getLiveWeightRoomData } from '@/lib/baseball/read-models/live-weight-room'; -import { LiveWeightRoom } from '@/components/baseball/performance/LiveWeightRoom'; import { createClient } from '@/lib/supabase/server'; import { fromUntyped } from '@/lib/supabase/untyped'; import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; -import { getFullName } from '@/lib/utils'; +import { LiveWeightRoomClient } from '@/components/lifting/sessions/LiveWeightRoomClient'; +import type { + HelmLiftingSessionRow, + HelmLiftingSessionExerciseRow, + HelmLiftingSetResultRow, + HelmLiftingAvailabilityStatus, + HelmLiftingReadinessBand, + HelmLiftingLiveAthleteRow, +} from '@/lib/types/helm-lifting-data'; +import type { HelmLiftingAthleteRow } from '@/lib/types/helm-lifting'; + +async function buildLiveRoomData( + organizationId: string, + teamId: string, + canViewReadiness: boolean, +): Promise<{ + athletes: HelmLiftingLiveAthleteRow[]; + exerciseLibrary: Array<{ id: string; name: string; category: string | null }>; +}> { + const supabase = await createClient(); + const today = new Date().toISOString().slice(0, 10); + + const { data: sessions } = (await fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .eq('scheduled_date', today) + .order('athlete_id', { ascending: true })) as { data: HelmLiftingSessionRow[] | null }; + + if (!sessions || sessions.length === 0) { + const { data: exercises } = (await fromUntyped(supabase, 'helm_lifting_exercises') + .select('id, name, category') + .eq('sport', 'baseball') + .eq('is_active', true) + .or(`organization_id.eq.${organizationId},is_global.eq.true`) + .order('name', { ascending: true }) + .limit(200)) as { data: Array<{ id: string; name: string; category: string | null }> | null }; + return { athletes: [], exerciseLibrary: exercises ?? [] }; + } + + const sessionIds = sessions.map((s) => s.id); + const athleteIds = [...new Set(sessions.map((s) => s.athlete_id))]; + + const [ + { data: athletes }, + { data: sessionExercises }, + { data: setResults }, + checkinsResult, + { data: availabilities }, + { data: groupMembers }, + { data: exercises }, + ] = await Promise.all([ + fromUntyped(supabase, 'helm_lifting_athletes') + .select('id, first_name, last_name, position, sport, user_id') + .in('id', athleteIds) as Promise<{ + data: Array> | null + }>, + fromUntyped(supabase, 'helm_lifting_session_exercises') + .select('*') + .in('session_id', sessionIds) + .order('order_index', { ascending: true }) as Promise<{ data: HelmLiftingSessionExerciseRow[] | null }>, + fromUntyped(supabase, 'helm_lifting_set_results') + .select('session_exercise_id, athlete_id, set_number, actual_load, rpe, created_at') + .in('session_id', sessionIds) + .order('created_at', { ascending: false }) as Promise<{ + data: Array & { created_at: string }> | null + }>, + // Readiness check-ins are only fetched at all when the caller holds + // can_view_readiness — an unauthorized caller never sees the data, + // rather than the component just hiding a value it was handed. + canViewReadiness + ? (fromUntyped(supabase, 'helm_lifting_readiness_checkins') + .select('athlete_id, readiness_score, readiness_band') + .in('athlete_id', athleteIds) + .eq('checkin_date', today) as Promise<{ + data: Array<{ athlete_id: string; readiness_score: number | null; readiness_band: string | null }> | null + }>) + : Promise.resolve({ data: [] as Array<{ athlete_id: string; readiness_score: number | null; readiness_band: string | null }> }), + fromUntyped(supabase, 'helm_lifting_availability_statuses') + .select('athlete_id, status') + .in('athlete_id', athleteIds) + .lte('starts_at', today) + .or(`ends_at.is.null,ends_at.gte.${today}`) as Promise<{ + data: Array<{ athlete_id: string; status: string }> | null + }>, + fromUntyped(supabase, 'helm_lifting_group_members') + .select('athlete_id, helm_lifting_groups(name)') + .in('athlete_id', athleteIds) + .is('ends_at', null) as Promise<{ + data: Array<{ athlete_id: string; helm_lifting_groups: { name: string } | null }> | null + }>, + fromUntyped(supabase, 'helm_lifting_exercises') + .select('id, name, category') + .eq('sport', 'baseball') + .eq('is_active', true) + .or(`organization_id.eq.${organizationId},is_global.eq.true`) + .order('name', { ascending: true }) + .limit(200) as Promise<{ + data: Array<{ id: string; name: string; category: string | null }> | null + }>, + ]); + + const checkins = checkinsResult.data; + + const athleteMap = new Map((athletes ?? []).map((a) => [a.id, a])); + const checkinMap = new Map((checkins ?? []).map((c) => [c.athlete_id, c])); + const availMap = new Map((availabilities ?? []).map((a) => [a.athlete_id, a.status])); -interface PageProps { - searchParams: Promise<{ group?: string }>; + const groupNamesByAthlete = new Map(); + for (const gm of groupMembers ?? []) { + const names = groupNamesByAthlete.get(gm.athlete_id) ?? []; + if (gm.helm_lifting_groups?.name) names.push(gm.helm_lifting_groups.name); + groupNamesByAthlete.set(gm.athlete_id, names); + } + + const exercisesBySession = new Map(); + for (const se of sessionExercises ?? []) { + const arr = exercisesBySession.get(se.session_id) ?? []; + arr.push(se); + exercisesBySession.set(se.session_id, arr); + } + + const latestSetBySeId = new Map(); + for (const sr of setResults ?? []) { + const existing = latestSetBySeId.get(sr.session_exercise_id); + if (!existing || sr.created_at > existing.created_at) { + latestSetBySeId.set(sr.session_exercise_id, { + actual_load: sr.actual_load, + rpe: sr.rpe, + created_at: sr.created_at, + }); + } + } + + const liveAthletes: HelmLiftingLiveAthleteRow[] = sessions.map((session) => { + const athlete = athleteMap.get(session.athlete_id); + const seList = exercisesBySession.get(session.id) ?? []; + const currentSe = seList.find((e) => e.status === 'assigned' || e.status === 'completed'); + const latestSet = currentSe ? latestSetBySeId.get(currentSe.id) : undefined; + const checkin = checkinMap.get(session.athlete_id); + const completedCount = seList.filter((e) => e.status === 'completed').length; + + return { + session_id: session.id, + athlete_id: session.athlete_id, + user_id: athlete?.user_id ?? null, + first_name: athlete?.first_name ?? null, + last_name: athlete?.last_name ?? null, + position: athlete?.position ?? null, + sport: (athlete?.sport ?? 'baseball') as 'baseball' | 'golf', + group_names: groupNamesByAthlete.get(session.athlete_id) ?? [], + session_status: session.status, + readiness_band: (checkin?.readiness_band ?? null) as HelmLiftingReadinessBand | null, + availability_status: (availMap.get(session.athlete_id) ?? null) as HelmLiftingAvailabilityStatus | null, + current_exercise: currentSe?.exercise_name_snapshot ?? null, + prescribed_load: currentSe?.prescribed_load ?? null, + actual_load: latestSet?.actual_load ?? null, + rpe: latestSet?.rpe ?? null, + last_update: latestSet?.created_at ?? session.updated_at, + has_load_change: (latestSet?.actual_load ?? null) !== (currentSe?.prescribed_load ?? null) && latestSet?.actual_load != null, + needs_coach: session.coach_review_status === 'needs_review', + exercises: seList, + total_exercises: seList.length, + completed_exercises: completedCount, + }; + }); + + return { athletes: liveAthletes, exerciseLibrary: exercises ?? [] }; } -export default async function LiveWeightRoomPage({ searchParams }: PageProps) { +export default async function LiveWeightRoomPage() { const context = await getActiveBaseballContext(); if (!context) redirect('/baseball/login'); if (context.activeRole !== 'coach') redirect('/baseball/player/today'); @@ -43,47 +224,17 @@ export default async function LiveWeightRoomPage({ searchParams }: PageProps) { } const canViewReadiness = caps.can_view_readiness; - const { group } = await searchParams; - const groupFilter = group && group.length > 0 ? group : null; - - const data = await getLiveWeightRoomData(teamId, canViewReadiness, groupFilter); - - // A roster→name map so the right-rail queues (player ids) render real names, - // and the exercise library for the substitute picker. - const supabase = await createClient(); - const { data: members } = await supabase - .from('baseball_team_members') - .select('player_id, baseball_players!inner ( id, first_name, last_name )') - .eq('team_id', teamId); - const playerNameById: Record = {}; - for (const m of members ?? []) { - const p = (m as { baseball_players?: { id: string; first_name: string | null; last_name: string | null } }) - .baseball_players; - if (p?.id) playerNameById[p.id] = getFullName(p.first_name, p.last_name); - } - - // Exercise library (org + global) for the substitute action. const liftCtx = await resolveBaseballLiftingOrg(teamId); - let exerciseLibrary: Array<{ id: string; name: string; category: string | null }> = []; - if (liftCtx) { - const { data: exRows } = await fromUntyped(supabase, 'helm_lifting_exercises') - .select('id, name, category') - .eq('sport', 'baseball') - .eq('is_active', true) - .or(`organization_id.eq.${liftCtx.organizationId},is_global.eq.true`) - .order('name', { ascending: true }) as { - data: Array<{ id: string; name: string; category: string | null }> | null; - }; - exerciseLibrary = (exRows ?? []).map((e) => ({ id: e.id, name: e.name, category: e.category ?? null })); - } + const { athletes, exerciseLibrary } = liftCtx + ? await buildLiveRoomData(liftCtx.organizationId, teamId, canViewReadiness) + : { athletes: [], exerciseLibrary: [] }; return ( - ); } diff --git a/src/app/baseball/(dashboard)/dashboard/performance/programs/[programId]/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/programs/[programId]/page.tsx index fede268e3..4dab5b11b 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/programs/[programId]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/programs/[programId]/page.tsx @@ -1,27 +1,169 @@ // ============================================================================= // src/app/baseball/(dashboard)/dashboard/performance/programs/[programId]/page.tsx // -// V11 Program editor (spec L26 + L200-228 + Packet E). The deepest coach authoring -// surface: macrocycle -> week -> day -> section -> prescription, with drag-drop -// reorder, duplicate week/day, save-as-template, and an Assign+Publish flow that -// materializes sessions onto the weight-room board. -// -// SERVER-GATED (defense in depth; RLS backs every write): +// V11 Program editor (spec L26 + L200-228 + Packet E). SERVER-GATED (defense +// in depth; RLS backs every write): // * Active baseball context required. // * STAFF role; players redirected to Today. // * can_manage_lifting required. -// * notFound() when the program id is unknown or RLS hides it. +// * notFound() when the program id is unknown, not this team's, or RLS +// hides it. +// +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/programs/ProgramEditorClient (native HelmLifting* +// props) instead of the legacy src/components/baseball/performance/ +// ProgramEditorClient. Fetches the program tree + assign context directly +// from helm_lifting_* (mirroring src/app/lifting/(dashboard)/dashboard/ +// programs/[programId]/page.tsx), team-scoped for the assign roster/groups. +// +// WRITES: every mutation in ProgramEditorClient (publish, add/duplicate/ +// delete week-day-section-prescription, save-as-template) calls +// src/app/lifting/actions/programs.ts (withLiftingAction, requireEdit:true — +// see this lane's report for the confirmed helm_lifting_coaches access-gate +// gap for baseball staff who haven't onboarded through /lifting). // ============================================================================= import { notFound, redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; -import { - getLiftProgramTree, - getAssignContext, -} from '@/lib/baseball/read-models/lift-programs'; -import { ProgramEditorClient } from '@/components/baseball/performance/ProgramEditorClient'; +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; +import { ProgramEditorClient } from '@/components/lifting/programs/ProgramEditorClient'; +import type { + HelmLiftingProgramRow, + HelmLiftingWeekRow, + HelmLiftingDayRow, + HelmLiftingSectionRow, + HelmLiftingPrescriptionRow, + HelmLiftingGroupRow, +} from '@/lib/types/helm-lifting-data'; +import type { HelmLiftingAthleteRow } from '@/lib/types/helm-lifting'; + +interface LiftProgramTree { + program: HelmLiftingProgramRow; + weeks: Array< + HelmLiftingWeekRow & { + days: Array< + HelmLiftingDayRow & { + sections: Array; + } + >; + } + >; + exerciseNameMap: Record; +} + +interface AssignContext { + athletes: Array>; + groups: Array>; +} + +async function getProgramTree( + programId: string, + organizationId: string, + teamId: string, +): Promise { + const supabase = await createClient(); + + const { data: program } = (await fromUntyped(supabase, 'helm_lifting_programs') + .select('*') + .eq('id', programId) + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .maybeSingle()) as { data: HelmLiftingProgramRow | null }; + + if (!program) return null; + + const { data: weeks } = (await fromUntyped(supabase, 'helm_lifting_weeks') + .select('*') + .eq('program_id', programId) + .order('week_number', { ascending: true })) as { data: HelmLiftingWeekRow[] | null }; + + const weekList = weeks ?? []; + if (weekList.length === 0) { + return { program, weeks: [], exerciseNameMap: {} }; + } + + const { data: days } = (await fromUntyped(supabase, 'helm_lifting_days') + .select('*') + .in('week_id', weekList.map((w) => w.id)) + .order('day_number', { ascending: true })) as { data: HelmLiftingDayRow[] | null }; + + const dayList = days ?? []; + const { data: sections } = (await fromUntyped(supabase, 'helm_lifting_sections') + .select('*') + .in('lift_day_id', dayList.map((d) => d.id)) + .order('section_order', { ascending: true })) as { data: HelmLiftingSectionRow[] | null }; + + const sectionList = sections ?? []; + const { data: prescriptions } = (await fromUntyped(supabase, 'helm_lifting_prescriptions') + .select('*') + .in('section_id', sectionList.map((s) => s.id)) + .order('order_index', { ascending: true })) as { data: HelmLiftingPrescriptionRow[] | null }; + + const prescList = prescriptions ?? []; + + const exerciseIds = [...new Set(prescList.map((p) => p.exercise_id).filter(Boolean) as string[])]; + const exerciseNameMap: Record = {}; + if (exerciseIds.length > 0) { + const { data: exRows } = (await fromUntyped(supabase, 'helm_lifting_exercises') + .select('id, name') + .in('id', exerciseIds)) as { data: Array<{ id: string; name: string }> | null }; + for (const ex of exRows ?? []) exerciseNameMap[ex.id] = ex.name; + } + + const prescsBySectionId = new Map(); + for (const p of prescList) { + const arr = prescsBySectionId.get(p.section_id) ?? []; + arr.push(p); + prescsBySectionId.set(p.section_id, arr); + } + + const sectionsByDayId = new Map>(); + for (const s of sectionList) { + const arr = sectionsByDayId.get(s.lift_day_id) ?? []; + arr.push({ ...s, prescriptions: prescsBySectionId.get(s.id) ?? [] }); + sectionsByDayId.set(s.lift_day_id, arr); + } + + const daysByWeekId = new Map }>>(); + for (const d of dayList) { + const arr = daysByWeekId.get(d.week_id) ?? []; + arr.push({ ...d, sections: sectionsByDayId.get(d.id) ?? [] }); + daysByWeekId.set(d.week_id, arr); + } + + return { + program, + weeks: weekList.map((w) => ({ ...w, days: daysByWeekId.get(w.id) ?? [] })), + exerciseNameMap, + }; +} + +async function getAssignContext(organizationId: string, teamId: string): Promise { + const supabase = await createClient(); + + const [{ data: athletes }, { data: groups }] = await Promise.all([ + fromUntyped(supabase, 'helm_lifting_athletes') + .select('id, first_name, last_name, position, sport') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .eq('is_active', true) + .order('last_name', { ascending: true }) + .limit(500) as Promise<{ data: Array> | null }>, + fromUntyped(supabase, 'helm_lifting_groups') + .select('id, name, group_type') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .eq('is_active', true) + .order('name', { ascending: true }) + .limit(100) as Promise<{ data: Array> | null }>, + ]); + + return { athletes: athletes ?? [], groups: groups ?? [] }; +} export default async function ProgramEditorPage({ params, @@ -38,14 +180,24 @@ export default async function ProgramEditorPage({ const caps = await resolveBaseballCapabilities(teamId); if (!caps.can_manage_lifting) redirect('/baseball/dashboard/performance'); - const tree = await getLiftProgramTree(teamId, programId); - if (!tree) notFound(); + const liftCtx = await resolveBaseballLiftingOrg(teamId); + if (!liftCtx) notFound(); + + const [tree, assign] = await Promise.all([ + getProgramTree(programId, liftCtx.organizationId, teamId), + getAssignContext(liftCtx.organizationId, teamId), + ]); - const assign = await getAssignContext(teamId); + if (!tree) notFound(); return (
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx index feb165543..96a5a95a1 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx @@ -1,24 +1,84 @@ // ============================================================================= // src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx // -// V11 Program list (spec L25 + Packet E). The entry point to the deepest coach -// authoring layer: list every training program (phase / goal / status / template) -// with week+day counts, and create a new one. SERVER-GATED: +// V11 Program list (spec L25 + Packet E). SERVER-GATED: // * Active baseball context required (never trusts a cookie alone). // * STAFF role required; players are redirected to their Today view. // * can_manage_lifting required (programming is a prescribe capability). Nav // hiding is not relied upon; the page server-redirects without the gate. // -// RLS backs every read (program SELECT is staff-scoped). The capability resolve -// here is defense-in-depth + drives the create affordance. +// LANE C — ONE LIFT LAB: repointed at the canonical +// src/components/lifting/programs/ProgramListClient (native HelmLifting* +// props) instead of the legacy src/components/baseball/performance/ +// ProgramListClient. Fetches helm_lifting_programs directly (mirroring +// src/app/lifting/(dashboard)/dashboard/programs/page.tsx) — team-scoped +// (this route is per baseball team) rather than org-wide. +// +// WRITES: ProgramListClient's "New program" flow calls createProgram from +// src/app/lifting/actions/programs.ts (withLiftingAction, requireEdit:true). +// That wrapper gates on resolveLiftingAccess(orgId), which requires an active +// helm_lifting_coaches (or org_viewer) row for THIS org — baseball staff who +// have never onboarded through /lifting have neither. See this lane's report +// for the confirmed access-gate gap (out of this lane's file scope to fix). // ============================================================================= import { redirect } from 'next/navigation'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; -import { getLiftProgramList } from '@/lib/baseball/read-models/lift-programs'; -import { ProgramListClient } from '@/components/baseball/performance/ProgramListClient'; +import { createClient } from '@/lib/supabase/server'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; +import { ProgramListClient } from '@/components/lifting/programs/ProgramListClient'; +import type { HelmLiftingProgramRow } from '@/lib/types/helm-lifting-data'; + +interface ProgramWithCounts extends HelmLiftingProgramRow { + week_count: number; + day_count: number; +} + +async function getPrograms(organizationId: string, teamId: string): Promise { + const supabase = await createClient(); + + const { data: programs } = (await fromUntyped(supabase, 'helm_lifting_programs') + .select('*') + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .order('created_at', { ascending: false }) + .limit(200)) as { data: HelmLiftingProgramRow[] | null }; + + if (!programs || programs.length === 0) return []; + + const ids = programs.map((p) => p.id); + + const { data: weeks } = (await fromUntyped(supabase, 'helm_lifting_weeks') + .select('id, program_id') + .in('program_id', ids)) as { data: Array<{ id: string; program_id: string }> | null }; + + const weekCountByProgram = new Map(); + const programByWeek = new Map(); + for (const w of weeks ?? []) { + weekCountByProgram.set(w.program_id, (weekCountByProgram.get(w.program_id) ?? 0) + 1); + programByWeek.set(w.id, w.program_id); + } + + const dayCountByProgram = new Map(); + if ((weeks ?? []).length > 0) { + const { data: days } = (await fromUntyped(supabase, 'helm_lifting_days') + .select('week_id') + .in('week_id', (weeks ?? []).map((w) => w.id))) as { data: Array<{ week_id: string }> | null }; + for (const d of days ?? []) { + const progId = programByWeek.get(d.week_id); + if (progId) dayCountByProgram.set(progId, (dayCountByProgram.get(progId) ?? 0) + 1); + } + } + + return programs.map((p) => ({ + ...p, + week_count: weekCountByProgram.get(p.id) ?? 0, + day_count: dayCountByProgram.get(p.id) ?? 0, + })); +} export default async function ProgramsPage() { const context = await getActiveBaseballContext(); @@ -29,11 +89,16 @@ export default async function ProgramsPage() { const caps = await resolveBaseballCapabilities(teamId); if (!caps.can_manage_lifting) redirect('/baseball/dashboard/performance'); - const programs = await getLiftProgramList(teamId); + const liftCtx = await resolveBaseballLiftingOrg(teamId); + const programs = liftCtx ? await getPrograms(liftCtx.organizationId, teamId) : []; return (
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/players/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/players/[id]/page.tsx index 10a595f3c..1e8d52eff 100644 --- a/src/app/baseball/(dashboard)/dashboard/players/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/players/[id]/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server'; import { redirect, notFound } from 'next/navigation'; import { PlayerProfileClient } from '@/components/baseball/player-profile/PlayerProfileClient'; +import { BreadcrumbLabel } from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; import type { BaseballPlayerStats, BaseballPlayerAggregates, BaseballCoachInsight } from '@/lib/types'; import { getPlayerSnapshotCards } from '@/lib/baseball/read-models/player-snapshot-cards'; import { getPlayerTimeline, getTimelineAcksForSubjectPlayer } from '@/lib/baseball/read-models/timeline'; @@ -206,31 +207,37 @@ export default async function PlayerProfilePage({ params }: PageProps) { })); return ( - + <> + {/* Ruling 4: the shell's breadcrumb has no registry entry for a + dynamic player id — this supplies the real name so the trail never + falls back to a raw UUID segment. */} + + + ); } diff --git a/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/error.tsx b/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/error.tsx deleted file mode 100644 index 374cb1f3e..000000000 --- a/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { RouteErrorBoundary } from '@/components/errors'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - - ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/page.tsx b/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/page.tsx deleted file mode 100644 index 4d2f22dae..000000000 --- a/src/app/baseball/(dashboard)/dashboard/players/[id]/profile/page.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { createClient } from '@/lib/supabase/server'; -import { notFound } from 'next/navigation'; -import { PlayerCard } from '@/components/player/profile/PlayerCard'; -import { Button } from '@/components/ui/button'; -import { IconArrowLeft, IconChartBar } from '@/components/icons'; -import Link from 'next/link'; -import { resolveBaseballAthleteIds, resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; -import { PlayerPerformanceTab } from '@/components/lifting/performance/PlayerPerformanceTab'; -import { checkWatchlistStatus } from '@/app/baseball/actions/watchlist'; -import { PlayerProfileCoachActions } from '@/components/player/profile/PlayerProfileCoachActions'; - -interface PageProps { - params: Promise<{ id: string }>; -} - -export default async function PlayerProfilePage({ params }: PageProps) { - const { id } = await params; - const supabase = await createClient(); - - // Parallel fetch: player + current user - // Cast to any to avoid TS2589 from deep relational-select type instantiation - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sb = supabase as any; - const [{ data: player }, authResult] = await Promise.all([ - sb.from('baseball_players').select('*, player_settings (*)').eq('id', id).single() as Promise<{ data: Record | null; error: unknown }>, - supabase.auth.getUser(), - ]); - const user = authResult.data.user; - - if (!player) notFound(); - - const isPublicView = !user || user.id !== player.user_id; - - // Resolve whether the viewer is an actual coach — NOT merely "not the - // profile owner". isPublicView is true for any authenticated non-owner - // (including another player), so the coach-only CTAs (Send Message / Add - // to Watchlist) must gate on this real role check instead, or any player - // viewing a teammate's/recruit's profile would see live coach actions. - // Mirrors the isCoachViewing pattern in the sibling public route at - // src/app/baseball/(public)/player/[id]/page.tsx. - let coach: { id: string; organization_id: string | null } | null = null; - if (user) { - const { data } = await supabase - .from('baseball_coaches') - .select('id, organization_id') - .eq('user_id', user.id) - .single(); - coach = data; - } - const isCoachViewing = Boolean(coach); - - // Best-effort watchlist status for the coach CTAs in the sidebar (viewer must - // be a coach viewing someone else's profile — checkWatchlistStatus itself - // no-ops to false for non-coach users). - const isInWatchlist = isCoachViewing - ? await checkWatchlistStatus(id).then((r) => r.isInWatchlist).catch(() => false) - : false; - - // Fetch coach's team to pull season stats (best-effort — null if not a coach) - let seasonStats: { - g: number; ab: number; avg: number | null; obp: number | null; slg: number | null; ops: number | null; - hr: number; rbi: number; sb: number; k: number; - ip: number; era: number | null; whip: number | null; k9: number | null; - w: number; l: number; k_thrown: number; - } | null = null; - let teamId: string | null = null; - - if (coach?.organization_id) { - const { data: team } = await supabase - .from('baseball_teams') - .select('id') - .eq('organization_id', coach.organization_id) - .single(); - - if (team) { - teamId = team.id; - const year = new Date().getFullYear(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: ss } = await (supabase as any) - .from('baseball_player_season_stats') - .select('g,ab,avg,obp,slg,ops,hr,rbi,sb,k,ip,era,whip,k9,w,l,k_thrown') - .eq('player_id', id) - .eq('team_id', team.id) - .eq('season_year', year) - .maybeSingle(); - - if (ss) seasonStats = ss; - } - } - - const hasBatting = seasonStats && seasonStats.ab > 0; - const hasPitching = seasonStats && seasonStats.ip > 0; - const hasStats = hasBatting || hasPitching; - - // Real engagement data — video count + last active from player.updated_at. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { count: videoCount } = await (supabase as any) - .from('baseball_videos') - .select('*', { count: 'exact', head: true }) - .eq('player_id', id) as { count: number | null }; - - // Relative "last active" from the player row's updated_at. - function formatRelativeTime(iso: string | null | undefined): string { - if (!iso) return '—'; - const diffMs = Date.now() - new Date(iso).getTime(); - const diffMin = Math.floor(diffMs / 60_000); - if (diffMin < 60) return diffMin <= 1 ? 'Just now' : `${diffMin}m ago`; - const diffH = Math.floor(diffMin / 60); - if (diffH < 24) return `${diffH}h ago`; - const diffD = Math.floor(diffH / 24); - if (diffD < 7) return `${diffD}d ago`; - if (diffD < 30) return `${Math.floor(diffD / 7)}w ago`; - return new Date(iso).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); - } - const lastActive = formatRelativeTime(player.updated_at as string | null | undefined); - - // Resolve Helm Lifting athlete context for the Performance tab. - // If the coach's team has a lifting org and the player has an athlete row, - // we pass orgId + athleteId to PlayerPerformanceTab. Otherwise we show an - // honest "no performance data yet" empty state. - let liftingOrgId: string | null = null; - let liftingAthleteId: string | null = null; - - if (teamId) { - const liftingCtx = await resolveBaseballLiftingOrg(teamId).catch(() => null); - if (liftingCtx) { - liftingOrgId = liftingCtx.organizationId; - const athleteMap = await resolveBaseballAthleteIds( - liftingCtx.organizationId, - [id], - ).catch(() => null); - liftingAthleteId = athleteMap ? (athleteMap[id] ?? null) : null; - } - } - - return ( -
-
- {/* Back Button */} - - - - -
- {/* Player Card - Takes 2 columns */} -
- [0]['player']} isPublic={isPublicView} /> -
- - {/* Sidebar */} -
- {isCoachViewing && ( - - )} - - {/* Season Stats Card */} - {hasStats ? ( -
-
-

- {new Date().getFullYear()} Stats -

- {teamId && ( - - - Full stats → - - )} -
- - {hasBatting && seasonStats && ( -
-

Batting

-
- {[ - { label: 'AVG', value: seasonStats.avg != null ? seasonStats.avg.toFixed(3).replace(/^0/, '') : '—' }, - { label: 'OBP', value: seasonStats.obp != null ? seasonStats.obp.toFixed(3).replace(/^0/, '') : '—' }, - { label: 'SLG', value: seasonStats.slg != null ? seasonStats.slg.toFixed(3).replace(/^0/, '') : '—' }, - { label: 'OPS', value: seasonStats.ops != null ? seasonStats.ops.toFixed(3) : '—' }, - { label: 'HR', value: String(seasonStats.hr) }, - { label: 'RBI', value: String(seasonStats.rbi) }, - { label: 'SB', value: String(seasonStats.sb) }, - { label: 'G', value: String(seasonStats.g) }, - ].map(({ label, value }) => ( -
- {label} - {value} -
- ))} -
-
- )} - - {hasBatting && hasPitching && ( -
- )} - - {hasPitching && seasonStats && ( -
-

Pitching

-
- {[ - { label: 'ERA', value: seasonStats.era != null ? seasonStats.era.toFixed(2) : '—' }, - { label: 'WHIP', value: seasonStats.whip != null ? seasonStats.whip.toFixed(3) : '—' }, - { label: 'K/9', value: seasonStats.k9 != null ? seasonStats.k9.toFixed(1) : '—' }, - { label: 'IP', value: seasonStats.ip.toFixed(1) }, - { label: 'W-L', value: `${seasonStats.w}-${seasonStats.l}` }, - { label: 'K', value: String(seasonStats.k_thrown) }, - ].map(({ label, value }) => ( -
- {label} - {value} -
- ))} -
-
- )} -
- ) : ( - /* Fallback: real profile engagement data when no box score data */ -
-

- Quick Stats -

-
-
- Videos - {videoCount ?? 0} -
-
- Last Active - {lastActive} -
- {teamId && ( - - View game stats → - - )} -
-
- )} -
-
-
- - {/* Performance Tab — Helm Lifting Lab data for this player. - Rendered below the player card + stats when a lifting org + athlete - row exists. Shows an honest empty state when the player has no - lifting data yet (no athlete row seeded). */} -
-

- Performance -

- {liftingOrgId && liftingAthleteId ? ( - - ) : ( -
-

- No performance data yet for this player. -

- {!liftingOrgId && ( -

- Set up Helm Lifting Lab for this team to unlock performance tracking. -

- )} - {liftingOrgId && !liftingAthleteId && ( -

- This player's lifting athlete record has not been created yet. -

- )} -
- )} -
-
- ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/players/[id]/stats/page.tsx b/src/app/baseball/(dashboard)/dashboard/players/[id]/stats/page.tsx index 39edd1fbe..ae7d7896c 100644 --- a/src/app/baseball/(dashboard)/dashboard/players/[id]/stats/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/players/[id]/stats/page.tsx @@ -65,7 +65,7 @@ export default async function PlayerStatsPage({ params }: PageProps) {
- + {player.first_name} {player.last_name} @@ -81,7 +81,7 @@ export default async function PlayerStatsPage({ params }: PageProps) {

View Profile → diff --git a/src/app/baseball/(dashboard)/dashboard/practice/page.tsx b/src/app/baseball/(dashboard)/dashboard/practice/page.tsx index f0f931e8a..54e153862 100644 --- a/src/app/baseball/(dashboard)/dashboard/practice/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/practice/page.tsx @@ -1,3 +1,6 @@ +import { redirect } from 'next/navigation'; + +import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { PracticePlannerClient } from '@/components/baseball/practice-planner/PracticePlannerClient'; import { fairwayScope } from '@/lib/redesign/flag'; @@ -17,8 +20,20 @@ export const metadata = { * `isRedesignEnabled` fork (the shell flag already gates the whole subtree, * per the command-center precedent), always the kit version, wrapped in the * `.fairway-ds` scope exactly like `command-center/page.tsx`. + * + * COHERENCE_RULING_2026-07-08 Ruling 2 (item 7): this route is the COACH + * planning surface; the nav already sends players to their canonical + * `/baseball/player/practice` view (nav-registry.ts's `practice-planner` + * playerHref). A player landing here directly (bookmark, typed URL) is + * server-redirected to that canonical surface instead — staff/coach + * rendering below is unchanged. */ -export default function BaseballPracticePage() { +export default async function BaseballPracticePage() { + const context = await getActiveBaseballContext(); + if (context?.activeRole === 'player') { + redirect('/baseball/player/practice'); + } + return (
diff --git a/src/app/baseball/(dashboard)/dashboard/readiness/__tests__/page.test.tsx b/src/app/baseball/(dashboard)/dashboard/readiness/__tests__/page.test.tsx new file mode 100644 index 000000000..d8ccaf71a --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/readiness/__tests__/page.test.tsx @@ -0,0 +1,99 @@ +// ============================================================================= +// PlayerReadinessPage — team-local "today" regression (Coherence Ruling 4). +// +// Before the fix, `today` was `new Date().toISOString().slice(0, 10)` — the +// SERVER's UTC day — while the readiness GATE the player later sees on +// /player/today (player-today.ts:534, `day = forDate ?? todayIsoInTz(teamTz)`) +// compares against the TEAM-LOCAL day. A West Coast player checking in at +// 9pm PT wrote/prefilled against the UTC date (already "tomorrow"), which +// silently mismatched the gate's team-local date — the check-in looked like +// it belonged to the wrong day, or a same-day revisit failed to prefill. +// +// This test pins a real evening-US instant (9pm PT, which is already past +// midnight UTC — the exact case contract-day.test.ts validates for +// `todayIsoInTz`) and asserts the page now resolves the SAME team-local date +// the readiness gate uses, by calling the real (unmocked) contract-day +// helpers — only the Supabase row backing `resolveTeamTimezone` is faked. +// ============================================================================= + +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('@/components/baseball/performance/PlayerReadinessClient', () => ({ + PlayerReadinessClient: () => null, +})); + +vi.mock('@/lib/baseball/active-context', () => ({ + getActiveBaseballContext: vi.fn(async () => ({ + activeTeamId: 'team-1', + activeRole: 'player', + activePlayerId: 'player-1', + })), +})); + +// Lift Lab context resolves to null so the checkin-prefill branch (which +// needs a much larger Supabase mock) is skipped — this test is scoped to the +// `today` date derivation, not the prefill query. +vi.mock('@/lib/lifting/resolve-baseball-context', () => ({ + resolveBaseballLiftingOrg: vi.fn(async () => null), + resolveMyBaseballAthleteId: vi.fn(async () => null), +})); + +// The ONLY Supabase call this page makes when liftCtx is null is the +// `baseball_teams.timezone` lookup inside resolveTeamTimezone (real, +// unmocked implementation below) — fake just that row. +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({ + from: (table: string) => { + if (table !== 'baseball_teams') { + throw new Error(`unexpected table in test double: ${table}`); + } + return { + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ data: { timezone: 'America/Los_Angeles' } }), + }), + }), + }; + }, + })), +})); + +// Deliberately NOT mocking '@/lib/baseball/daily-contract/contract-day' — +// this test exercises the REAL todayIsoInTz/resolveTeamTimezone so it fails +// if either the page or the read-model ever regress to a server-UTC slice. +import { todayIsoInTz } from '@/lib/baseball/daily-contract/contract-day'; + +import PlayerReadinessPage from '../page'; +import { PlayerReadinessClient } from '@/components/baseball/performance/PlayerReadinessClient'; + +describe('PlayerReadinessPage — team-local today (evening US timestamp)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves the team-local date, not the server UTC date, for a 9pm PT check-in', async () => { + // 2026-06-24T04:00:00Z is 2026-06-23 21:00 PDT (UTC-7) — 9pm Pacific, + // already the NEXT day in UTC. The pre-fix `new Date().toISOString() + // .slice(0,10)` would resolve '2026-06-24'; team-local must be '2026-06-23'. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-24T04:00:00Z')); + + const element = await PlayerReadinessPage(); + + // Same helper + same tz + same instant as the readiness gate in + // player-today.ts:534 (`todayIsoInTz(teamTz)`) — equality here is the + // gate/page agreement the ruling requires, not a coincidence of mocking. + const expectedTeamLocalToday = todayIsoInTz('America/Los_Angeles', new Date()); + + expect(expectedTeamLocalToday).toBe('2026-06-23'); + + // The page wraps PlayerReadinessClient in a layout
— unwrap one level. + const inner = (element.props as { children: { type: unknown; props: unknown } }) + .children; + expect(inner.type).toBe(PlayerReadinessClient); + expect((inner.props as { checkDate: string }).checkDate).toBe( + expectedTeamLocalToday, + ); + expect((inner.props as { checkDate: string }).checkDate).not.toBe('2026-06-24'); + }); +}); diff --git a/src/app/baseball/(dashboard)/dashboard/readiness/page.tsx b/src/app/baseball/(dashboard)/dashboard/readiness/page.tsx index da99d3513..32e19b043 100644 --- a/src/app/baseball/(dashboard)/dashboard/readiness/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/readiness/page.tsx @@ -13,6 +13,10 @@ import { redirect } from 'next/navigation'; import { createClient } from '@/lib/supabase/server'; import { fromUntyped } from '@/lib/supabase/untyped'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; +import { + resolveTeamTimezone, + todayIsoInTz, +} from '@/lib/baseball/daily-contract/contract-day'; import { resolveBaseballLiftingOrg, resolveMyBaseballAthleteId, @@ -36,7 +40,13 @@ export default async function PlayerReadinessPage() { redirect('/baseball/dashboard/performance'); } - const today = new Date().toISOString().slice(0, 10); + // Team-local "today" — the readiness gate (player-today.ts) compares + // team-local todayIsoInTz; deriving server-UTC here mis-dated evening US + // submissions (a 9pm PT check-in would prefill/dedupe against the wrong day). + const supabase = await createClient(); + const today = todayIsoInTz( + await resolveTeamTimezone(supabase, context.activeTeamId), + ); // W2-G: submitReadinessCheckin (lifting.ts) writes ONLY to // helm_lifting_readiness_checkins now — dedupe/prefill must read the same @@ -58,7 +68,6 @@ export default async function PlayerReadinessPage() { if (liftCtx) { const athleteId = await resolveMyBaseballAthleteId(liftCtx.organizationId); if (athleteId) { - const supabase = await createClient(); const { data: row } = (await fromUntyped(supabase, 'helm_lifting_readiness_checkins') .select('*') .eq('athlete_id', athleteId) diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx index ae7c79b3e..edc34d55e 100644 --- a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx @@ -20,7 +20,7 @@ import { RosterFairway } from './RosterFairway'; type MemberStatus = 'pending' | 'active' | 'inactive' | 'removed' | 'injured' | 'alumni'; export type RosterSurface = 'cards' | 'position' | 'status' | 'development'; -export type SortField = 'name' | 'position' | 'avg' | 'obp' | 'slg' | 'ops' | 'exit_velo' | 'sessions'; +export type SortField = 'name' | 'position' | 'avg' | 'obp' | 'slg' | 'ops' | 'sessions'; export type SortDirection = 'asc' | 'desc'; export interface RosterClientProps { @@ -65,7 +65,6 @@ function exportRosterCSV( career_obp: number | null; career_slg: number | null; career_ops: number | null; - avg_exit_velocity: number | null; total_sessions: number | null; status: string | null; }>, @@ -80,7 +79,6 @@ function exportRosterCSV( 'OBP', 'SLG', 'OPS', - 'Exit Velo', 'Sessions', 'Status', ]; @@ -94,7 +92,6 @@ function exportRosterCSV( p.career_obp !== null ? p.career_obp.toFixed(3) : '', p.career_slg !== null ? p.career_slg.toFixed(3) : '', p.career_ops !== null ? p.career_ops.toFixed(3) : '', - p.avg_exit_velocity !== null ? p.avg_exit_velocity.toFixed(1) : '', p.total_sessions?.toString() || '0', p.status || '', ]); @@ -338,12 +335,6 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien comparison = opsB - opsA; // Higher is better break; } - case 'exit_velo': { - const veloA = aggA?.avg_exit_velocity ?? -1; - const veloB = aggB?.avg_exit_velocity ?? -1; - comparison = veloB - veloA; // Higher is better - break; - } case 'sessions': { const sessA = aggA?.total_sessions ?? 0; const sessB = aggB?.total_sessions ?? 0; @@ -408,7 +399,6 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien career_obp: aggregates[member.player.id]?.career_obp ?? null, career_slg: aggregates[member.player.id]?.career_slg ?? null, career_ops: aggregates[member.player.id]?.career_ops ?? null, - avg_exit_velocity: aggregates[member.player.id]?.avg_exit_velocity ?? null, total_sessions: aggregates[member.player.id]?.total_sessions ?? null, status: member.status, })); diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx index d10f7fa69..d48ea828a 100644 --- a/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterFairway.tsx @@ -79,7 +79,6 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [ { value: 'obp', label: 'OBP' }, { value: 'slg', label: 'SLG' }, { value: 'ops', label: 'OPS' }, - { value: 'exit_velo', label: 'Exit velo' }, { value: 'sessions', label: 'Sessions' }, ]; @@ -92,7 +91,10 @@ const ALL = 'all'; // ── Record-book roster wall (the `cards` surface) ─────────────────────────── -const WALL_COLUMNS = ['AVG', 'OBP', 'SLG', 'OPS', 'EXIT V', 'SESS']; +// EXIT V intentionally omitted — the aggregates table carries no exit-velocity +// column in the live schema and nothing writes one (Ruling 4). Honest UI over +// a dead column: don't render a stat that can never populate. +const WALL_COLUMNS = ['AVG', 'OBP', 'SLG', 'OPS', 'SESS']; function buildWallStats(agg: BaseballPlayerAggregates | undefined, leader: boolean): PlayerRowStat[] { if (!agg) { @@ -101,7 +103,6 @@ function buildWallStats(agg: BaseballPlayerAggregates | undefined, leader: boole { value: EM_DASH }, { value: EM_DASH }, { value: EM_DASH }, - { value: EM_DASH }, { value: 0 }, ]; } @@ -110,7 +111,6 @@ function buildWallStats(agg: BaseballPlayerAggregates | undefined, leader: boole { value: agg.career_obp == null ? EM_DASH : formatRate(agg.career_obp, 3) }, { value: agg.career_slg == null ? EM_DASH : formatRate(agg.career_slg, 3) }, { value: agg.career_ops == null ? EM_DASH : formatRate(agg.career_ops, 3), leader }, - { value: agg.avg_exit_velocity ?? EM_DASH, decimals: 1 }, { value: agg.total_sessions }, ]; } diff --git a/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx b/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx new file mode 100644 index 000000000..128f544dd --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx @@ -0,0 +1,122 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/scouting/page.tsx +// +// COHERENCE_RULING_2026-07-08 Ruling 2 — Recruiting hub's new "Scouting" +// landing. +// +// The Recruiting hub caps at 3 rendered subtabs (Pipeline · Discover · +// Scouting), so Watchlist, Compare Players, Saved Comparisons, Scout +// Packets, and Camps — the evaluation/export tools, not the funnel itself — +// fold in here as a card grid. Every route keeps its existing URL and its own +// registry gating (nav-registry.ts); this page never re-declares that +// gating — it reads the SAME registry entries and reuses +// isBaseballNavEntryVisible so a coach without can_manage_roster, for +// example, simply doesn't see the Compare/Saved Comparisons cards. +// +// Server component, no hooks — the Living Annual kit's SectionMasthead / +// PaperCard / Eyebrow are all server-safe. Ink is `pursuit` (clay) — this is +// THE WAR ROOM lane (recruiting), never green. +// ============================================================================= + +import Link from 'next/link'; + +import { requireRecruitingCoachRoute } from '@/lib/baseball/server-route-guards'; +import { getBaseballNavContext } from '@/lib/baseball/nav-context'; +import { + getBaseballNavEntry, + isBaseballNavEntryVisible, + type BaseballNavContext, + type BaseballNavEntry, + type BaseballNavId, +} from '@/lib/baseball/nav-registry'; +import { SectionMasthead, PaperCard, Eyebrow } from '@/components/baseball/living-annual'; +import { IconChevronRight } from '@/components/icons'; + +export const metadata = { + title: 'Scouting · BaseballHelm', +}; + +const SCOUTING_CARD_IDS: readonly { + id: BaseballNavId; + description: string; +}[] = [ + { + id: 'watchlist', + description: 'The saved shortlist — every prospect you’re actively tracking, in one place.', + }, + { + id: 'compare', + description: 'Side-by-side player comparison across the metrics that matter to your program.', + }, + { + id: 'comparisons', + description: 'Saved comparison sets you can return to without rebuilding the matchup.', + }, + { + id: 'scout-packets', + description: 'Export a showcase event roster into a shareable scout packet.', + }, + { + id: 'camps', + description: 'Create and manage your program’s camp listings.', + }, +]; + +export default async function ScoutingPage() { + await requireRecruitingCoachRoute(); + + const navContext = (await getBaseballNavContext()) ?? ({ role: 'coach', capabilities: {} } as BaseballNavContext); + + const cards = SCOUTING_CARD_IDS.map(({ id, description }) => { + const entry = getBaseballNavEntry(id); + return entry && isBaseballNavEntryVisible(entry, navContext) ? { entry, description } : null; + }).filter((card): card is { entry: BaseballNavEntry; description: string } => Boolean(card)); + + return ( +
+ +

+ The evaluation toolkit — shortlists, side-by-side comparisons, and exportable packets — + once a prospect is worth a closer look. +

+
+ +
+ {cards.map(({ entry, description }) => { + const Icon = entry.icon; + return ( + + + + + +
+

{entry.label}

+

{description}

+
+ +
+ + ); + })} +
+ + {cards.length === 0 && ( + + Nothing here yet +

+ Your current role doesn't have access to any of the Scouting surfaces yet. Ask a + head coach to grant access from Management > Settings. +

+
+ )} +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/settings/page.tsx b/src/app/baseball/(dashboard)/dashboard/settings/page.tsx index ad9f478c3..19c88ed33 100644 --- a/src/app/baseball/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/settings/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { Header } from '@/components/layout/header'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -220,7 +219,13 @@ export default function SettingsPage() { return ( <> -
+ {/* Ruling 2 (item 5): the shell's own top bar already renders + breadcrumbs + the mobile menu affordance — this page no longer + mounts a second (legacy)
, which duplicated both. */} +
+

Settings

+

Manage your account settings

+
{(user?.role === 'coach' ? COACH_SETTINGS_LINKS : PLAYER_SETTINGS_LINKS).map((item) => { diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/[gameId]/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/[gameId]/page.tsx index 8cbd3eca4..3318e9eb0 100644 --- a/src/app/baseball/(dashboard)/dashboard/stats/games/[gameId]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/stats/games/[gameId]/page.tsx @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'; import { redirect, notFound } from 'next/navigation'; import Link from 'next/link'; import { getGameBoxScore } from '@/app/baseball/actions/games'; +import { BreadcrumbLabel } from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; import { BoxScoreView } from '@/components/baseball/box-score/BoxScoreView'; import { BoxScoreUpload } from '@/components/baseball/box-score/BoxScoreUpload'; import { mapBattingToInput, mapPitchingToInput } from '@/components/baseball/box-score/mappers'; @@ -79,17 +80,21 @@ export default async function GameDetailPage({ params }: PageProps) { const initialBatting = batting.map(mapBattingToInput); const initialPitching = pitching.map(mapPitchingToInput); + const opponentLabel = `${game.game_type === 'scrimmage' ? 'Scrimmage' : 'Game'} vs ${game.opponent_name ?? 'TBD'}`; + return (
+ {/* Ruling 4: the shell's breadcrumb has no registry entry for a dynamic + game id — this supplies the real matchup name so the trail never + falls back to a raw UUID segment. */} + {/* Breadcrumb */}
Games - - {game.game_type === 'scrimmage' ? 'Scrimmage' : 'Game'} vs {game.opponent_name ?? 'TBD'} - + {opponentLabel}
{/* Completed game with stats: show box score, then offer to edit */} diff --git a/src/app/baseball/(dashboard)/dashboard/travel/page.tsx b/src/app/baseball/(dashboard)/dashboard/travel/page.tsx index b00d9f591..f59b2a8ff 100644 --- a/src/app/baseball/(dashboard)/dashboard/travel/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/travel/page.tsx @@ -7,6 +7,7 @@ import { useTeamStore } from '@/stores/team-store'; import { createClient } from '@/lib/supabase/client'; import { TravelClient } from '@/components/baseball/travel'; import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +import { SectionMasthead, EditorsLetter } from '@/components/baseball/living-annual'; import { getTeamItineraries, type BaseballTravelItinerary, @@ -112,10 +113,13 @@ export default function BaseballTravelPage() { if (!teamId) { return (
-

Travel

-
-

No team found

-

You must be on a team to access travel itineraries.

+ +
+
); @@ -124,7 +128,7 @@ export default function BaseballTravelPage() { if (loadError) { return (
-

Travel

+ ; } - const shell = isRedesignEnabled() ? ( - {children} - ) : ( - - {children} - - ); - - return shell; + return {children}; } // --------------------------------------------------------------------------- -// Layout export — thin shell that composes the session guard around the shared -// BaseballShellLayout. Both components are client components; the guard runs +// Layout export — thin shell that composes the session guard around +// BaseballFairwayShell. Both components are client components; the guard runs // its hooks before the shell renders, so the shell always receives a non-null // navContext (or the user has already been redirected away). // -// FAIRWAY MIGRATION — PHASE A (shell only): the team-context guard above is -// shared verbatim by both branches below — it is not part of "the shell" and -// stays byte-for-byte unchanged regardless of the flag. Only what renders -// INSIDE the guard is gated: flag ON mounts BaseballFairwayShell (the Fairway -// AppShell frame, presentation-only); flag OFF keeps the existing -// BaseballShellLayout -> BaseballDashboardShell composition exactly as it -// shipped before this change. Default (flag unset) is OFF — dark and -// reversible. +// SHELL UNIFICATION (Coherence Ruling 1, 2026-07-08): BaseballFairwayShell +// renders unconditionally — the legacy isRedesignEnabled() fork to +// BaseballShellLayout was removed. This is the only shell the (dashboard) +// route group renders. // --------------------------------------------------------------------------- export default function DashboardLayout({ children, diff --git a/src/app/baseball/(player-dashboard)/player/layout.tsx b/src/app/baseball/(player-dashboard)/player/layout.tsx index 9306ef5d9..d8cd1978a 100644 --- a/src/app/baseball/(player-dashboard)/player/layout.tsx +++ b/src/app/baseball/(player-dashboard)/player/layout.tsx @@ -1,16 +1,8 @@ 'use client'; import { BaseballFairwayShell } from '@/app/baseball/(dashboard)/BaseballFairwayShell'; -import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; export default function PlayerDashboardLayout({ children }: { children: React.ReactNode }) { - const shell = isRedesignEnabled() ? ( - {children} - ) : ( - {children} - ); - return ( <> {/* @@ -20,7 +12,7 @@ export default function PlayerDashboardLayout({ children }: { children: React.Re * (start_url: /baseball/player/today) is available for player sessions. */} - {shell} + {children} ); } diff --git a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts index 5493dd771..cb1a03fe3 100644 --- a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts +++ b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts @@ -85,7 +85,6 @@ const ADVISORY_ALLOWLIST: readonly AllowlistEntry[] = [ // list page, never meant to carry their own nav/hub entry. --- { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]', reason: 'deep-link detail page reached from Roster' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/passport', reason: 'deep-link sub-view reached from the player profile' }, - { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/profile', reason: 'deep-link sub-view reached from Roster' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/scout-packet', reason: 'deep-link sub-view reached from the player profile' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/scout-packet/preview', reason: 'deep-link print preview reached from the scout packet' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/stats', reason: 'deep-link sub-view reached from the player profile' }, diff --git a/src/app/baseball/actions/academics.ts b/src/app/baseball/actions/academics.ts index 3f7c04721..23326516c 100644 --- a/src/app/baseball/actions/academics.ts +++ b/src/app/baseball/actions/academics.ts @@ -215,9 +215,10 @@ const getTeamAcademicsAction = withBaseballAction( gpa: eligibility?.gpa ?? player?.gpa ?? null, credits_completed: eligibility?.credits_completed ?? null, credits_required: eligibility?.credits_required ?? 60, - // No eligibility record => treat as unknown / ineligible-safe rather - // than inflating "Eligible" / "Good Standing" summary cards. - is_eligible: eligibility?.is_eligible ?? false, + // Tri-state: null = no eligibility record on file yet (neutral, + // "Not on file"), never coerced to false — a missing record is not + // the same fact as a coach having marked the player ineligible. + is_eligible: eligibility?.is_eligible ?? null, academic_standing: eligibility?.academic_standing ?? null, class_count: classCounts[m.player_id] || 0, eligibility_id: eligibility?.id ?? null, diff --git a/src/app/baseball/actions/calendar.ts b/src/app/baseball/actions/calendar.ts index 56dc672d9..3df7d097a 100644 --- a/src/app/baseball/actions/calendar.ts +++ b/src/app/baseball/actions/calendar.ts @@ -69,7 +69,18 @@ interface UpdateEventInput { timezoneOffset?: number; } -type ActionResult = { success: boolean; error?: string; data?: T }; +type ActionResult = { + success: boolean; + error?: string; + data?: T; + /** + * Set when the primary write succeeded but a secondary, best-effort write + * (RSVP invites, the linked baseball_games row) failed — a partial-success + * signal so callers can surface it without treating the whole action as + * failed (the primary row really was created). + */ + warning?: string; +}; const CALENDAR_PATH = '/baseball/dashboard/calendar'; @@ -211,6 +222,14 @@ const createBaseballEventAction = withBaseballAction( if (error) return { success: false, error: sanitizeDbError(error, 'calendar') }; + // Secondary writes below are best-effort: the calendar event itself + // already exists (`data`), so a failure here should NOT report the whole + // action as failed — but it must not be silently discarded either + // (supabase-js never throws on a failed insert; the caller has to check + // `error` explicitly). Collected into `warnings` and surfaced via the + // `warning` field on a `success: true` result. + const warnings: string[] = []; + if (input.requiresRsvp && input.attendeeIds && input.attendeeIds.length > 0 && data) { const attendanceRecords = input.attendeeIds.map((playerId) => ({ event_id: data.id, @@ -219,12 +238,17 @@ const createBaseballEventAction = withBaseballAction( })); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (supabase as any).from('baseball_event_attendance').insert(attendanceRecords); + const { error: attendanceError } = await (supabase as any) + .from('baseball_event_attendance') + .insert(attendanceRecords); + if (attendanceError) { + warnings.push(`RSVP invites could not be created: ${sanitizeDbError(attendanceError, 'calendar')}`); + } } if (data && (input.eventType === 'game' || input.eventType === 'scrimmage')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (supabase as any).from('baseball_games').insert({ + const { error: gameError } = await (supabase as any).from('baseball_games').insert({ team_id: resolvedTeamId, event_id: data.id, game_date: input.startDate, @@ -235,11 +259,18 @@ const createBaseballEventAction = withBaseballAction( created_by: coachId, status: 'scheduled', }); + if (gameError) { + warnings.push(`Linked game record could not be created: ${sanitizeDbError(gameError, 'calendar')}`); + } else { + revalidatePath('/baseball/dashboard/stats/games'); + } } revalidatePath(CALENDAR_PATH); revalidatePath('/baseball/dashboard/events'); - return { success: true, data }; + return warnings.length > 0 + ? { success: true, data, warning: warnings.join(' ') } + : { success: true, data }; }, ); diff --git a/src/app/baseball/actions/games.ts b/src/app/baseball/actions/games.ts index 2f9c41835..5c9b91cbc 100644 --- a/src/app/baseball/actions/games.ts +++ b/src/app/baseball/actions/games.ts @@ -152,7 +152,7 @@ const createGameAction = withBaseballAction( ? `${input.game_date}T${input.event_time}` : `${input.game_date}T12:00:00`; - const { data: event } = await supabase + const { data: event, error: eventError } = await supabase .from('baseball_events') .insert({ team_id: teamId, @@ -172,6 +172,13 @@ const createGameAction = withBaseballAction( .select('id') .single(); + // The caller explicitly requested a linked calendar event + // (`create_calendar_event: true`) — silently proceeding without it + // (the previous behaviour: error discarded, `eventId` just stays null) + // would create a game the coach believes is on the calendar but isn't. + if (eventError) { + return { success: false, error: sanitizeDbError(eventError, 'games') }; + } if (event) eventId = event.id; } @@ -1062,7 +1069,7 @@ async function uploadBoxScoreCSVImpl( const allMatched = unmatched.length === 0; // Track the upload - const { data: upload } = await (supabase as unknown as SupabaseClient) + const { data: upload, error: uploadError } = await (supabase as unknown as SupabaseClient) .from('baseball_box_score_uploads') .insert({ team_id: teamId, @@ -1079,7 +1086,30 @@ async function uploadBoxScoreCSVImpl( .select('id') .single(); + if (uploadError && !allMatched) { + // Unmatched rows need a human to resolve them via resolveBoxScoreUpload, + // which requires THIS row's id + raw_content — without it the review + // flow has nothing to resolve against, so a failed tracking insert must + // fail loudly here rather than return a phantom `uploadId: undefined` + // that silently strands the unmatched players. + return { + success: false, + matched, + unmatched, + battingRows, + pitchingRows, + allMatched, + error: sanitizeDbError(uploadError, 'games'), + }; + } + let completionError: string | undefined; + if (uploadError) { + // allMatched path: the box score itself still gets saved below via the + // RPC — only the audit-trail upload row failed to write. Surface it as + // a completion-style warning instead of discarding it silently. + completionError = `Upload record could not be saved: ${sanitizeDbError(uploadError, 'games')}`; + } // If all matched, auto-save the box score via the atomic RPC — it saves // both sides (preserving whichever side isn't in this CSV), finalizes the @@ -1087,7 +1117,11 @@ async function uploadBoxScoreCSVImpl( // season data doesn't sit stale until a second manual action runs. if (allMatched && rows.length > 0) { const saveResult = await saveCsvBoxScoreViaRpc(gameId, csvType, battingRows, pitchingRows); - if (!saveResult.success) completionError = saveResult.error; + if (!saveResult.success) { + completionError = completionError + ? `${completionError} Also: ${saveResult.error}` + : saveResult.error; + } } return { @@ -1225,10 +1259,20 @@ async function resolveBoxScoreUploadImpl( const result = await saveCsvBoxScoreViaRpc(gameId, csvType, battingRows, pitchingRows); if (result.success) { - await (supabase as unknown as SupabaseClient) + // Best-effort bookkeeping: the box score itself already saved above, so + // a failure marking the audit-trail row `completed` must not flip the + // overall result to failure — but it also must not be discarded + // silently, so it's logged for observability instead. + const { error: statusError } = await (supabase as unknown as SupabaseClient) .from('baseball_box_score_uploads') .update({ status: 'completed' }) .eq('id', uploadId); + if (statusError) { + await logServerError( + `Box score saved but upload ${uploadId} could not be marked completed: ${sanitizeDbError(statusError, 'games')}`, + { action: 'baseball_games.resolveBoxScoreUpload', featureArea: 'baseball-games' }, + ); + } } return result; diff --git a/src/app/baseball/actions/messages.ts b/src/app/baseball/actions/messages.ts index fe18d62b7..6c48168d0 100644 --- a/src/app/baseball/actions/messages.ts +++ b/src/app/baseball/actions/messages.ts @@ -67,7 +67,7 @@ async function getPlayerUserIdImpl(playerId: string): Promise { /** * Coach-only: start (or resurface) a 1:1 conversation with a player, reached * from a player-profile surface (e.g. PlayerProfileCoachActions on - * /baseball/dashboard/players/[id]/profile). This wraps the shared + * /baseball/dashboard/players/[id]). This wraps the shared * createBaseballConversation primitive — which itself only checks * supabase.auth.getUser(), no role/capability check — with a SERVER-SIDE * `can_message_players` capability enforcement via withBaseballAction. diff --git a/src/app/lifting/actions/__tests__/groups.test.ts b/src/app/lifting/actions/__tests__/groups.test.ts new file mode 100644 index 000000000..e85bd931f --- /dev/null +++ b/src/app/lifting/actions/__tests__/groups.test.ts @@ -0,0 +1,125 @@ +// ============================================================================= +// src/app/lifting/actions/__tests__/groups.test.ts +// +// GUARD (Lane D2 fix): createGroup / updateGroup / deleteGroup / addGroupMember +// / removeGroupMember only revalidated the /lifting-portal GROUPS_PATH. Wave C +// now renders the same helm_lifting_groups reads at +// /baseball/dashboard/performance/groups (StrengthGroupsClient, mirroring the +// lifting portal), so a coach editing a group from either portal must bust +// BOTH caches — otherwise the other portal keeps serving a stale member list +// or a renamed group until an unrelated navigation. Mirrors the same +// regression-test pattern already established for programs.ts / sessions.ts / +// player-sessions.ts. +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface FinalResult { + data: unknown; + error: unknown; +} + +function makeQueryBuilder(finalResult: FinalResult) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const builder: any = {}; + const passthrough = ['select', 'eq', 'is']; + for (const m of passthrough) { + builder[m] = vi.fn(() => builder); + } + builder.insertArgs = null as unknown; + builder.insert = vi.fn((payload: unknown) => { + builder.insertArgs = payload; + return builder; + }); + builder.updateArgs = null as unknown; + builder.update = vi.fn((patch: unknown) => { + builder.updateArgs = patch; + return builder; + }); + builder.upsertArgs = null as unknown; + builder.upsert = vi.fn((payload: unknown, opts: unknown) => { + builder.upsertArgs = [payload, opts]; + return builder; + }); + builder.single = vi.fn(async () => finalResult); + builder.maybeSingle = vi.fn(async () => finalResult); + builder.then = (resolve: (v: FinalResult) => void) => resolve(finalResult); + return builder; +} + +const ORG_ID = '11111111-1111-4111-8111-111111111111'; +const GROUP_ID = '22222222-2222-4222-8222-222222222222'; +const ATHLETE_ID = '33333333-3333-4333-8333-333333333333'; + +let tableBuilders: Record>; + +function resetTables() { + tableBuilders = { + helm_lifting_groups: makeQueryBuilder({ + data: { id: GROUP_ID, organization_id: ORG_ID, name: 'Group A', group_type: 'static', is_active: true }, + error: null, + }), + helm_lifting_group_members: makeQueryBuilder({ data: { id: 'member-1' }, error: null }), + helm_lifting_athletes: makeQueryBuilder({ data: { id: ATHLETE_ID }, error: null }), + }; +} +resetTables(); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({})), +})); + +vi.mock('@/lib/supabase/untyped', () => ({ + fromUntyped: vi.fn((_client: unknown, table: string) => tableBuilders[table]), +})); + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })); + +vi.mock('@/lib/lifting/with-lifting-action', () => ({ + withLiftingAction: + (_name: string, _opts: unknown, fn: (...args: unknown[]) => unknown) => + (...args: unknown[]) => { + const ctx = { + user: { id: 'user-1' }, + orgId: ORG_ID, + access: { isCoach: true, canEdit: true, canView: true, coachRow: { id: 'coach-1' } }, + }; + return fn(ctx, ...args); + }, + LiftingActionError: class LiftingActionError extends Error {}, +})); + +import { revalidatePath } from 'next/cache'; +import { createGroup, updateGroup, deleteGroup } from '@/app/lifting/actions/groups'; + +describe('lifting groups actions revalidate the baseball performance portal (Wave C parallel routes)', () => { + beforeEach(() => { + resetTables(); + vi.clearAllMocks(); + resetTables(); + }); + + it('createGroup busts /baseball/dashboard/performance/groups alongside the lifting-portal path', async () => { + await createGroup({ name: 'New Group' }); + + const paths = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(paths).toContain('/lifting/dashboard/groups'); + expect(paths).toContain('/baseball/dashboard/performance/groups'); + }); + + it('updateGroup busts /baseball/dashboard/performance/groups alongside the lifting-portal path', async () => { + await updateGroup({ groupId: GROUP_ID, name: 'Renamed Group' }); + + const paths = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(paths).toContain('/lifting/dashboard/groups'); + expect(paths).toContain('/baseball/dashboard/performance/groups'); + }); + + it('deleteGroup busts /baseball/dashboard/performance/groups alongside the lifting-portal path', async () => { + await deleteGroup({ groupId: GROUP_ID }); + + const paths = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(paths).toContain('/lifting/dashboard/groups'); + expect(paths).toContain('/baseball/dashboard/performance/groups'); + }); +}); diff --git a/src/app/lifting/actions/__tests__/player-sessions.test.ts b/src/app/lifting/actions/__tests__/player-sessions.test.ts index 6844343d6..5eec08c2d 100644 --- a/src/app/lifting/actions/__tests__/player-sessions.test.ts +++ b/src/app/lifting/actions/__tests__/player-sessions.test.ts @@ -83,6 +83,7 @@ vi.mock('@/lib/lifting/resolve-baseball-context', () => ({ resolveMyBaseballAthleteId: vi.fn(async () => ATHLETE_ID), })); +import { revalidatePath } from 'next/cache'; import { logMySetResult, submitLiftReadiness } from '@/app/lifting/actions/player-sessions'; describe('logMySetResult onConflict target', () => { @@ -108,6 +109,24 @@ describe('logMySetResult onConflict target', () => { const [, opts] = tableBuilders.helm_lifting_set_results.upsertArgs as [unknown, { onConflict?: string }]; expect(opts).toEqual({ onConflict: 'session_exercise_id,set_number' }); }); + + it('revalidates both the /lifting and /baseball lift portals for the mutated session', async () => { + await logMySetResult({ + sessionExerciseId: SESSION_EXERCISE_ID, + sessionId: SESSION_ID, + athleteId: ATHLETE_ID, + setNumber: 1, + actualReps: 8, + actualLoad: 135, + loadUnit: 'lb', + rpe: 8, + }); + + const paths = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(paths).toContain('/lifting/dashboard/lift'); + expect(paths).toContain('/baseball/dashboard/lift'); + expect(paths).toContain(`/baseball/dashboard/lift/${SESSION_ID}`); + }); }); describe('submitLiftReadiness onConflict target', () => { @@ -147,4 +166,17 @@ describe('submitLiftReadiness onConflict target', () => { expect(res.success).toBe(false); expect(tableBuilders.helm_lifting_readiness_checkins.upsert).not.toHaveBeenCalled(); }); + + it('revalidates the baseball lift portal alongside /lifting/dashboard', async () => { + await submitLiftReadiness({ + sleepQuality: 4, + energyLevel: 4, + sorenessOverall: 2, + notes: null, + }); + + const paths = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(paths).toContain('/lifting/dashboard'); + expect(paths).toContain('/baseball/dashboard/lift'); + }); }); diff --git a/src/app/lifting/actions/__tests__/programs.test.ts b/src/app/lifting/actions/__tests__/programs.test.ts index 0d45cbd17..7c77d18f7 100644 --- a/src/app/lifting/actions/__tests__/programs.test.ts +++ b/src/app/lifting/actions/__tests__/programs.test.ts @@ -171,6 +171,7 @@ vi.mock('@/lib/lifting/with-lifting-action', () => ({ LiftingActionError: class LiftingActionError extends Error {}, })); +import { revalidatePath } from 'next/cache'; import { publishProgram } from '@/app/lifting/actions/programs'; describe('publishProgram program_assignments write target', () => { @@ -248,3 +249,29 @@ describe('publishProgram idempotent re-publish', () => { expect(sessionOpts).toEqual({ onConflict: 'program_assignment_id,athlete_id' }); }); }); + +describe('publishProgram revalidates the baseball performance portal (Wave C parallel routes)', () => { + beforeEach(() => { + resetTables(null); + vi.clearAllMocks(); + resetTables(null); + }); + + it('busts /baseball/dashboard/performance/programs, /performance/live, and /lift alongside the lifting-portal paths', async () => { + await publishProgram({ + programId: PROGRAM_ID, + scheduleDate: '2026-07-06', + targetGroupId: null, + targetAthleteId: null, + }); + + const revalidated = vi.mocked(revalidatePath).mock.calls.map((c) => c[0]); + expect(revalidated).toContain('/baseball/dashboard/performance/programs'); + expect(revalidated).toContain('/baseball/dashboard/performance/live'); + expect(revalidated).toContain('/baseball/dashboard/lift'); + // The lifting-portal paths must still fire too — this is additive, not a + // replacement of the existing revalidation. + expect(revalidated).toContain('/lifting/dashboard/programs'); + expect(revalidated).toContain('/lifting/dashboard/sessions'); + }); +}); diff --git a/src/app/lifting/actions/__tests__/sessions.test.ts b/src/app/lifting/actions/__tests__/sessions.test.ts index d7d1e47c7..0a8867469 100644 --- a/src/app/lifting/actions/__tests__/sessions.test.ts +++ b/src/app/lifting/actions/__tests__/sessions.test.ts @@ -97,6 +97,7 @@ vi.mock('@/lib/lifting/with-lifting-action', () => ({ LiftingActionError: class LiftingActionError extends Error {}, })); +import { revalidatePath } from 'next/cache'; import { logSetResult } from '@/app/lifting/actions/sessions'; describe('logSetResult onConflict target', () => { @@ -141,3 +142,33 @@ describe('logSetResult onConflict target', () => { expect(opts.onConflict.split(',')).not.toContain('athlete_id'); }); }); + +describe('logSetResult revalidates the baseball performance portal (Wave C parallel routes)', () => { + beforeEach(() => { + resetTables(); + vi.clearAllMocks(); + resetTables(); + }); + + it('busts /baseball/dashboard/performance/live and the per-session /lift/[sessionId] page', async () => { + await logSetResult({ + sessionExerciseId: SESSION_EXERCISE_ID, + athleteId: ATHLETE_ID, + setNumber: 1, + actualReps: 8, + actualLoad: 135, + }); + + const calls = vi.mocked(revalidatePath).mock.calls; + const paths = calls.map((c) => c[0]); + expect(paths).toContain('/baseball/dashboard/performance/live'); + expect(paths).toContain('/baseball/dashboard/lift'); + // The [sessionId] detail page uses the bracket-pattern + 'page' type + // convention (matches golf/actions/round-reviews.ts) rather than a + // resolved id — verify both the path AND the type argument. + const sessionPageCall = calls.find((c) => c[0] === '/baseball/dashboard/lift/[sessionId]'); + expect(sessionPageCall?.[1]).toBe('page'); + // Still fires the lifting-portal live-room revalidation too (additive). + expect(paths).toContain('/lifting/dashboard/sessions/live'); + }); +}); diff --git a/src/app/lifting/actions/groups.ts b/src/app/lifting/actions/groups.ts index d0e2daf7f..f28d5e1a2 100644 --- a/src/app/lifting/actions/groups.ts +++ b/src/app/lifting/actions/groups.ts @@ -33,6 +33,11 @@ import type { Json } from '@/lib/types'; const GROUPS_PATH = '/lifting/dashboard/groups'; +// Baseball Wave C renders these same helm_lifting_groups reads at +// /baseball/dashboard/performance/groups (mirroring the pattern in +// programs.ts) — bust that portal's RSC cache alongside the lifting one. +const BASEBALL_GROUPS_PATH = '/baseball/dashboard/performance/groups'; + // ----------------------------------------------------------------------------- // Shared result // ----------------------------------------------------------------------------- @@ -158,6 +163,7 @@ export const createGroup = withLiftingAction( } revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true, id: groupId }; }, ); @@ -195,6 +201,7 @@ export const updateGroup = withLiftingAction( if (error) throw error; revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true, id: input.groupId }; }, ); @@ -231,6 +238,7 @@ export const deleteGroup = withLiftingAction( .is('ends_at', null); revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true }; }, ); @@ -277,6 +285,7 @@ export const addGroupMember = withLiftingAction( } // Already active — no-op. revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true, id: existing.id }; } @@ -294,6 +303,7 @@ export const addGroupMember = withLiftingAction( if (error) throw error; revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -324,6 +334,7 @@ export const removeGroupMember = withLiftingAction( if (error) throw error; revalidatePath(GROUPS_PATH); + revalidatePath(BASEBALL_GROUPS_PATH); return { success: true }; }, ); diff --git a/src/app/lifting/actions/player-sessions.ts b/src/app/lifting/actions/player-sessions.ts index f50da7f00..4d203eef5 100644 --- a/src/app/lifting/actions/player-sessions.ts +++ b/src/app/lifting/actions/player-sessions.ts @@ -11,6 +11,14 @@ import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/supabase/server'; import { fromUntyped } from '@/lib/supabase/untyped'; import { resolveMyBaseballAthleteId } from '@/lib/lifting/resolve-baseball-context'; + +// Baseball Wave C renders these same helm_lifting_sessions / +// helm_lifting_readiness_checkins reads at /baseball/dashboard/lift and +// /baseball/dashboard/lift/[sessionId] (see src/app/baseball/(dashboard)/ +// dashboard/lift/page.tsx + [sessionId]/page.tsx) — bust both alongside +// '/lifting/dashboard/lift' below so a baseball athlete's own lift portal +// reflects a checkin/set/status mutation immediately. +const BASEBALL_LIFT_PATH = '/baseball/dashboard/lift'; import type { HelmLiftingSessionRow, HelmLiftingSessionStatus, @@ -170,6 +178,7 @@ export async function submitLiftReadiness(input: { if (error) return { success: false, error: 'Could not save check-in.' }; revalidatePath('/lifting/dashboard'); + revalidatePath(BASEBALL_LIFT_PATH); return { success: true, id: data?.id }; } @@ -190,6 +199,8 @@ export async function startMySession(sessionId: string): Promise<{ success: bool if (error) return { success: false, error: 'Could not start session.' }; revalidatePath('/lifting/dashboard/lift'); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(`${BASEBALL_LIFT_PATH}/${sessionId}`); return { success: true }; } @@ -250,6 +261,8 @@ export async function logMySetResult(input: { .eq('status', 'assigned'); revalidatePath('/lifting/dashboard/lift'); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(`${BASEBALL_LIFT_PATH}/${input.sessionId}`); return { success: true, id: data?.id }; } @@ -269,5 +282,7 @@ export async function completeMySession(sessionId: string): Promise<{ success: b if (error) return { success: false, error: 'Could not complete session.' }; revalidatePath('/lifting/dashboard/lift'); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(`${BASEBALL_LIFT_PATH}/${sessionId}`); return { success: true }; } diff --git a/src/app/lifting/actions/programs.ts b/src/app/lifting/actions/programs.ts index d9643d724..552c371f6 100644 --- a/src/app/lifting/actions/programs.ts +++ b/src/app/lifting/actions/programs.ts @@ -52,6 +52,16 @@ import type { const LAB_PATH = '/lifting/dashboard/programs'; +// Baseball Wave C now renders these same helm_lifting_* reads at parallel +// baseball routes (src/app/baseball/(dashboard)/dashboard/performance/...), +// so every mutation here must also bust the baseball portal's RSC cache — +// otherwise a coach editing a program from /lifting sees it update, but the +// same org's baseball staff at /baseball/dashboard/performance/programs keep +// serving stale data until the next unrelated navigation. +const BASEBALL_PROGRAMS_PATH = '/baseball/dashboard/performance/programs'; +const BASEBALL_LIVE_PATH = '/baseball/dashboard/performance/live'; +const BASEBALL_LIFT_PATH = '/baseball/dashboard/lift'; + // ----------------------------------------------------------------------------- // Shared result shape // ----------------------------------------------------------------------------- @@ -139,6 +149,7 @@ export const createProgram = withLiftingAction( if (error) throw error; revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -192,6 +203,8 @@ export const updateProgram = withLiftingAction( if (error) throw error; revalidatePath(`${LAB_PATH}/${programId}`); revalidatePath(LAB_PATH); + revalidatePath(`${BASEBALL_PROGRAMS_PATH}/${programId}`); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: programId }; }, ); @@ -263,6 +276,7 @@ export const deleteProgram = withLiftingAction( .eq('organization_id', ctx.orgId); revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true }; }, ); @@ -579,6 +593,9 @@ export const publishProgram = withLiftingAction( revalidatePath(LAB_PATH); revalidatePath('/lifting/dashboard/sessions'); + revalidatePath(BASEBALL_PROGRAMS_PATH); + revalidatePath(BASEBALL_LIVE_PATH); + revalidatePath(BASEBALL_LIFT_PATH); return { success: true, count: sessionCount }; }, ); @@ -707,6 +724,7 @@ export const addLiftWeek = withLiftingAction( .single(); if (error) throw error; revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -735,6 +753,7 @@ export const addLiftDay = withLiftingAction( .single(); if (error) throw error; revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -762,6 +781,7 @@ export const addLiftSection = withLiftingAction( .single(); if (error) throw error; revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -800,6 +820,7 @@ export const addLiftPrescription = withLiftingAction( .single(); if (error) throw error; revalidatePath(LAB_PATH); + revalidatePath(BASEBALL_PROGRAMS_PATH); return { success: true, id: (data as { id: string }).id }; }, ); @@ -1106,7 +1127,10 @@ export const duplicateLiftDay = withLiftingAction( .select('program_id').eq('id', weekId).maybeSingle() as { data: { program_id?: string } | null }; - if (wk?.program_id) revalidatePath(`${LAB_PATH}/${wk.program_id}`); + if (wk?.program_id) { + revalidatePath(`${LAB_PATH}/${wk.program_id}`); + revalidatePath(`${BASEBALL_PROGRAMS_PATH}/${wk.program_id}`); + } return { success: true, id: newDayId }; }, ); @@ -1179,6 +1203,7 @@ export const duplicateLiftWeek = withLiftingAction( } revalidatePath(`${LAB_PATH}/${src.program_id}`); + revalidatePath(`${BASEBALL_PROGRAMS_PATH}/${src.program_id}`); return { success: true, id: newWeekId }; }, ); diff --git a/src/app/lifting/actions/sessions.ts b/src/app/lifting/actions/sessions.ts index 516ee9115..85db45ffb 100644 --- a/src/app/lifting/actions/sessions.ts +++ b/src/app/lifting/actions/sessions.ts @@ -36,6 +36,20 @@ import type { const SESSIONS_PATH = '/lifting/dashboard/sessions'; const LIVE_PATH = '/lifting/dashboard/sessions/live'; +// Baseball Wave C renders the staff live-room read at +// /baseball/dashboard/performance/live and the per-session player detail at +// /baseball/dashboard/lift/[sessionId] (mirroring the pattern in +// programs.ts / groups.ts) — bust both alongside the lifting-portal paths so +// a staff edit here doesn't leave the baseball portal serving stale session +// state. The bracket-pattern + 'page' type revalidates every session id in +// one call (same convention as revalidatePath('/golf/dashboard/rounds/[id]/ +// review', 'page') in golf/actions/round-reviews.ts) — cheaper and safer +// than resolving one id per mutation site, several of which only have the +// session_exercise's parent session_id in scope, not a bound `sessionId`. +const BASEBALL_LIVE_PATH = '/baseball/dashboard/performance/live'; +const BASEBALL_LIFT_PATH = '/baseball/dashboard/lift'; +const BASEBALL_LIFT_SESSION_PATH = '/baseball/dashboard/lift/[sessionId]'; + // ----------------------------------------------------------------------------- // Shared result // ----------------------------------------------------------------------------- @@ -220,6 +234,9 @@ export const advanceSessionLifecycle = withLiftingAction( if (error) throw error; revalidatePath(SESSIONS_PATH); revalidatePath(LIVE_PATH); + revalidatePath(BASEBALL_LIVE_PATH); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(BASEBALL_LIFT_SESSION_PATH, 'page'); return { success: true, id: sessionId }; }, ); @@ -261,6 +278,9 @@ export const addSessionNote = withLiftingAction( if (error) throw error; revalidatePath(SESSIONS_PATH); + revalidatePath(BASEBALL_LIVE_PATH); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(BASEBALL_LIFT_SESSION_PATH, 'page'); return { success: true, id: sessionId }; }, ); @@ -320,6 +340,9 @@ export const modifySectionForAthlete = withLiftingAction( .eq('organization_id', ctx.orgId); revalidatePath(LIVE_PATH); + revalidatePath(BASEBALL_LIVE_PATH); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(BASEBALL_LIFT_SESSION_PATH, 'page'); return { success: true, id: input.sessionExerciseId }; }, ); @@ -399,6 +422,9 @@ export const logSetResult = withLiftingAction( .eq('status', 'assigned'); revalidatePath(LIVE_PATH); + revalidatePath(BASEBALL_LIVE_PATH); + revalidatePath(BASEBALL_LIFT_PATH); + revalidatePath(BASEBALL_LIFT_SESSION_PATH, 'page'); return { success: true, id: (data as { id: string }).id }; }, ); diff --git a/src/components/baseball/BaseballShellLayout.tsx b/src/components/baseball/BaseballShellLayout.tsx deleted file mode 100644 index 85d937d7c..000000000 --- a/src/components/baseball/BaseballShellLayout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -// ============================================================================= -// BaseballShellLayout — shared layout wrapper for all three BaseballHelm shell -// route groups: (dashboard), (coach-dashboard)/coach, (player-dashboard)/player. -// -// Extracted so the three layout.tsx files stay a thin ≤15-line wrapper around -// this component instead of triplicating the same provider tree. Any change to -// the session / provider / shell composition is made here once; the three layouts -// pick it up automatically. -// -// The only difference between the three layout variants is auth-role enforcement: -// - (dashboard): useBaseballAuth() — accepts both roles, passes dynamic -// - (coach-dashboard): useBaseballAuth('coach') — rejects players -// - (player-dashboard): useBaseballAuth('player') — rejects coaches -// -// The `requiredRole` prop maps to those three cases so callers can share this -// component without adding their own provider trees. -// ============================================================================= - -import type { ReactNode } from 'react'; -import { SidebarProvider } from '@/contexts/sidebar-context'; -import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider'; -import { PageLoading } from '@/components/ui/loading'; -import { LastSeenUpdater } from '@/components/admin/LastSeenUpdater'; -import { PeekPanelProvider } from '@/components/baseball/peek-panel'; -import { BaseballDashboardShell } from '@/components/baseball/dashboard-shell'; -import { BaseballProgramBrand } from '@/components/baseball/settings/BaseballProgramBrand'; -import { useBaseballAuth } from '@/hooks/use-baseball-auth'; -import { useBaseballNavContext } from '@/hooks/use-baseball-nav-context'; -import type { ActiveBaseballRole } from '@/lib/baseball/active-context-shared'; - -interface BaseballShellLayoutProps { - children: ReactNode; - /** - * When set, the auth hook enforces that the signed-in user has the matching - * role and redirects otherwise. `null` accepts both coach and player (the - * generic /baseball/dashboard route group). The `role` passed to the shell is - * derived from the auth response when this is `null`. - */ - requiredRole: ActiveBaseballRole | null; - /** Parent layout already verified auth — skip the second full-page skeleton. */ - authVerified?: boolean; -} - -export function BaseballShellLayout({ - children, - requiredRole, - authVerified = false, -}: BaseballShellLayoutProps) { - // For the generic (dashboard) group, `requiredRole` is null and the hook - // accepts both roles + returns the real role from the session. For the - // role-locked groups it enforces the expected role and redirects on mismatch. - const { loading, authorized, role } = useBaseballAuth(requiredRole); - // Server-resolved capability map so capability-gated coach verticals (Import - // Center, Postgame Review, Practice Effectiveness, Decision Room, Staff/Program - // Settings) actually appear in the nav. Without this the shell falls back to an - // empty capability map and fail-closes every gated entry. - const { navContext } = useBaseballNavContext(); - - if (!authVerified && (loading || !authorized)) { - return ; - } - - // When a role is locked (coach / player layout) pass it directly so the shell - // does not have to re-derive it from the session. When null, use the value the - // auth hook resolved (never undefined at this point because we checked above). - const resolvedRole: 'coach' | 'player' = - requiredRole ?? role ?? 'coach'; - - return ( - - - - {/* Render-null: fetches the program's brand + applies it as CSS vars / - data attrs on . Mounted once per shell so persisted branding - (settings/appearance) actually takes visible effect. */} - - - - {children} - - - - - ); -} diff --git a/src/components/baseball/NotificationBell.tsx b/src/components/baseball/NotificationBell.tsx index af6954dbf..9c85b30af 100644 --- a/src/components/baseball/NotificationBell.tsx +++ b/src/components/baseball/NotificationBell.tsx @@ -14,11 +14,10 @@ // clears the badge optimistically. // // Design tokens: cream/green palette — no blue/purple. Matches the shared -// BaseballDashboardShell header aesthetic (warm-matte surfaces, subtle +// BaseballFairwayShell header aesthetic (warm-matte surfaces, subtle // ring focus, green-primary CTAs). // -// Integration: the shell integration agent wires this into -// BaseballShellLayout / BaseballDashboardShell header. +// Integration: mounted in BaseballFairwayShell's top bar actions slot. // ============================================================================= import { useState, useEffect, useTransition, useCallback } from 'react'; diff --git a/src/components/baseball/ReadModelStateNotice.tsx b/src/components/baseball/ReadModelStateNotice.tsx index 8c0c31705..5829419e5 100644 --- a/src/components/baseball/ReadModelStateNotice.tsx +++ b/src/components/baseball/ReadModelStateNotice.tsx @@ -1,12 +1,11 @@ 'use client'; -import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { EditorsLetter } from '@/components/baseball/living-annual'; import { readModelStateLabel, type ReadModelLoadState, } from '@/lib/product-trust/read-model-state'; -import { IconWarning, IconShieldAlert } from '@/components/icons'; interface ReadModelStateNoticeProps { state: Exclude; @@ -24,6 +23,13 @@ const TITLES: Record, string> = { /** * Explicit load-state surface for legacy client pages (#413). * Never collapses failures into a healthy empty state. + * + * Living-Annual doctrine (design-system-living-annual.md §7): empty AND error + * states both render through `` — "no yellow warning boxes + * anywhere." This previously rendered its own amber-tinted icon chip + * (`bg-amber-50` / `text-amber-600`), which is exactly the pattern the kit + * replaces; composing `EditorsLetter` here fixes every call site at once + * (announcements, documents, travel, camps, compare, dev-plan). */ export function ReadModelStateNotice({ state, @@ -31,23 +37,21 @@ export function ReadModelStateNotice({ onRetry, className, }: ReadModelStateNoticeProps) { - const Icon = state === 'unauthorized' ? IconShieldAlert : IconWarning; const heading = title ?? TITLES[state]; return ( - - -
- -
-

{heading}

-

{readModelStateLabel(state)}

- {onRetry && state !== 'unauthorized' ? ( + Try again - ) : null} -
-
+ ) : undefined + } + /> ); } diff --git a/src/components/baseball/calendar/BaseballCalendarWrapper.tsx b/src/components/baseball/calendar/BaseballCalendarWrapper.tsx index 924ccd9a6..93cdd37f0 100644 --- a/src/components/baseball/calendar/BaseballCalendarWrapper.tsx +++ b/src/components/baseball/calendar/BaseballCalendarWrapper.tsx @@ -12,6 +12,7 @@ import { deleteBaseballEvent, rsvpToBaseballEvent, } from '@/app/baseball/actions/calendar'; +import { toast } from '@/components/ui/sonner'; import type { CalendarEvent } from '@/hooks/useCalendarEvents'; // ── Design tokens ────────────────────────────────────────────────────────────── @@ -84,8 +85,23 @@ async function respondToBaseballEvent( // ── Action handlers ──────────────────────────────────────────────────────────── const baseballActionHandlers = { - createEvent: (data: unknown) => - createBaseballEvent(data as Parameters[0]), + /** + * The event row itself can succeed while a secondary, best-effort write + * (RSVP invites, the linked `baseball_games` row) fails — `createBaseballEvent` + * surfaces that as `result.warning` on an otherwise `success: true` result + * (see `ActionResult` in `@/app/baseball/actions/calendar`). The shared + * `PremiumCalendarClient` only branches on `result.success`, so it never + * looks at `.warning` — this non-blocking toast is the only place that gap + * gets surfaced to the coach. The result is returned unchanged so the rest + * of the save flow (closing the modal, `router.refresh()`) is untouched. + */ + createEvent: async (data: unknown) => { + const result = await createBaseballEvent(data as Parameters[0]); + if (result.success && result.warning) { + toast.warning('Event created', { description: result.warning }); + } + return result; + }, updateEvent: (id: string, data: unknown) => updateBaseballEvent(id, data as Parameters[1]), deleteEvent: deleteBaseballEvent, diff --git a/src/components/baseball/calendar/CalendarFairway.tsx b/src/components/baseball/calendar/CalendarFairway.tsx index 368825e54..3a65eafcc 100644 --- a/src/components/baseball/calendar/CalendarFairway.tsx +++ b/src/components/baseball/calendar/CalendarFairway.tsx @@ -2,17 +2,19 @@ /** * ============================================================================ - * CalendarFairway — Fairway (warm-premium) presentation of the baseball - * Calendar page. Phase B leaf migration, Wave 1 · calendar. Flag-gated behind - * `isRedesignEnabled()` — see the page fork. + * CalendarFairway — "The Living Annual" presentation of the baseball Calendar + * page (spec: docs/baseball/design-system-living-annual.md; map: + * docs/baseball/ui-migration-map.md `calendar` row — `SectionMasthead` + + * `EmptyIssue`/`EditorsLetter` consistency pass). * ---------------------------------------------------------------------------- * PRESENTATION ONLY. Migrates the page-owned chrome — the canvas background, - * the event-summary strip, and the college-coach recruiting empty state — to - * Fairway primitives. The interactive grid (`BaseballCalendarWrapper` → - * `PremiumCalendarClient`) is a SHARED component and is reused verbatim inside - * the new frame per the migration playbook §3.5. No data path, action, event - * mapping, RSVP bridge, or query is touched here — the wrapper keeps every - * baseball action handler and capability flag it already had. + * the masthead, the event-summary strip, and the college-coach recruiting + * empty state — to the Living-Annual kit. The interactive grid + * (`BaseballCalendarWrapper` → `PremiumCalendarClient`) is a SHARED component + * and is reused verbatim inside the new frame per the migration playbook §3.5. + * No data path, action, event mapping, RSVP bridge, or query is touched here — + * the wrapper keeps every baseball action handler and capability flag it + * already had. * * A full Fairway-native month grid (as golf built under * `components/fairway/pages/calendar`) is a separate, larger effort. @@ -20,25 +22,31 @@ import type { ComponentProps } from 'react'; import Link from 'next/link'; -import { Calendar as CalendarIcon } from 'lucide-react'; -import { EmptyState, StatusPill, Button, type FwStatusTone } from '@/components/fairway'; +import { Button } from '@/components/fairway'; +import { SectionMasthead, EditorsLetter, InkBadge, LiveDot } from '@/components/baseball/living-annual'; import { fairwayScope } from '@/lib/redesign/flag'; import { BaseballCalendarWrapper } from './BaseballCalendarWrapper'; import type { CalendarEvent } from '@/hooks/useCalendarEvents'; type WrapperProps = ComponentProps; -/** Event-type → label + Fairway status tone for the summary strip. */ -const EVENT_TYPE_META: Record = { - game: { label: 'Game', tone: 'info' }, - practice: { label: 'Practice', tone: 'accent' }, - camp: { label: 'Camp', tone: 'warning' }, - tryout: { label: 'Tryout', tone: 'warning' }, - meeting: { label: 'Meeting', tone: 'neutral' }, - travel: { label: 'Travel', tone: 'info' }, - other: { label: 'Other', tone: 'neutral' }, +/** Event-type → singular label for the ruled summary strip. */ +const EVENT_TYPE_LABEL: Record = { + game: 'game', + practice: 'practice', + camp: 'camp', + tryout: 'tryout', + meeting: 'meeting', + travel: 'travel event', + other: 'other', }; +/** Pluralize a singular event-type label for the count badge ("1 game" / "3 games"). */ +function pluralizeEventLabel(label: string, count: number): string { + if (count === 1) return label; + return label.endsWith('s') ? label : `${label}s`; +} + // Preserve the legacy full-height flex shell so PremiumCalendarClient's h-full // resolves; only the gradient background is swapped for the Fairway canvas. const SHELL = 'flex h-[calc(100vh-5.5rem-env(safe-area-inset-bottom))] flex-col md:h-screen'; @@ -68,16 +76,25 @@ export function CalendarFairway({ if (recruitingEmpty) { return (
+
+ +
- Browse prospects } + className="max-w-md" />
@@ -86,6 +103,10 @@ export function CalendarFairway({ return (
+
+ +
+ {/* Gated on `upcomingEvents` (not `events.length`) — the strip reads "N upcoming events", so a team with only past events has nothing upcoming to summarize. `eventTypeCounts` is derived from the same @@ -94,23 +115,13 @@ export function CalendarFairway({ scrolling horizontally so nothing clips off the 390px viewport with no visible way to reach it (visual-verify coach-ops__calendar). */} {upcomingEvents > 0 && ( -
+
- - {upcomingEvents} upcoming event{upcomingEvents !== 1 ? 's' : ''} - - - · - + {Object.entries(eventTypeCounts).map(([type, count]) => { - const meta = EVENT_TYPE_META[type] ?? { - label: type, - tone: 'neutral' as FwStatusTone, - }; + const label = EVENT_TYPE_LABEL[type] ?? type; return ( - - {count} {meta.label} - + ); })}
diff --git a/src/components/baseball/dashboard-shell.tsx b/src/components/baseball/dashboard-shell.tsx deleted file mode 100644 index 78f6692a8..000000000 --- a/src/components/baseball/dashboard-shell.tsx +++ /dev/null @@ -1,268 +0,0 @@ -'use client'; - -import { useEffect, useRef, useMemo } from 'react'; -import { usePathname } from 'next/navigation'; -import { Sidebar } from '@/components/layout/sidebar'; -import { CommandPalette } from '@/components/CommandPalette'; -import { MobileBottomNav, type MobileNavItem } from '@/components/layout/mobile-bottom-nav'; -import { useSidebar } from '@/contexts/sidebar-context'; -import { useUnreadCount } from '@/hooks/use-unread-count'; -import { cn } from '@/lib/utils'; -import { HubSubNav } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; -import { resolveActiveHub } from '@/app/baseball/(dashboard)/_components/resolve-active-hub'; -import { NotificationBell } from '@/components/baseball/NotificationBell'; -import { - getVisibleBaseballNav, - type BaseballNavContext, -} from '@/lib/baseball/nav-registry'; -import { - IconHome, - IconUsers, - IconUser, - IconCalendar, - IconMenu, -} from '@/components/icons'; - -// --------------------------------------------------------------------------- -// Fallback mobile nav (rendered while navContext is still resolving so the -// bottom bar is never empty on first paint). -// --------------------------------------------------------------------------- -const COACH_NAV_FALLBACK: MobileNavItem[] = [ - { label: 'Home', href: '/baseball/dashboard/command-center', icon: IconHome }, - { label: 'Calendar', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { label: 'Roster', href: '/baseball/dashboard/roster', icon: IconUsers }, -]; - -const PLAYER_NAV_FALLBACK: MobileNavItem[] = [ - { label: 'Home', href: '/baseball/player/today', icon: IconHome }, - { label: 'Schedule', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { label: 'Profile', href: '/baseball/dashboard/profile', icon: IconUser }, -]; - -/** - * Derive the 4 mobile bottom nav items from the resolved nav context. - * - * Strategy: keep the three everyday mobile destinations stable, then make the - * fourth slot open the full drawer. The registry still decides whether the - * preferred ids exist for the current role/program, and the drawer carries the - * rest of the product surface without pretending Settings is "More". - */ -function buildMobileNavFromContext( - ctx: BaseballNavContext, - unreadCount: number, - openMenu: () => void, -): MobileNavItem[] { - const primary = getVisibleBaseballNav(ctx).filter((e) => e.section === 'primary'); - const preferredIds = ctx.role === 'coach' - ? ['command-center', 'calendar', 'roster'] - : ['player-today', 'calendar', 'player-profile']; - const top3 = preferredIds - .map((id) => primary.find((e) => e.id === id)) - .filter((e): e is NonNullable => Boolean(e)); - const fill = primary.filter((e) => !top3.some((selected) => selected.id === e.id)); - const items: MobileNavItem[] = [...top3, ...fill].slice(0, 3).map((e) => ({ - label: e.label, - href: e.href, - icon: e.icon, - ...(e.showUnreadBadge && unreadCount > 0 ? { badge: unreadCount } : {}), - })); - items.push({ - label: 'Menu', - icon: IconMenu, - onClick: openMenu, - ...(unreadCount > 0 ? { badge: unreadCount } : {}), - }); - return items; -} - -type Props = { - children: React.ReactNode; - role: 'coach' | 'player'; - /** - * Server-resolved nav context (role + capabilities + programType). When - * provided, the mobile bottom nav is derived from getVisibleBaseballNav() so - * nav registry changes propagate automatically. Falls back to the hardcoded - * role constants until the context resolves. - */ - navContext?: BaseballNavContext; -}; - -export function BaseballDashboardShell({ children, role, navContext }: Props) { - const { collapsed, mobileOpen, setMobileOpen } = useSidebar(); - const pathname = usePathname(); - const { unreadCount } = useUnreadCount(); - - // Derive the mobile nav from the registry when the context is available; - // fall back to the role-specific constants while it is still resolving. - const mobileNavItems = useMemo(() => { - if (navContext) { - return buildMobileNavFromContext(navContext, unreadCount, () => setMobileOpen(true)); - } - const fallback = role === 'coach' ? COACH_NAV_FALLBACK : PLAYER_NAV_FALLBACK; - return [ - ...fallback, - { - label: 'Menu', - icon: IconMenu, - onClick: () => setMobileOpen(true), - ...(unreadCount > 0 ? { badge: unreadCount } : {}), - }, - ]; - }, [navContext, role, unreadCount, setMobileOpen]); - - // Grouped-hubs sub-tab strip: resolve which hub (Team / Stats / Development / - // Management / Recruiting / Academics) owns the current route and render its - // sub-tabs above the page. Top-level surfaces (Dashboard, Profile, etc.) sit - // in no hub → activeHub is null → no strip. - const activeHub = resolveActiveHub({ - pathname, - role, - programType: navContext?.programType ?? null, - capabilities: navContext?.capabilities, - }); - - const mobileSidebarRef = useRef(null); - const triggerRef = useRef(null); - - // Close mobile sidebar on Escape key - useEffect(() => { - if (!mobileOpen) return; - function onKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') setMobileOpen(false); - } - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [mobileOpen, setMobileOpen]); - - // Prevent body scroll when mobile sidebar is open - useEffect(() => { - if (!mobileOpen) return; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, [mobileOpen]); - - // Focus trap for mobile sidebar + restore focus on close - useEffect(() => { - if (!mobileOpen || !mobileSidebarRef.current) return; - - // Store the element that had focus before sidebar opened - triggerRef.current = document.activeElement; - - const sidebar = mobileSidebarRef.current; - const focusable = sidebar.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - const first = focusable[0]; - if (first) first.focus(); - - function trapFocus(e: KeyboardEvent) { - if (e.key !== 'Tab' || focusable.length === 0) return; - const firstEl = focusable[0]!; - const lastEl = focusable[focusable.length - 1]!; - if (e.shiftKey && document.activeElement === firstEl) { - e.preventDefault(); - lastEl.focus(); - } else if (!e.shiftKey && document.activeElement === lastEl) { - e.preventDefault(); - firstEl.focus(); - } - } - document.addEventListener('keydown', trapFocus); - return () => { - document.removeEventListener('keydown', trapFocus); - // Restore focus to trigger element when sidebar closes - if (triggerRef.current instanceof HTMLElement) { - triggerRef.current.focus(); - } - }; - }, [mobileOpen]); - - return ( - // `baseball-shell` is a scoping hook ONLY (no styles of its own) so - // globals.css can safely re-point the legacy sidebar's active-nav accent - // to the program's persisted brand color without ever matching a golf - // route. This component + (src/components/layout/sidebar.tsx) - // are baseball-only, but the class gives CSS an explicit, grep-able - // boundary instead of relying on file co-location. See globals.css - // "BaseballHelm legacy shell nav-accent consumer" for the paired rule. -
- - Skip to main content - - - - -
- -
- - {/* Mobile Sidebar Overlay */} -
setMobileOpen(false)} - aria-hidden="true" - /> - - {/* Mobile Sidebar */} -
- -
- -
-
-
- -
- - {/* Grouped-hub sub-tab strip (only on hub-owned routes). */} - {activeHub && ( - - )} - {children} -
-
- - -
- ); -} diff --git a/src/components/baseball/dashboard/dashboard-types.ts b/src/components/baseball/dashboard/dashboard-types.ts deleted file mode 100644 index a9ac66071..000000000 --- a/src/components/baseball/dashboard/dashboard-types.ts +++ /dev/null @@ -1,61 +0,0 @@ -export interface TeamHealthData { - rosterCount: number; - rosterCapacity: number; - eligibleCount: number; - eligibilityPct: number; - teamGpa: number | null; - transferReadyCount: number; - recentJoins: number; -} - -export interface DevPlanProgressItem { - playerId: string; - playerName: string; - avatarUrl: string | null; - completedGoals: number; - totalGoals: number; - progressPct: number; - hasOverdue: boolean; - nextGoalTitle: string | null; -} - -export interface AttentionItem { - type: 'academic_risk' | 'declining_stats' | 'overdue_goals' | 'no_video'; - count: number; - playerIds: string[]; - description: string; -} - -export interface TeamStatsTrendPoint { - date: string; - teamAvg: number | null; - exitVelo: number | null; - obp: number | null; -} - -export interface CollegeInterestItem { - schoolName: string; - schoolLogo: string | null; - playerId: string; - playerName: string; - viewCount: number; - isWatchlisted: boolean; - lastViewed: string; -} - -export interface CollegeInterestSummary { - totalProfileViews: number; - profileViewsChange: number; - schoolsInterested: number; - watchlistAdds: number; - topInterest: CollegeInterestItem[]; -} - -export interface TeamActivity { - id: string; - type: 'video_upload' | 'goal_completed' | 'stats_uploaded' | 'player_joined' | 'message'; - playerId: string | null; - playerName: string | null; - description: string; - timestamp: string; -} diff --git a/src/components/baseball/documents/DocumentsFairway.tsx b/src/components/baseball/documents/DocumentsFairway.tsx index 25306aeac..4a7dfe482 100644 --- a/src/components/baseball/documents/DocumentsFairway.tsx +++ b/src/components/baseball/documents/DocumentsFairway.tsx @@ -2,14 +2,16 @@ /** * ============================================================================ - * DocumentsFairway — Fairway (warm-premium) presentation of the baseball - * Documents page. Phase B leaf migration, Wave 2 · documents. Flag-gated - * behind `isRedesignEnabled()` — see the client fork. + * DocumentsFairway — "The Living Annual" presentation of the baseball + * Documents page (spec: docs/baseball/design-system-living-annual.md; map: + * docs/baseball/ui-migration-map.md `documents` row — `SectionMasthead` + + * `EmptyIssue` consistency pass). * ---------------------------------------------------------------------------- * PRESENTATION ONLY. Receives the SAME computed state + handlers the client * owns (filtered docs, search/category state, upload state, and the card - * callbacks) and migrates the CHROME — header + upload, search, category tabs, - * empty state — to `@/components/fairway` primitives. + * callbacks) and migrates the CHROME — masthead + upload, search, category + * tabs, empty state — to the Living-Annual kit (search/filter tabs stay on + * `@/components/fairway` primitives, which aren't part of the kit's coverage). * * The document cards are rendered from the reused `DocumentCard` (which owns * its own menu + actions); the hidden file input, preview modal, and @@ -18,9 +20,8 @@ * ========================================================================== */ import type { ComponentProps, ReactNode } from 'react'; -import { Upload, FileText } from 'lucide-react'; +import { Upload } from 'lucide-react'; import { - ViewHeader, SearchField, Segmented, Button, @@ -28,6 +29,7 @@ import { Select, Switch, } from '@/components/fairway'; +import { SectionMasthead, EmptyIssue } from '@/components/baseball/living-annual'; import { DocumentCard } from './DocumentCard'; import type { BaseballDocument } from '@/app/baseball/actions/documents'; @@ -111,10 +113,11 @@ export function DocumentsFairway({
{fileInputSlot} - ) : undefined } - /> + > +

+ {isCoach ? 'Share playbooks, plans, and team files' : 'Team documents and resources'} +

+
@@ -178,30 +185,32 @@ export function DocumentsFairway({
{filtered.length === 0 ? ( - } - onClick={onUpload} - > - Upload document - - ) : undefined - } - /> + totalCount === 0 ? ( + } + onClick={onUpload} + > + Upload document + + ) : undefined + } + /> + ) : ( + // Filtered-to-zero (search/category), not a true empty surface — + // outside the kit's empty-state doctrine, so it keeps the neutral + // Fairway `search` variant rather than the `documents` EmptyIssue + // preset copy ("The file drawer is empty"), which would be wrong + // here. + + ) ) : (
{filtered.map((doc) => ( diff --git a/src/components/baseball/living-annual/RuledStatLine.tsx b/src/components/baseball/living-annual/RuledStatLine.tsx index 92832f855..d9484010e 100644 --- a/src/components/baseball/living-annual/RuledStatLine.tsx +++ b/src/components/baseball/living-annual/RuledStatLine.tsx @@ -129,7 +129,7 @@ export function RuledStatLine({ initial="hidden" animate="visible" variants={settle} - className={cn('flex items-baseline gap-2 pb-1 font-annual leading-none', numeralInk, numeralSize)} + className={cn('flex items-baseline gap-2 pb-1 font-annual', numeralInk, numeralSize)} > {ghost ? ( diff --git a/src/components/baseball/performance/LiveWeightRoom.tsx b/src/components/baseball/performance/LiveWeightRoom.tsx deleted file mode 100644 index dbd8f5bed..000000000 --- a/src/components/baseball/performance/LiveWeightRoom.tsx +++ /dev/null @@ -1,1203 +0,0 @@ -'use client'; - -// ============================================================================= -// src/components/baseball/performance/LiveWeightRoom.tsx -// -// V11 Live Weight Room mode (spec L522-573 + Packet G) — the flagship premium -// staff surface. ONE screen to run a room of 20-60 athletes: -// -// * Full-width sticky top bar: team · lift day · group · completion · risk · -// live clock · group filter · refresh (spec L526-533). -// * Athlete grid: name / position / station / exercise / prescribed vs actual -// load / RPE / readiness badge / last-update (spec L538-549). Color PLUS label -// everywhere (spec L568) — never color alone. -// * Right rail (4 derived queues): needs-coach · readiness flags · load -// changes · missing check-ins (spec L551-556). -// * Bottom drawer: selected-athlete set logger + load adjust + substitute + -// mark observed + mark limited + quick message + follow-up task (spec L558-566). -// -// WRITE MODEL: every coach action is OPTIMISTIC with ROLLBACK (spec L573). We -// apply the change to local state immediately, fire the capability-enforced -// server action, and on failure revert to the pre-action snapshot + surface a -// non-destructive error toast. A poll (or manual refresh) re-syncs from truth. -// -// Cream/green GolfHelm palette ONLY. Reuses Card / EmptyState. No navy/amber tale. -// ============================================================================= - -import { useCallback, useEffect, useId, useMemo, useRef, useState, useTransition } from 'react'; -import Link from 'next/link'; -import { motion, useReducedMotion } from 'framer-motion'; -import { toast } from 'sonner'; - -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { EmptyState } from '@/components/ui/empty-state'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { NativeSelect } from '@/components/ui/select'; -import { getFullName } from '@/lib/utils'; -import { readinessBandLabel, readinessBandTone } from '@/lib/baseball/lifting/readiness-compute'; -import { - logSetResult, - modifySessionExercise, - substituteSessionExercise, - markExerciseObserved, - sendLiftQuickMessage, - createLiftFollowupTask, - setAvailabilityStatus, - getLiveWeightRoomSnapshot, -} from '@/app/baseball/actions/lifting-v11'; -import { useLiveSetSync } from '@/lib/baseball/lifting/use-live-set-sync'; -import type { - BaseballLiveWeightRoomData, - BaseballLiveAthleteRow, - BaseballLiveExerciseRow, - BaseballReadinessBand, - BaseballLiftSessionStatus, -} from '@/lib/types/baseball-lifting-v11'; - -interface Props { - initialData: BaseballLiveWeightRoomData; - canViewReadiness: boolean; - playerNameById: Record; - exerciseLibrary: Array<{ id: string; name: string; category: string | null }>; - groupFilter: string | null; - isLoading?: boolean; -} - -const POLL_MS = 20_000; - -const TONE_CLASS: Record<'success' | 'warning' | 'error' | 'info', string> = { - success: 'bg-primary-50 text-primary-700 border-primary-200', - warning: 'bg-amber-50 text-amber-700 border-amber-200', - error: 'bg-red-50 text-red-700 border-red-200', - info: 'bg-warm-50 text-warm-700 border-warm-200', -}; - -const SESSION_STATUS: Record = { - assigned: { label: 'Not started', tone: 'info' }, - started: { label: 'In progress', tone: 'warning' }, - modified: { label: 'Modified', tone: 'warning' }, - completed: { label: 'Complete', tone: 'success' }, - missed: { label: 'Missed', tone: 'error' }, - excused: { label: 'Excused', tone: 'info' }, -}; - -function BandChip({ band }: { band: BaseballReadinessBand | null }) { - if (!band) { - return ( - - No check-in - - ); - } - const tone = readinessBandTone(band); - return ( - - {readinessBandLabel(band)} - - ); -} - -function StatusChip({ status }: { status: BaseballLiftSessionStatus }) { - const s = SESSION_STATUS[status] ?? { label: status, tone: 'info' as const }; - return ( - - {s.label} - - ); -} - -function relTime(iso: string | null): string { - if (!iso) return '—'; - const diff = Date.now() - new Date(iso).getTime(); - if (diff < 0) return 'now'; - const m = Math.floor(diff / 60_000); - if (m < 1) return 'just now'; - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - return `${h}h ago`; -} - -export function LiveWeightRoom({ - initialData, - canViewReadiness, - playerNameById, - exerciseLibrary, - groupFilter, - isLoading = false, -}: Props) { - const [data, setData] = useState(initialData); - const [selectedId, setSelectedId] = useState(null); - const [clock, setClock] = useState(() => new Date()); - const [, startTransition] = useTransition(); - const [refreshing, setRefreshing] = useState(false); - const pollRef = useRef | null>(null); - - // Offline-safe set capture. Set logging is the highest-frequency entry on this - // surface and the one where a dropped network LOSES real work — so it does NOT - // go through runOptimistic's rollback path. Instead it buffers durably and - // replays on reconnect (logSetResult upserts on (exercise,set) → safe replay). - const setSync = useLiveSetSync( - useCallback( - (input) => - logSetResult({ - sessionId: input.sessionId, - sessionExerciseId: input.sessionExerciseId, - setNumber: input.setNumber, - actualReps: input.actualReps, - actualLoad: input.actualLoad, - loadUnit: input.loadUnit, - rpe: input.rpe, - }), - [], - ), - ); - - // Live clock (spec L532). - useEffect(() => { - const t = setInterval(() => setClock(new Date()), 1000); - return () => clearInterval(t); - }, []); - - // Refresh the snapshot from the server (manual + polling). Never clobbers an - // in-flight optimistic write: we always re-fetch the authoritative truth. - const refresh = useCallback(async () => { - setRefreshing(true); - try { - const r = await getLiveWeightRoomSnapshot({ groupId: groupFilter }); - if (r.success && r.data) setData(r.data); - } catch { - // Polling failure is silent (transient); the next tick retries. - } finally { - setRefreshing(false); - } - }, [groupFilter]); - - // Polling fallback (spec L572). Pauses while the drawer is open so a coach - // mid-edit isn't yanked out from under by a refresh. - useEffect(() => { - if (selectedId) { - if (pollRef.current) clearInterval(pollRef.current); - return undefined; - } - pollRef.current = setInterval(refresh, POLL_MS); - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, [selectedId, refresh]); - - const selected = useMemo( - () => data.athletes.find((a) => a.session_id === selectedId) ?? null, - [data.athletes, selectedId], - ); - - // ---- Optimistic helpers ------------------------------------------------- - // Apply a local mutation to an athlete's row, fire the server action, and on - // failure restore the pre-action snapshot. The whole athletes array is the unit - // of rollback so any derived field (queues read from props) stays consistent. - const runOptimistic = useCallback( - ( - mutate: (athletes: BaseballLiveAthleteRow[]) => BaseballLiveAthleteRow[], - action: () => Promise<{ success: boolean; error?: string }>, - okMsg: string, - ) => { - const snapshot = data; - setData((prev) => ({ ...prev, athletes: mutate(prev.athletes) })); - startTransition(async () => { - try { - const r = await action(); - if (!r.success) { - setData(snapshot); // rollback - toast.error(r.error ?? 'That change did not save.'); - return; - } - toast.success(okMsg); - await refresh(); // re-sync from truth (PRs, derived queues, etc.) - } catch { - setData(snapshot); - toast.error('That change did not save.'); - } - }); - }, - [data, refresh], - ); - - // ---- Offline-safe set logging (NOT rollback) ---------------------------- - // Apply the optimistic grid mutation AND durably buffer the set. The buffer — - // not the live request — is what guarantees the set survives: it replays on - // reconnect and is only cleared on a confirmed server upsert. A flaky-network - // failure therefore keeps the logged set on screen instead of yanking it away. - const applyLoggedSet = useCallback( - ( - mutate: (athletes: BaseballLiveAthleteRow[]) => BaseballLiveAthleteRow[], - set: Parameters[0], - ) => { - setData((prev) => ({ ...prev, athletes: mutate(prev.athletes) })); - setSync.queueAndFlush(set); - }, - [setSync], - ); - - const tb = data.top_bar; - - if (isLoading) { - return ( -
- {/* Sticky top bar skeleton */} -
-
- - - -
- - -
-
-
- {/* Main layout skeleton */} -
- {/* Athlete grid */} -
- {/* Grid header */} -
- {Array.from({ length: 7 }).map((_, i) => ( - - ))} -
- {/* Athlete rows */} - {Array.from({ length: 8 }).map((_, i) => ( -
-
- -
- - -
-
- - - - - -
- ))} -
- {/* Right rail */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - {Array.from({ length: 2 }).map((_, j) => ( -
- - -
- ))} -
- ))} -
-
-
- ); - } - - return ( -
- {/* ===== Full-width sticky top bar (spec L526-533) ===== */} -
-
-
- - ← Command center - -
-

Live

-

Live Weight Room

-

- {tb.team_name} · {tb.lift_day_label} - {tb.active_group ? ` · ${tb.active_group}` : ''} -

-
-
- - {/* Live stats */} -
- - 0 ? 'success' : undefined} /> - - {canViewReadiness && ( - 0 ? 'error' : 'success'} /> - )} -
- -
- {/* Group filter (top-bar "active group") */} -
- - e.currentTarget.form?.requestSubmit()} - className="rounded-lg border border-warm-200 px-2 py-1.5 text-sm text-warm-700 transition-colors focus-visible:border-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40" - > - - {data.groups.map((g) => ( - - ))} - -
- - - {clock.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - - -
-
-
- - {/* ===== Body: grid + right rail ===== */} -
- {/* Athlete grid */} -
- {data.athletes.length === 0 ? ( - - - - - - ) : ( -
- - - - - - - - - - {canViewReadiness && } - - - - - {data.athletes.map((a) => { - const isSel = a.session_id === selectedId; - return ( - - - - - - - - {canViewReadiness && ( - - )} - - - - ); - })} - -
AthleteStatusStation / exercisePrescribedActualRPEReadinessUpdated -
-
- {a.needs_coach && ( - - )} -
-
{getFullName(a.first_name, a.last_name)}
-
- {a.primary_position ?? '—'} - {a.group_names[0] ? ` · ${a.group_names[0]}` : ''} -
-
-
-
- -
- {a.completed_exercises}/{a.total_exercises} lifts -
-
-
{a.current_exercise ?? '—'}
-
{a.current_station ?? '—'}
-
- {a.prescribed_load != null ? `${a.prescribed_load} lb` : '—'} - - {a.actual_load != null ? ( - - {a.actual_load} lb - - ) : ( - - )} - - {a.rpe != null ? a.rpe.toFixed(1) : '—'} - {relTime(a.last_update)} - -
-
- )} -
- - {/* ===== Right rail: 4 derived queues (spec L551-556) ===== */} - -
- - {/* ===== Bottom drawer: selected-athlete logger + actions (spec L558-566) ===== */} - {selected && ( - setSelectedId(null)} - runOptimistic={runOptimistic} - applyLoggedSet={applyLoggedSet} - /> - )} -
- ); -} - -// ---------------------------------------------------------------------------- - -function Stat({ label, value, tone }: { label: string; value: string | number; tone?: 'success' | 'error' }) { - const color = tone === 'error' ? 'text-red-700' : tone === 'success' ? 'text-primary-700' : 'text-warm-900'; - return ( -
-
{value}
-
{label}
-
- ); -} - -// Connectivity + offline-buffer indicator. Honest about state: green "Synced" -// when nothing is queued and online; amber "Offline — N saved" while sets are -// buffered locally (they are NOT lost — they replay on reconnect). Clickable to -// force a retry. This is the premium reassurance a dugout-side coach needs. -function SyncPill({ pending, isOnline, onRetry }: { pending: number; isOnline: boolean; onRetry: () => void }) { - if (pending === 0 && isOnline) { - return ( - - - Synced - - ); - } - const label = pending > 0 ? `${pending} set${pending === 1 ? '' : 's'} saved locally` : 'Offline'; - return ( - - ); -} - -interface QueueItem { key: string; name: string; detail: string; athleteId: string | null } - -function QueueCard({ - title, - empty, - tone, - items, - onSelect, -}: { - title: string; - empty: string; - tone: keyof typeof TONE_CLASS; - items: QueueItem[]; - onSelect: (sessionId: string) => void; -}) { - return ( - - -

{title}

- - {items.length} - -
- - {items.length === 0 ? ( -

{empty}

- ) : ( -
    - {items.map((it) => ( -
  • - -
  • - ))} -
- )} -
-
- ); -} - -// ---------------------------------------------------------------------------- -// Bottom drawer — the per-athlete command surface. Houses the set logger and the -// seven live actions, each optimistic-with-rollback via runOptimistic. -// ---------------------------------------------------------------------------- - -type RunOptimistic = ( - mutate: (athletes: BaseballLiveAthleteRow[]) => BaseballLiveAthleteRow[], - action: () => Promise<{ success: boolean; error?: string }>, - okMsg: string, -) => void; - -interface BufferedSetInput { - sessionId: string; - sessionExerciseId: string; - setNumber: number; - actualReps: number | null; - actualLoad: number | null; - loadUnit: string | null; - rpe: number | null; -} - -type ApplyLoggedSet = ( - mutate: (athletes: BaseballLiveAthleteRow[]) => BaseballLiveAthleteRow[], - set: BufferedSetInput, -) => void; - -function AthleteDrawer({ - athlete, - playerName, - exerciseLibrary, - onClose, - runOptimistic, - applyLoggedSet, -}: { - athlete: BaseballLiveAthleteRow; - playerName: string; - exerciseLibrary: Array<{ id: string; name: string; category: string | null }>; - onClose: () => void; - runOptimistic: RunOptimistic; - applyLoggedSet: ApplyLoggedSet; -}) { - const [activeExId, setActiveExId] = useState( - athlete.exercises.find((e) => e.exercise_name === athlete.current_exercise)?.session_exercise_id - ?? athlete.exercises[0]?.session_exercise_id - ?? null, - ); - const activeEx = athlete.exercises.find((e) => e.session_exercise_id === activeExId) ?? null; - const prefersReducedMotion = useReducedMotion(); - - return ( - -
- {/* Drawer header */} -
-
-

{playerName}

- {athlete.primary_position ?? '—'} - - -
- -
- - {athlete.readiness_reasons.length > 0 && ( -

{athlete.readiness_reasons.slice(0, 2).join(' · ')}

- )} - -
- {/* Exercise list (stations) */} -
- {athlete.exercises.length === 0 ? ( -

No exercises in this session.

- ) : ( - athlete.exercises.map((ex) => ( - - )) - )} -
- - {/* Active exercise: set logger + load/sub actions */} -
- {activeEx ? ( - - ) : ( -

Select an exercise to log sets.

- )} -
-
- - {/* Athlete-level actions (mark limited / message / task) */} - -
-
- ); -} - -function ExercisePanel({ - athlete, - ex, - exerciseLibrary, - runOptimistic, - applyLoggedSet, -}: { - athlete: BaseballLiveAthleteRow; - ex: BaseballLiveExerciseRow; - exerciseLibrary: Array<{ id: string; name: string; category: string | null }>; - runOptimistic: RunOptimistic; - applyLoggedSet: ApplyLoggedSet; -}) { - const nextSet = (ex.sets.at(-1)?.set_number ?? 0) + 1; - const [reps, setReps] = useState(ex.prescribed_reps != null ? String(ex.prescribed_reps) : ''); - const [load, setLoad] = useState(ex.prescribed_load != null ? String(ex.prescribed_load) : ''); - const [rpe, setRpe] = useState(''); - const [adjLoad, setAdjLoad] = useState(ex.prescribed_load != null ? String(ex.prescribed_load) : ''); - const [adjReason, setAdjReason] = useState(''); - const [subOpen, setSubOpen] = useState(false); - const [subId, setSubId] = useState(''); - const [subReason, setSubReason] = useState(''); - - function logSet() { - const repsN = reps ? Number(reps) : null; - const loadN = load ? Number(load) : null; - const rpeN = rpe ? Number(rpe) : null; - if (repsN == null && loadN == null) { - toast.error('Enter reps or load before logging.'); - return; - } - // Offline-safe: the set is applied optimistically AND durably buffered. It - // is NOT rolled back on a network failure — the buffer replays it on - // reconnect (logSetResult upserts on (exercise,set), so replay is safe). - applyLoggedSet( - (athletes) => - athletes.map((a) => - a.session_id !== athlete.session_id - ? a - : { - ...a, - last_update: new Date().toISOString(), - actual_load: loadN != null ? Math.max(a.actual_load ?? 0, loadN) : a.actual_load, - rpe: rpeN ?? a.rpe, - exercises: a.exercises.map((e) => - e.session_exercise_id !== ex.session_exercise_id - ? e - : { - ...e, - sets_logged: e.sets_logged + 1, - sets: [ - ...e.sets, - { - set_number: nextSet, - prescribed_reps: e.prescribed_reps, - actual_reps: repsN, - prescribed_load: e.prescribed_load, - actual_load: loadN, - load_unit: e.prescribed_load_unit ?? 'lb', - rpe: rpeN, - velocity: null, - coach_observed: true, - completed_at: new Date().toISOString(), - player_note: null, - }, - ], - }, - ), - }, - ), - { - sessionId: athlete.session_id, - sessionExerciseId: ex.session_exercise_id, - setNumber: nextSet, - actualReps: repsN, - actualLoad: loadN, - loadUnit: ex.prescribed_load_unit ?? 'lb', - rpe: rpeN, - }, - ); - setRpe(''); - } - - function adjustLoad() { - const loadN = adjLoad ? Number(adjLoad) : null; - if (loadN == null) { - toast.error('Enter the new load.'); - return; - } - if (!adjReason.trim()) { - toast.error('Add a reason for the change.'); - return; - } - runOptimistic( - (athletes) => - athletes.map((a) => - a.session_id !== athlete.session_id - ? a - : { - ...a, - has_load_change: true, - exercises: a.exercises.map((e) => - e.session_exercise_id !== ex.session_exercise_id - ? e - : { ...e, prescribed_load: loadN, was_modified: true, modification_reason: adjReason }, - ), - }, - ), - () => - modifySessionExercise({ - sessionExerciseId: ex.session_exercise_id, - prescribedLoad: loadN, - reason: adjReason, - }), - 'Load adjusted.', - ); - setAdjReason(''); - } - - function substitute() { - if (!subId) { - toast.error('Pick a substitute exercise.'); - return; - } - if (!subReason.trim()) { - toast.error('Add a reason for the substitution.'); - return; - } - const subName = exerciseLibrary.find((e) => e.id === subId)?.name ?? 'Exercise'; - runOptimistic( - (athletes) => - athletes.map((a) => - a.session_id !== athlete.session_id - ? a - : { - ...a, - has_load_change: true, - exercises: a.exercises.map((e) => - e.session_exercise_id !== ex.session_exercise_id - ? e - : { ...e, exercise_name: subName, exercise_id: subId, status: 'substituted', was_modified: true, modification_reason: `Substituted: ${subReason}` }, - ), - }, - ), - () => - substituteSessionExercise({ - sessionExerciseId: ex.session_exercise_id, - newExerciseId: subId, - reason: subReason, - }), - `Swapped to ${subName}.`, - ); - setSubOpen(false); - setSubReason(''); - setSubId(''); - } - - function markObserved() { - if (ex.sets_logged === 0) { - toast.error('No logged set to mark observed yet.'); - return; - } - runOptimistic( - (athletes) => athletes, // no visible field change; truth re-syncs on refresh - () => markExerciseObserved({ sessionExerciseId: ex.session_exercise_id }), - 'Marked form observed.', - ); - } - - return ( -
- {/* Prescription summary */} -
- {ex.exercise_name} - - {ex.prescribed_sets ?? '—'}×{ex.prescribed_reps ?? '—'} - {ex.prescribed_load != null ? ` @ ${ex.prescribed_load} ${ex.prescribed_load_unit ?? 'lb'}` : ''} - {ex.prescribed_rpe != null ? ` · RPE ${ex.prescribed_rpe}` : ''} - - {ex.was_modified && ex.modification_reason && ( - - {ex.modification_reason} - - )} -
- - {/* Logged sets */} - {ex.sets.length > 0 && ( -
- {ex.sets.map((s) => ( - - #{s.set_number} - {s.actual_reps ?? '—'}×{s.actual_load ?? '—'} - {s.rpe != null ? ` @${s.rpe}` : ''} - {s.coach_observed && } - - ))} -
- )} - - {/* Set logger (spec L564) */} -
- - - - -
- - {/* Adjust next-set load + substitute + observe */} -
- -
- setAdjReason(e.target.value)} - placeholder="reason" - aria-label="Reason for load adjustment" - className="h-[42px] rounded-xl border border-warm-200 bg-cream-50 px-3 text-sm text-warm-800 placeholder:text-warm-400 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - /> -
- - - -
- - {subOpen && ( -
-
- - setSubId(e.target.value)} - className="h-[42px] min-w-[200px] rounded-xl border border-warm-200 px-3 text-sm text-warm-800 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - > - - {exerciseLibrary.map((e) => ( - - ))} - -
-
- setSubReason(e.target.value)} - placeholder="reason (e.g. shoulder soreness)" - aria-label="Reason for substitution" - className="h-[42px] rounded-xl border border-warm-200 bg-cream-50 px-3 text-sm text-warm-800 placeholder:text-warm-400 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - /> -
- -
- )} -
- ); -} - -function AthleteActions({ - athlete, - playerName, - runOptimistic, - onClose, -}: { - athlete: BaseballLiveAthleteRow; - playerName: string; - runOptimistic: RunOptimistic; - onClose: () => void; -}) { - const [msgOpen, setMsgOpen] = useState(false); - const [msg, setMsg] = useState(''); - const [taskOpen, setTaskOpen] = useState(false); - const [taskTitle, setTaskTitle] = useState(''); - - function markLimited() { - runOptimistic( - (athletes) => - athletes.map((a) => - a.session_id === athlete.session_id ? { ...a, availability_status: 'limited', needs_coach: true } : a, - ), - () => setAvailabilityStatus({ playerId: athlete.player_id, status: 'limited', reasonCategory: 'soreness' }), - `${playerName} marked limited.`, - ); - } - - function sendMessage() { - if (!msg.trim()) { - toast.error('Type a message first.'); - return; - } - runOptimistic( - (athletes) => athletes, - () => sendLiftQuickMessage({ playerId: athlete.player_id, message: msg }), - 'Message sent.', - ); - setMsg(''); - setMsgOpen(false); - } - - function createTask() { - if (!taskTitle.trim()) { - toast.error('Add a task title.'); - return; - } - runOptimistic( - (athletes) => athletes, - () => - createLiftFollowupTask({ - title: taskTitle, - description: `Follow-up for ${playerName} from the weight room.`, - }), - 'Follow-up task created.', - ); - setTaskTitle(''); - setTaskOpen(false); - } - - return ( -
- - - - - Full profile → - - - {msgOpen && ( -
-
- setMsg(e.target.value)} - placeholder={`Message to ${playerName}…`} - aria-label={`Quick message to ${playerName}`} - className="h-[42px] rounded-xl border border-warm-200 bg-cream-50 px-3 text-sm text-warm-800 placeholder:text-warm-400 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - onKeyDown={(e) => e.key === 'Enter' && sendMessage()} - /> -
- -
- )} - - {taskOpen && ( -
-
- setTaskTitle(e.target.value)} - placeholder={`e.g. Re-test ${playerName}'s back squat next week`} - aria-label="Follow-up task title" - className="h-[42px] rounded-xl border border-warm-200 bg-cream-50 px-3 text-sm text-warm-800 placeholder:text-warm-400 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - onKeyDown={(e) => e.key === 'Enter' && createTask()} - /> -
- -
- )} -
- ); -} - -function Field({ - label, - value, - onChange, - placeholder, -}: { - label: string; - value: string; - onChange: (v: string) => void; - placeholder?: string; -}) { - const fieldId = useId(); - return ( -
- - onChange(e.target.value)} - placeholder={placeholder} - className="h-[42px] rounded-xl border border-warm-200 bg-cream-50 px-3 text-sm text-warm-800 placeholder:text-warm-400 focus-visible:border-primary-400 focus-visible:ring-2 focus-visible:ring-primary-500/40" - /> -
- ); -} diff --git a/src/components/baseball/performance/PlayerLiftHomeClient.tsx b/src/components/baseball/performance/PlayerLiftHomeClient.tsx deleted file mode 100644 index 3e62cf886..000000000 --- a/src/components/baseball/performance/PlayerLiftHomeClient.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -// ============================================================================= -// src/components/baseball/performance/PlayerLiftHomeClient.tsx -// -// V11 Player Lift Home (spec L467-477). Fast, obvious: today's lift title + -// status + a Start button, a readiness check-in prompt, and a history preview. -// Cream/green palette, reuses Card / EmptyState. No staff data shown. -// ============================================================================= - -import { useState } from 'react'; -import Link from 'next/link'; -import { motion, useReducedMotion } from 'framer-motion'; - -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { EmptyState } from '@/components/ui/empty-state'; -import { IconHeart, IconDumbbell, IconChevronRight } from '@/components/icons'; -import type { BaseballLiftSessionRow } from '@/lib/types/baseball-lifting-v11'; -import { - LiftOnboardingGate, - LiftOnboardingFlow, - LiftLabWelcomeState, -} from '@/components/baseball/performance/lift-onboarding'; - -interface Props { - upcoming: BaseballLiftSessionRow[]; - recent: BaseballLiftSessionRow[]; - readinessSubmittedToday: boolean; - /** - * Task C — Lift Lab entry + onboarding (additive). True when the athlete - * has zero prior Lab activity: gates the first-run tour AND swaps the - * bare "No lift today" empty state for a branded welcome. Defaults false - * so any caller that doesn't pass it keeps today's exact behavior. - */ - isNewToLab?: boolean; - /** helm_lifting_athletes.id — only needed when isNewToLab is true. */ - athleteId?: string | null; -} - -const STATUS_LABEL: Record = { - assigned: 'Not started', - started: 'In progress', - modified: 'Modified by coach', - completed: 'Complete', -}; - -function formatDate(d: string): string { - return new Date(d + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); -} - -export function PlayerLiftHomeClient({ - upcoming, - recent, - readinessSubmittedToday, - isNewToLab = false, - athleteId = null, -}: Props) { - const today = new Date().toISOString().slice(0, 10); - const todaysSession = upcoming.find((s) => s.scheduled_date === today) ?? null; - const prefersReducedMotion = useReducedMotion(); - // "Retake the welcome tour" (from LiftLabWelcomeState) re-opens the same - // first-run flow on demand — separate from the auto-shown-once gate below, - // which only fires automatically on a genuine first visit. - const [manualTourOpen, setManualTourOpen] = useState(false); - - return ( - - {isNewToLab ? ( - - ) : null} - {manualTourOpen ? ( - setManualTourOpen(false)} onSkip={() => setManualTourOpen(false)} /> - ) : null} - -
-

Strength & conditioning

-

Lift

-

What to do today, log your sets, and track progress.

-
- - {/* Readiness prompt */} - {!readinessSubmittedToday && ( - - -
- - - -
-

Daily check-in

-

Tell the staff how you feel before training.

-
-
- - Check in - -
-
- )} - - {/* Today's lift */} - - -

Today

-
- - {todaysSession ? ( -
-
-

{todaysSession.title ?? 'Lift'}

-

- {STATUS_LABEL[todaysSession.status] ?? todaysSession.status} - {todaysSession.estimated_minutes ? ` · ~${todaysSession.estimated_minutes} min` : ''} -

-
- - {todaysSession.status === 'started' ? 'Continue' : 'Start'} - -
- ) : isNewToLab ? ( - setManualTourOpen(true)} /> - ) : ( - } - title="No lift today" - description="You have no scheduled lift today. Rest up and recover — your coach will publish your next session here." - /> - )} -
-
- - {/* Upcoming */} - {upcoming.filter((s) => s.scheduled_date !== today).length > 0 && ( - -

Upcoming

- -
    - {upcoming.filter((s) => s.scheduled_date !== today).map((s) => ( -
  • - -
    -

    {s.title ?? 'Lift'}

    -

    {formatDate(s.scheduled_date)}

    -
    - - View - - - -
  • - ))} -
-
-
- )} - - {/* History */} - -

Recent

- - {recent.length === 0 ? ( - } - title="No completed lifts yet" - description="Once you finish a session, it'll show up here so you can review what you logged." - /> - ) : ( -
    - {recent.map((s) => ( -
  • - -
    -

    {s.title ?? 'Lift'}

    -

    {s.completed_at ? formatDate(s.completed_at.slice(0, 10)) : formatDate(s.scheduled_date)}

    -
    - - Review - - - -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/src/components/baseball/performance/PlayerLiftSessionClient.tsx b/src/components/baseball/performance/PlayerLiftSessionClient.tsx deleted file mode 100644 index 4e9fa04ae..000000000 --- a/src/components/baseball/performance/PlayerLiftSessionClient.tsx +++ /dev/null @@ -1,616 +0,0 @@ -'use client'; - -// ============================================================================= -// src/components/baseball/performance/PlayerLiftSessionClient.tsx -// -// V11 player lift execution (spec "During lift" L487-499 + "After lift" L501-510). -// Readiness gate -> section-by-section set logging (weight / reps / RPE) -> -// completion summary. Reuses Card; cream/green; sticky bottom action on mobile. -// -// A4 — offline buffer via useLiveSetSync (STORAGE_KEY='baseball.playerLift.pendingSets.v1', -// distinct from the coach live-weight-room key). Sets are queued durably in -// localStorage and flushed on reconnect — optimistic UI marks them saved -// immediately so the player is never blocked by a bad connection. -// NOTE: the buffer key is a documentation distinction; the underlying -// live-set-offline-buffer module uses 'baseball.liveWeightRoom.pendingSets.v1'. -// Separate key support would require modifying that module (separate ownership). -// In practice coach and player sessions are different auth users so collisions -// are effectively zero. -// -// H1 — IncrementButton (+5/-5) per load field. Load prefills from the previous -// logged set of this exercise in the session (else prescribed_load). A -// "Repeat" button per unsaved row copies the last set's weight+reps. -// -// H2 — completeLiftSession returns top set + RPE average. Completion panel shows -// "N sets · Top set: Name W×R · RPE avg X.X". prCount > 0 renders a -// motion.div scale-spring "New PR" badge; useReducedMotion skips animation. -// ============================================================================= - -import { useCallback, useMemo, useState, useTransition } from 'react'; -import Link from 'next/link'; -import { motion, useReducedMotion } from 'framer-motion'; - -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { IconCheckCircle2, IconAlertCircle, IconArrowLeft } from '@/components/icons'; -import { - logSetResult, - startLiftSession, - completeLiftSession, -} from '@/app/baseball/actions/lifting-v11'; -import type { TopSet } from '@/app/baseball/actions/lifting-v11'; -import { useLiveSetSync } from '@/lib/baseball/lifting/use-live-set-sync'; -import type { BaseballLiftSessionWithExercises } from '@/lib/types/baseball-lifting-v11'; - -interface Props { - session: BaseballLiftSessionWithExercises; - readinessSubmittedToday: boolean; -} - -interface DraftSet { - reps: string; - load: string; - rpe: string; -} - -export function PlayerLiftSessionClient({ session, readinessSubmittedToday }: Props) { - const [isPending, startTransition] = useTransition(); - const prefersReducedMotion = useReducedMotion(); - const [status, setStatus] = useState(session.status); - const [savedSets, setSavedSets] = useState>>(() => { - const m: Record> = {}; - for (const ex of session.exercises) m[ex.id] = new Set(ex.sets.map((s) => s.set_number)); - return m; - }); - const [error, setError] = useState(null); - const [completionResult, setCompletionResult] = useState<{ - prCount: number; - topSet: TopSet | null; - rpeAverage: number | null; - } | null>(null); - - // A4: Offline-safe set capture. Wrapping logSetResult in useCallback prevents - // the flush loop from reinstating on every render. The buffer key documented in - // the file header distinguishes this from the coach live-weight-room buffer. - const setSync = useLiveSetSync( - useCallback( - (input) => - logSetResult({ - sessionId: input.sessionId, - sessionExerciseId: input.sessionExerciseId, - setNumber: input.setNumber, - actualReps: input.actualReps, - actualLoad: input.actualLoad, - loadUnit: input.loadUnit, - rpe: input.rpe, - }), - [], - ), - ); - - // Group session exercises by section (snapshot, no template math). - const sections = useMemo(() => { - const map = new Map(); - for (const ex of session.exercises) { - const key = ex.section_name_snapshot ?? 'Workout'; - const arr = map.get(key) ?? []; - arr.push(ex); - map.set(key, arr); - } - return Array.from(map.entries()); - }, [session]); - - const totalSets = useMemo( - () => session.exercises.reduce((n, ex) => n + (ex.prescribed_sets ?? 1), 0), - [session.exercises], - ); - const doneSets = useMemo( - () => Object.values(savedSets).reduce((n, s) => n + s.size, 0), - [savedSets], - ); - - function handleStart() { - setError(null); - startTransition(async () => { - const r = await startLiftSession({ sessionId: session.id }); - if (r.success) setStatus('started'); - else setError(r.error ?? 'Could not start session.'); - }); - } - - // A4: Optimistically mark the set as saved, then queue-and-flush via the - // offline buffer. No await — the set is durably stored before the server call. - function handleLogSet(exId: string, setNumber: number, draft: DraftSet, loadUnit: string | null) { - // Optimistic UI update: the set appears saved immediately. - setSavedSets((prev) => { - const updated = new Set(prev[exId] ?? []); - updated.add(setNumber); - return { ...prev, [exId]: updated }; - }); - // Durable buffer + flush. Upsert on (session_exercise_id, set_number) on the - // server makes replay safe; the buffer retries on reconnect until confirmed. - setSync.queueAndFlush({ - sessionId: session.id, - sessionExerciseId: exId, - setNumber, - actualReps: draft.reps ? Number(draft.reps) : null, - actualLoad: draft.load ? Number(draft.load) : null, - loadUnit: draft.load ? (loadUnit ?? 'lb') : null, - rpe: draft.rpe ? Number(draft.rpe) : null, - }); - } - - function handleComplete() { - setError(null); - startTransition(async () => { - const r = await completeLiftSession({ sessionId: session.id }); - if (r.success) { - // H2: capture the extended completion stats for the summary panel. - setCompletionResult({ - prCount: r.prCount ?? 0, - topSet: r.topSet ?? null, - rpeAverage: r.rpeAverage ?? null, - }); - setStatus('completed'); - } else { - setError(r.error ?? 'Could not complete session.'); - } - }); - } - - // H2: Completion panel with top-set + RPE summary and optional PR badge. - if (status === 'completed') { - const prCount = completionResult?.prCount ?? 0; - const topSet = completionResult?.topSet ?? null; - const rpeAvg = completionResult?.rpeAverage ?? null; - - return ( - - - - - - - -

Lift complete

- {/* H2: "N sets · Top set: Name W×R · RPE avg X.X" */} -

- {doneSets} sets - {topSet && ( - <> - {' · '} - - Top set: {topSet.name} {topSet.load} - {topSet.unit}×{topSet.reps} - - - )} - {rpeAvg !== null && ( - <> - {' · '} - RPE avg {rpeAvg.toFixed(1)} - - )} -

- {/* H2: PR badge — scale-spring, useReducedMotion guard, no confetti. */} - {prCount > 0 && ( - - New PR{prCount > 1 ? ` (${prCount})` : ''} - - )} -
- - Back to Lift - -
-
-
-
- ); - } - - return ( - - -
-

{session.title ?? 'Lift'}

-
-

- {doneSets} / {totalSets} sets - {' · '} - {status === 'started' ? 'in progress' : 'not started'} -

- {/* A4: Offline status pill — visible only when there are pending sets or offline. */} - -
- {/* Progress bar — calm, transform-free fill */} -
- 0 ? Math.round((doneSets / totalSets) * 100) : 0}%` }} - transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.4, ease: [0.22, 1, 0.36, 1] }} - /> -
-
- - {/* Readiness gate (spec L480-485) */} - {!readinessSubmittedToday && ( - - -

Complete your daily check-in first.

- - Check in - -
-
- )} - - {error && ( -
- - {error} -
- )} - - {status === 'assigned' && ( - - )} - - {sections.map(([sectionName, exercises]) => ( - -

{sectionName}

- - {exercises.map((ex) => ( - - handleLogSet(ex.id, setNumber, draft, ex.prescribed_load_unit) - } - /> - ))} - -
- ))} - - {/* Sticky complete action on mobile */} - {status !== 'assigned' && ( -
- -
- )} -
- ); -} - -// ============================================================================= -// BackLink -// ============================================================================= - -function BackLink() { - return ( - - - Back to Lift - - ); -} - -// ============================================================================= -// A4: OfflinePill — surfaces pending-set count and connectivity to the player. -// Matches the SyncPill pattern from LiveWeightRoom but simplified for the player -// surface (no retry spinner; tap-to-retry is the affordance). -// ============================================================================= - -function OfflinePill({ - pending, - isOnline, - onRetry, -}: { - pending: number; - isOnline: boolean; - onRetry: () => void; -}) { - if (pending === 0 && isOnline) return null; - return ( - - ); -} - -// ============================================================================= -// H1: IncrementButton — +N / -N tap target for the load field. -// min-h-[44px] ensures accessibility touch target size per WCAG 2.5.5. -// ============================================================================= - -function IncrementButton({ - delta, - onPress, - disabled, -}: { - delta: number; - onPress: () => void; - disabled: boolean; -}) { - return ( - - ); -} - -// ============================================================================= -// ExerciseBlock — one block per session exercise. -// -// H1 additions: -// * IncrementButton +5 / -5 flanking the load input. -// * Load + reps prefill: starts at prescribed values; updates after each logged -// set so the next set auto-fills from the previous actual values. -// * "Repeat" button per unsaved set (when at least one set has been logged): -// copies the last-logged weight + reps into this set's draft. -// ============================================================================= - -function ExerciseBlock({ - name, - prescribedSets, - prescribedReps, - prescribedLoad, - prescribedLoadUnit, - modified, - savedSetNumbers, - disabled, - onLog, -}: { - name: string; - prescribedSets: number; - prescribedReps: number | null; - prescribedLoad: number | null; - prescribedLoadUnit: string | null; - modified: boolean; - savedSetNumbers: Set; - disabled: boolean; - onLog: (setNumber: number, draft: DraftSet) => void; -}) { - const [drafts, setDrafts] = useState>({}); - // H1: last-logged values for prefill and repeat. Initialized from prescribed - // values so the very first set is pre-populated, then updated after each log. - const [lastLoad, setLastLoad] = useState( - prescribedLoad != null ? String(prescribedLoad) : '', - ); - const [lastReps, setLastReps] = useState( - prescribedReps != null ? String(prescribedReps) : '', - ); - - const hasPriorLoggedSet = savedSetNumbers.size > 0; - - return ( -
-
-

{name}

- {modified && ( - - Modified - - )} -
-

- Target: {prescribedSets} × {prescribedReps ?? '—'} - {prescribedLoad != null ? ` @ ${prescribedLoad}${prescribedLoadUnit ?? 'lb'}` : ''} -

-
- {Array.from({ length: prescribedSets }, (_, i) => i + 1).map((setNum) => { - const saved = savedSetNumbers.has(setNum); - // H1 prefill: uninitialized draft uses last-logged values (or prescribed). - const d: DraftSet = drafts[setNum] ?? { reps: lastReps, load: lastLoad, rpe: '' }; - const isInputDisabled = disabled || saved; - - return ( -
- {/* Row 1: Set label + reps + ±5 + load + ±5 */} -
- - Set {setNum} - - {/* Reps */} - - setDrafts((p) => ({ ...p, [setNum]: { ...d, reps: e.target.value } })) - } - disabled={isInputDisabled} - className="w-16 rounded-lg border border-warm-200 bg-cream-50 px-2 py-1.5 text-sm text-warm-900 placeholder:text-warm-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:opacity-60" - /> - {/* H1: Load with +5/-5 steppers */} - - setDrafts((p) => { - const curr = p[setNum] ?? { reps: lastReps, load: lastLoad, rpe: '' }; - const newLoad = Math.max(0, Number(curr.load || '0') - 5); - return { ...p, [setNum]: { ...curr, load: String(newLoad) } }; - }) - } - /> - - setDrafts((p) => ({ ...p, [setNum]: { ...d, load: e.target.value } })) - } - disabled={isInputDisabled} - className="w-16 rounded-lg border border-warm-200 bg-cream-50 px-2 py-1.5 text-sm text-warm-900 placeholder:text-warm-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:opacity-60" - /> - - setDrafts((p) => { - const curr = p[setNum] ?? { reps: lastReps, load: lastLoad, rpe: '' }; - const newLoad = Number(curr.load || '0') + 5; - return { ...p, [setNum]: { ...curr, load: String(newLoad) } }; - }) - } - /> -
- {/* Row 2: RPE + Log (indented past the Set label) */} -
- {/* RPE */} - - setDrafts((p) => ({ ...p, [setNum]: { ...d, rpe: e.target.value } })) - } - disabled={isInputDisabled} - className="w-16 rounded-lg border border-warm-200 bg-cream-50 px-2 py-1.5 text-sm text-warm-900 placeholder:text-warm-400 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-400 disabled:opacity-60" - /> - -
- {/* H1: Repeat-last-set affordance — shown on unsaved rows once there's - a prior logged set to copy from. Copies weight + reps only. */} - {!saved && hasPriorLoggedSet && (lastLoad || lastReps) && ( -
- -
- )} -
- ); - })} -
-
- ); -} diff --git a/src/components/baseball/performance/PlayerLiftToday.tsx b/src/components/baseball/performance/PlayerLiftToday.tsx index 8cb8d705f..1e1989490 100644 --- a/src/components/baseball/performance/PlayerLiftToday.tsx +++ b/src/components/baseball/performance/PlayerLiftToday.tsx @@ -7,25 +7,27 @@ // (PlayerTodayClient). It is a clean DEFAULT export so the integration phase can // drop it straight in without touching this file. // -// UNIFIED STORAGE (V11): this card reads the SAME materialized -// baseball_lift_sessions rows that publishLiftDay writes — the identical source -// the dedicated player lift route (/baseball/dashboard/lift via getPlayerLiftHome) -// and the CoachHelm engine (loaders-v10) consume. The previous version read the -// legacy baseball_lift_assignments island, so a lift built+published through the -// V11 program builder never appeared here and the AI never analyzed it. That -// island is closed: publish -> materialized session -> set logging -> PRs is now -// one loop visible on Today, on the lift route, and to the engine. +// UNIFIED STORAGE (helm_lifting_*, ONE Lift Lab): this card reads the SAME +// materialized helm_lifting_sessions rows that publishProgram / publishLiftDay +// write — the identical source the dedicated player lift route +// (/baseball/dashboard/lift via getPlayerLiftHome, now backed by +// helm_lifting_sessions via the baseball-view-adapter) and the CoachHelm +// engine (loaders-v10) consume. The legacy baseball_lift_* / baseball_lift_ +// assignments tables are write-dead — publish -> materialized session -> set +// logging -> PRs is one loop visible on Today, on the lift route, and to the +// engine. // // The card itself is an honest daily-loop SUMMARY + launcher: it lists today's // (and overdue, not-yet-completed) sessions with status, and routes each to the // dedicated session screen (/baseball/dashboard/lift/[sessionId]) where the full // per-set logging surface already lives (one place for execution, not two). The -// daily readiness check-in writes the shared baseball_readiness_checkins table. +// daily readiness check-in writes the shared helm_lifting_readiness_checkins +// table. // // Players can only ever see/log their OWN sessions and readiness — the RLS -// policies on baseball_lift_sessions / baseball_readiness_checkins make that -// structural; the client typing is loosened only because these tables are not in -// the generated database.ts yet. +// policies on helm_lifting_sessions / helm_lifting_readiness_checkins make +// that structural; the client typing is loosened only because these tables +// are not in the generated database.ts yet. // ============================================================================= import { useCallback, useEffect, useState } from 'react'; @@ -363,7 +365,7 @@ export default function PlayerLiftToday({ - {/* Daily readiness check-in (shared baseball_readiness_checkins). */} + {/* Daily readiness check-in (shared helm_lifting_readiness_checkins). */}
diff --git a/src/components/baseball/performance/ProgramEditorClient.tsx b/src/components/baseball/performance/ProgramEditorClient.tsx deleted file mode 100644 index a6d356f95..000000000 --- a/src/components/baseball/performance/ProgramEditorClient.tsx +++ /dev/null @@ -1,1252 +0,0 @@ -'use client'; - -// ============================================================================= -// src/components/baseball/performance/ProgramEditorClient.tsx -// -// V11 Program editor (spec L200-228 + Packet E). Wires the full orphaned authoring -// layer to the deep, already-built server actions in lifting-v11.ts: -// createLiftProgram* / updateLiftProgram / addLiftWeek / duplicateLiftWeek / -// deleteLiftWeek / addLiftDay / updateLiftDay / duplicateLiftDay / deleteLiftDay / -// addLiftSection / updateLiftSection / reorderLiftSections / deleteLiftSection / -// addLiftPrescription / updateLiftPrescription / reorderLiftPrescriptions / -// deleteLiftPrescription / saveProgramAsTemplate / publishLiftDay. -// (*createLiftProgram is called from the list; the rest are wired here.) -// -// IA: the editor is composed for the coach's primary task — author a day's work -// fast. Left rail = macrocycle tree (weeks -> days, the navigation spine). Right = -// the SELECTED day's section/prescription editor (the dense work surface). One -// prominent primary action per context (Assign & publish on a day). -// -// Drag-drop reorder for sections + prescriptions, with a keyboard alternative -// (Move up / Move down buttons) so reordering never requires a mouse (a11y). -// -// Cream/green GolfHelm palette only. Every write goes through a withBaseballAction -// server action (capability-enforced server-side) + RLS; this client only offers -// affordances. No destructive bulk writes — deletes are explicit single-node. -// ============================================================================= - -import { useCallback, useMemo, useRef, useState, useTransition } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { motion, useReducedMotion } from 'framer-motion'; - -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { Select } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { Modal } from '@/components/ui/modal'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { EmptyState } from '@/components/ui/empty-state'; -import { - IconPlus, IconTrash, IconCopy, IconChevronDown, IconChevronUp, - IconClock, IconClipboardList, -} from '@/components/icons'; -import { - updateLiftProgram, - addLiftWeek, duplicateLiftWeek, deleteLiftWeek, - addLiftDay, updateLiftDay, duplicateLiftDay, deleteLiftDay, - addLiftSection, updateLiftSection, reorderLiftSections, deleteLiftSection, - addLiftPrescription, updateLiftPrescription, reorderLiftPrescriptions, deleteLiftPrescription, - saveProgramAsTemplate, publishLiftDay, -} from '@/app/baseball/actions/lifting-v11'; -import { updateProgramBlockOrder } from '@/app/baseball/actions/program-settings'; -import type { - LiftProgramTree, LiftWeekNode, LiftDayNode, LiftSectionNode, - LiftPrescriptionNode, AssignContext, -} from '@/lib/baseball/read-models/lift-programs'; -import type { - BaseballLiftProgramPhase, BaseballLiftProgramGoal, BaseballLiftProgramStatus, - BaseballLiftDayType, BaseballLiftBaseballContext, BaseballLiftSectionType, - BaseballLiftPrescriptionType, BaseballLiftAssignmentTargetType, -} from '@/lib/types/baseball-lifting-v11'; - -// ----------------------------------------------------------------------------- -// Vocabularies (mirror the migration CHECK constraints + action schemas) -// ----------------------------------------------------------------------------- - -const PHASE_OPTIONS: Array<{ value: BaseballLiftProgramPhase; label: string }> = [ - { value: 'fall', label: 'Fall' }, { value: 'winter', label: 'Winter' }, - { value: 'preseason', label: 'Preseason' }, { value: 'in_season', label: 'In-season' }, - { value: 'postseason', label: 'Postseason' }, { value: 'summer', label: 'Summer' }, - { value: 'return_to_play', label: 'Return to play' }, { value: 'testing', label: 'Testing' }, -]; -const GOAL_OPTIONS: Array<{ value: BaseballLiftProgramGoal; label: string }> = [ - { value: 'strength', label: 'Strength' }, { value: 'power', label: 'Power' }, - { value: 'hypertrophy', label: 'Hypertrophy' }, { value: 'speed', label: 'Speed' }, - { value: 'maintenance', label: 'Maintenance' }, { value: 'recovery', label: 'Recovery' }, - { value: 'arm_care', label: 'Arm care' }, { value: 'testing', label: 'Testing' }, -]; -const STATUS_OPTIONS: Array<{ value: BaseballLiftProgramStatus; label: string }> = [ - { value: 'draft', label: 'Draft' }, { value: 'active', label: 'Active' }, - { value: 'archived', label: 'Archived' }, -]; -const DAY_TYPE_OPTIONS: Array<{ value: BaseballLiftDayType; label: string }> = [ - { value: 'lower', label: 'Lower' }, { value: 'upper', label: 'Upper' }, - { value: 'full_body', label: 'Full body' }, { value: 'recovery', label: 'Recovery' }, - { value: 'arm_care', label: 'Arm care' }, { value: 'conditioning', label: 'Conditioning' }, - { value: 'testing', label: 'Testing' }, { value: 'custom', label: 'Custom' }, -]; -const BASEBALL_CONTEXT_OPTIONS: Array<{ value: string; label: string }> = [ - { value: '', label: 'No baseball context' }, - { value: 'pre_game', label: 'Pre-game' }, { value: 'post_game', label: 'Post-game' }, - { value: 'bullpen_day', label: 'Bullpen day' }, { value: 'starter_plus_1', label: 'Starter +1' }, - { value: 'starter_plus_2', label: 'Starter +2' }, { value: 'travel_day', label: 'Travel day' }, - { value: 'off_day', label: 'Off day' }, { value: 'practice_day', label: 'Practice day' }, -]; -const SECTION_TYPE_OPTIONS: Array<{ value: BaseballLiftSectionType; label: string }> = [ - { value: 'warmup', label: 'Warmup' }, { value: 'movement_prep', label: 'Movement prep' }, - { value: 'power', label: 'Power' }, { value: 'main_strength', label: 'Main strength' }, - { value: 'accessory', label: 'Accessory' }, { value: 'arm_care', label: 'Arm care' }, - { value: 'mobility', label: 'Mobility' }, { value: 'conditioning', label: 'Conditioning' }, -]; -const PRESCRIPTION_TYPE_OPTIONS: Array<{ value: BaseballLiftPrescriptionType; label: string }> = [ - { value: 'fixed', label: 'Fixed load' }, { value: 'percent_1rm', label: '% of 1RM' }, - { value: 'rpe', label: 'RPE target' }, { value: 'velocity', label: 'Velocity' }, - { value: 'coach_load', label: 'Coach assigns' }, { value: 'player_select', label: 'Player selects' }, -]; - -const DAY_TYPE_LABEL = Object.fromEntries(DAY_TYPE_OPTIONS.map((o) => [o.value, o.label])); -const SECTION_TYPE_LABEL = Object.fromEntries(SECTION_TYPE_OPTIONS.map((o) => [o.value, o.label])); -const BASEBALL_CONTEXT_LABEL = Object.fromEntries( - BASEBALL_CONTEXT_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]), -); - -/** Swap two indices in a copy of the array (typesafe under noUncheckedIndexedAccess). */ -function swapped(arr: readonly T[], a: number, b: number): T[] { - const next = [...arr]; - const tmp = next[a]!; - next[a] = next[b]!; - next[b] = tmp; - return next; -} - -function GripDots() { - return ( - - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - ); -} - -// ----------------------------------------------------------------------------- -// Editor root -// ----------------------------------------------------------------------------- - -interface Props { - tree: LiftProgramTree; - assign: AssignContext; -} - -export function ProgramEditorClient({ tree, assign }: Props) { - const router = useRouter(); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - const [pending, startTransition] = useTransition(); - const prefersReducedMotion = useReducedMotion(); - - // Selected day in the editor (defaults to the first day of the first week). - const firstDayId = tree.weeks[0]?.days[0]?.id ?? null; - const [selectedDayId, setSelectedDayId] = useState(firstDayId); - - const selectedDay = useMemo<{ week: LiftWeekNode; day: LiftDayNode } | null>(() => { - for (const w of tree.weeks) { - const d = w.days.find((x) => x.id === selectedDayId); - if (d) return { week: w, day: d }; - } - return null; - }, [tree.weeks, selectedDayId]); - - // Wrap a server action: refresh on success, surface a friendly error otherwise. - const run = useCallback( - (fn: () => Promise<{ success: boolean; error?: string; id?: string }>, onOk?: (id?: string) => void) => { - setError(null); - startTransition(async () => { - try { - const res = await fn(); - if (res.success) { - onOk?.(res.id); - router.refresh(); - } else { - setError(res.error ?? 'Something went wrong.'); - } - } catch { - setError('Something went wrong. Please try again.'); - } - }); - }, - [router], - ); - - return ( - - run(() => updateLiftProgram({ programId: tree.program.id, ...patch }), onOk)} - onSaveTemplate={(name) => - run( - () => saveProgramAsTemplate({ programId: tree.program.id, name: name || null }), - () => setNotice('Saved a reusable template copy.'), - ) - } - /> - - {error && ( -
- {error} -
- )} - {notice && ( -
- {notice} -
- )} - -
- {/* Macrocycle spine */} - - run(() => - addLiftWeek({ - programId: tree.program.id, - weekNumber: (tree.weeks.at(-1)?.week_number ?? 0) + 1, - name: `Week ${(tree.weeks.at(-1)?.week_number ?? 0) + 1}`, - }), - ) - } - onDuplicateWeek={(weekId) => run(() => duplicateLiftWeek({ weekId }))} - onDeleteWeek={(weekId) => run(() => deleteLiftWeek({ id: weekId }))} - onAddDay={(week) => - run( - () => - addLiftDay({ - weekId: week.id, - dayNumber: nextDayNumber(week), - name: `Day ${nextDayNumber(week)}`, - }), - () => undefined, - ) - } - onDuplicateDay={(dayId) => run(() => duplicateLiftDay({ dayId }), (id) => id && setSelectedDayId(id))} - onDeleteDay={(dayId) => - run(() => deleteLiftDay({ id: dayId }), () => { - if (dayId === selectedDayId) setSelectedDayId(null); - }) - } - /> - - {/* Day work surface */} -
- {selectedDay ? ( - - ) : ( - - - } - title={tree.weeks.length === 0 ? 'Add your first week' : 'Pick a day to edit'} - description={ - tree.weeks.length === 0 - ? 'A program is built from weeks and lift days. Add a week to begin.' - : 'Select a day from the left, or add a day to a week, to start building sections and exercises.' - } - /> - - - )} -
-
-
- ); -} - -function nextDayNumber(week: LiftWeekNode): number { - const used = new Set(week.days.map((d) => d.day_number)); - let n = 1; - while (used.has(n) && n < 7) n++; - return n; -} - -// ----------------------------------------------------------------------------- -// Program header (editable meta + save-as-template) -// ----------------------------------------------------------------------------- - -function ProgramHeader({ - tree, pending, onSave, onSaveTemplate, -}: { - tree: LiftProgramTree; - pending: boolean; - onSave: (patch: Record, onOk?: () => void) => void; - onSaveTemplate: (name: string) => void; -}) { - const p = tree.program; - const [editing, setEditing] = useState(false); - const [name, setName] = useState(p.name); - const [description, setDescription] = useState(p.description ?? ''); - const [phase, setPhase] = useState(p.phase); - const [goal, setGoal] = useState(p.goal); - const [status, setStatus] = useState(p.status); - const [tplOpen, setTplOpen] = useState(false); - const [tplName, setTplName] = useState(`${p.name} (template)`); - - return ( - - - - - {editing ? ( -
- setName(e.target.value)} /> -