diff --git a/docs/audits/DB_FORENSIC_AUDIT_2026-07-08.md b/docs/audits/DB_FORENSIC_AUDIT_2026-07-08.md new file mode 100644 index 000000000..2c6365970 --- /dev/null +++ b/docs/audits/DB_FORENSIC_AUDIT_2026-07-08.md @@ -0,0 +1,60 @@ +# Production Database Forensic Audit — 2026-07-08 + +Shared GolfHelm + BaseballHelm Supabase project. 5 read-only auditors + Supabase +advisors. Overall grade: **C+** — structurally messy, operationally small, **zero +data corruption**. Security/hygiene debt, not integrity failure. + +## Dimension grades + +| Dimension | Grade | Verdict | +|---|---|---| +| RLS coverage | B− | 100% of 260 tables have RLS. One cross-tenant write hole (course library). | +| SECURITY DEFINER fns | C→**fixed** | Anon clean; 2 fns leaked all user emails to any authed user. **Gated 2026-07-08.** | +| Data quality | C | Zero orphans/corruption; E2E still writes into prod (`organizations` 50% junk). | +| Schema truth | C+ | 8/8 recent migrations verified applied; 27 prod migrations have no repo file; drift bugs. | +| Performance | B+ | 591 MB, 9/60 conns, cached. 694 perf advisories ~all cosmetic at this scale. | + +## Fixed in this PR (applied to prod + repo migrations) + +- **[P0] PII leak** — `get_users_with_auth()` / `get_platform_health_stats()` were + SECURITY DEFINER + authenticated-EXECUTE with no gate; any logged-in user could + dump every user's email + auth metadata. Now `is_admin()/is_super_admin()`-gated + (migration `20260708020000`). +- **[P1] Ungated RPC cluster** — `update_user_last_seen` (could overwrite any user's + timestamp) now self-or-admin; dead RPCs (`get_pending_task_reminders`, + `mark_task_reminder_sent`), CRM analytics (`get_crm_click_destinations`, + `get_crm_template_performance`), and `refresh_crm_coach_engagement` (cron uses + service_role) had authenticated EXECUTE revoked (migration `20260708021000`). +- **[P0] CSV stat upload broken** — `uploadStatsCSV` wrote `upload_batch_id` (a + column that never existed) on every insert → every upload failed. Removed the + legacy write; added the 7 real stat columns the type declared but the table + lacked (`caught_stealing`, `sacrifice_bunts`, `runs_allowed`, `pitches_thrown`, + `strikes_thrown`, `launch_angle`, `spin_rate`) additively so the DB matches the + type; type now names the 6 real `source_*`/`import_run_id` columns it was + missing (migration `20260708022000`). + +## Deferred — needs your decision (NOT changed) + +- **[P1] Course-library cross-tenant writes.** `golf_course_tee_holes` (ALL, + `USING true`), `golf_course_tees` (UPDATE, `USING true`), `golf_courses` (UPDATE, + `auth.uid() IS NOT NULL`) let any authenticated user edit/delete any school's + course data. **This may be intentional** — the library is a soft-delete, + crowd-sourced "grows-from-saves" wiki. I did not tighten it because it's a live + golf product and the open-edit model is plausibly by design. **Decision needed:** + is cross-school course editing intended (wiki model → add a server-side audit + trigger so edits are always logged) or not (→ scope writes to owner/admin)? +- Security-definer `*_public` views expose a platform-wide coach directory to any + authed user (no PII); fine at 10 teams, add `is_public` opt-in before scaling. +- 27 orphan migrations (recorded in prod, no repo file); `schema_migrations` + version/filename mismatches + 6 double-recorded — a future replay-from-scratch + hazard, not a live bug. +- `admin_events` + `error_logs` = 80% of DB size, ~1,550 rows/day, no retention. +- E2E suite writes into the shared prod DB (an "E2E Test University" org was created + 6 days before this audit). See the separate-E2E-project recommendation. + +## Healthy (verified, don't worry) + +RLS on every table. Zero FK orphans, zero childless conversations, zero +impossible/negative stats, zero future-dated rows. 160-game E2E purge held. +Graveyard tables dormant. The scary advisor counts (199 unindexed FKs, 259 unused +indexes) waste 9.6 MB total and sit on tiny tables — cosmetic at this scale. diff --git a/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md b/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md new file mode 100644 index 000000000..2bbe09a78 --- /dev/null +++ b/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md @@ -0,0 +1,195 @@ +# Production-Readiness Mission — 2026-07-09 + +> Living mission doc. Owner directive: full autonomous page-by-page UI/UX + +> architecture review; coach and player at ~8 nav tabs each; routing/shell/UI +> all on the newest systems; whole database production-ready; iterate until +> done. Orchestration: Fable plans, Sonnet executes. Ground truth from the +> 20-agent discovery sweep (2026-07-09, ~2.9M tokens) + live prod DB audit. + +## Ground truth (discovery synthesis) + +**Nav counts today** (primary+secondary rail destinations, Settings footer excluded): +| Surface | Today | Target | +|---|---|---| +| Baseball coach | **8 hubs** ✅ (Dashboard, Messages, Team, Stats & Performance, Development, Recruiting, Academics, Management) | keep | +| Baseball player | **9** (7 primary + exposureNoun + Settings-in-rail) | **8** — move Settings to pinned footer (parity with coach shell) | +| Golf coach (Fairway, live) | **15** (7 primary + 8 secondary) | **8 hubs** | +| Golf player (Fairway, live) | **12** (8 primary + 4 secondary) | **8** | + +**Shells**: Baseball = ONE unconditional `BaseballFairwayShell` ✅ (Coherence +Ruling 1). Golf = DUAL shells still live; `NEXT_PUBLIC_REDESIGN` hardcoded +true in prod + CI for 5+ weeks; ~65 golf pages ship both a legacy tree +(dead-in-prod) and a Fairway tree. Largest flag/legacy debt in the codebase. + +**Design systems in the wild**: baseball has 4 coexisting languages +(Living Annual ~28 routes; legacy cream ~50 routes incl. `player/today` — +the highest-traffic player screen; Lift Lab component library; bespoke +Entry-World login/onboarding [login is owner-approved, keep]). Golf Fairway +adoption is essentially complete on live paths, but edge/error branches and +`FeatureUnavailable` render un-gated legacy chrome; stats never got the +green ruled-leader treatment the owner asked for. + +**DB posture (live advisors 2026-07-09)**: RLS on every table ✅; 0 anon +SECURITY DEFINER ✅; types file zero drift ✅. Open: 4 ERROR +security_definer_view (the *_public views); 109 SECDEF functions EXECUTE-able +by authenticated (18 admin-sounding need body gate-checks — gap-fill running); +golf_course_tee_holes/tees always-true RLS (owner's open-edit call — REPORT +ONLY); avatars bucket public listing; 691 perf findings (multiple permissive +policies on hot golf tables, 199 unindexed FKs, 256 unused indexes). +**Migration-vs-live drift**: 8 tables named in migrations but ABSENT live +(5 baseball import-lineage + 3 coachhelm support). + +**Adversarially verified findings (gap-fill fleet, 2026-07-09)**: +- **CONFIRMED P0**: `discover.ts` `getDiscoverPlayers`/`getStateCounts` never + check `baseball_player_settings.profile_visibility` (unlike + `recruitability.ts`) — private players surface in Discover search/map. +- CONFIRMED P1: Activate-Recruiting bypass — `updatePlayer` in + `use-auth.ts:165-175` does a raw browser UPDATE; RLS (`user_id=auth.uid()`, + no column guard) lets any player set `recruiting_activated` directly, + bypassing the `recruiting_exposure_enabled` toggle. Fix = remove client + write path + BEFORE UPDATE trigger guard (baseball DB, check-first). +- CONFIRMED P1: sign-out never calls `invalidateAuthCache()` on + use-baseball-auth's 5s module cache (only sign-in does) — back-nav within + ~5s of logout re-authorizes from stale verified state. +- CONFIRMED P1: `is_anonymous` column doesn't exist on + `baseball_player_engagement_events`; 4 unchecked inserts (watchlist.ts ×3, + player-peek.ts ×1) silently 42703-fail forever — engagement events never + recorded. Fix = drop the field from those 4 call sites. +- CONFIRMED P1 (DB views): `baseball_team_coach_staff_public` ignores + `visible_to_players` + `status`; `baseball_teams_public_profile` ignores + `public_profile_mode` — anon sees data owners opted out of sharing. + Recreate views with filters (REVOKE anon re-grant after recreate; verify + relacl). +- CONFIRMED P1 (DB fn): `get_admin_event_summary(int)` has zero self-gating + and EXECUTE granted to PUBLIC/anon — gate + revoke. +- CONFIRMED P1 (email): weekly-coach-email cron has NO opt-out gate; + task-reminders cron ignores `email_task_reminders` preference. +- CONFIRMED P1 (rounds): FairwayShotTracking discards the player's METERS + unit preference (corrupts distances/proximity/GIR — unit-audit bug class + returns); new-round-client re-edit path missing `allHolesScored` refresh + (stale scorecard submit) that continue-round already has. +- CONFIRMED P1 (API): /api/account/delete fails on RESTRICT FKs for most + active coach/admin accounts. +- REFUTED: password-change reauth (changePasswordActionImpl re-authenticates + via signInWithPassword, rate-limited, tested — already correct). +- REFUTED: "8 phantom tables" — migration-filename shorthand for ADD COLUMN + sets; nothing missing live, nothing broken. +- ~12 baseball dashboard routes still client-only auth (college-interest, + camps, colleges, journey, dev-plans, program, tasks, messages, + announcements, travel, videos; comparisons returns raw string) — server + guards needed. Middleware fails open (no Sentry), golf-side capability + layer thinner than baseball's. +- PWA/native P1s: web-push server pipeline has ZERO client callers (dead); + universal links have no in-app handler (join-code/reset deep links + dropped); native shell force-bounces non-/golf/ URLs (baseball deep links + architecturally blocked); baseball dashboard inherits the GOLF manifest. +- A11y P1s: box-score grid ~40 unnamed inputs + headers without scope; + 3 of 4 command palettes lack focus trap/restore; coach InviteModal has no + dialog semantics at all; BaseballInviteButton lacks trap/restore. +- Docs: golfhelm-features.md claims "Availability Polling" done — feature + does not exist in code. Readiness matrix + gap map confirmed stale. +- Admin: Bridge (SUPER_ADMIN_USER_IDS) vs legacy /golf/admin (role='admin') + authorize two different unreconciled populations; the role='admin' + post-login redirect is copy-pasted across 4 call sites. +- Golf features vs live DB: NO baseball-class drift; 18 doc-named absent + tables all deliberately graveyarded. Rounds flow otherwise sound + (beaconPartialSave wired; honest-error patterns hold). +- P2s for W4: public player/[id] "no stats table" comment is FALSE + (baseball_player_season_stats live) but prop is dead — wire or drop; + program/[id] Facilities/Commitments = permanently-dead sections (tables + don't exist) — remove sections. + +## Target IA (~8 tabs, owner directive) + +### Baseball coach — unchanged (8 hubs) ✅ +### Baseball player — 8 (move Settings out of rail to pinned footer) +Today · Schedule · My Profile · Stats · Development · Team · Messages · [exposureNoun] + +### Golf coach — 8 hubs (adopt baseball's proven hub+subtab pattern on FairwayDashboardShell) +1. **Dashboard** (Overview; What's New folds in as card/CTA) +2. **CoachHelm AI** (Intelligence · Alerts · Insights · Patterns · Analytics — cluster activeMatch already exists) +3. **Team** (Roster · Recruiting HQ) +4. **Calendar** (Calendar · Travel) +5. **Rounds & Stats** (Rounds · Stats · Team Stats · Qualifiers) +6. **Messages** (Messages · Announcements) +7. **Operations** (Tasks · Documents) +8. **Courses** +Footer: Settings · Sign out. Mobile bottom bar: Home, CoachHelm, Roster, Calendar, Messages (unchanged). + +### Golf player — 8 +1. **Dashboard** (merge Hub into Dashboard landing — two "homes" is duplicative) +2. **CoachHelm AI** (cluster: coachhelm · my-development · my-game-profile · my-standing) +3. **My Rounds** +4. **My Stats** +5. **Calendar** +6. **Team** (Roster · Team Info · Team Hub · My Qualifiers as sub-tabs) +7. **Messages** +8. **Courses** +Footer: Settings · Sign out. + +## Waves (each: Sonnet executor(s), ≤~15-file PRs where possible, gates = tsc/lint/unit with captured exit codes, merge before next dependent wave) + +- **W0 — P0/P1 security & correctness** (3 sub-PRs): + - W0a code: discover.ts profile_visibility filter; Activate-Recruiting + client-write removal; sign-out invalidateAuthCache; is_anonymous field + dropped from 4 insert sites; server guards on ~12 client-only routes; + comparisons redirect; coaching-intelligence player redirect (golf); + middleware Sentry + tighten; account/delete FK-order fix. + - W0b DB (baseball additive, check-first; REVOKE-after-recreate): + recreate 2 leaky public views with visible_to_players/status + + public_profile_mode filters; gate + revoke get_admin_event_summary; + BEFORE UPDATE trigger guarding recruiting_activated. + - W0c product correctness: FairwayShotTracking meters preference; + new-round-client allHolesScored re-edit refresh; weekly-coach-email + opt-out gate; task-reminders honor email_task_reminders. +- **W1 — Golf legacy-tree deletion** (the Coherence Ruling for golf): + Fairway unconditional; delete GolfDashboardShell + GolfSidebar + every + `isRedesignEnabled()/useRedesign()` fork's legacy branch (~65 pages); + migrate un-gated legacy edge/error branches (roster error states, + FeatureUnavailable users) to Fairway equivalents; delete flag plumbing + (keep print route as-is by design). Likely 3–5 PRs by route cluster. +- **W2 — Golf nav consolidation 15/12 → 8/8**: hub-grouped rail per target + IA above (port nav-registry/hub-definitions pattern or restructure + buildNavSections with sub-tab strips); align legacy-vs-fairway nav parity + claims; delete unreachable GolfSidebar redesign branch (dead code). +- **W3 — Baseball player nav → 8**: Settings to footer; verify manifest + invariants + nav tests. +- **W4 — Baseball Living Annual completion (priority order)**: player/today + FIRST; travel client; recruiting cluster (journey, colleges, analytics, + discover, compare, comparisons, camps, dev-plans); settings hub + subpages; + public profiles (team/[id] loading+error, shared tiered-access helper); + command-center empty state; error.tsx cluster → shared RouteErrorBoundary; + off-palette red/amber status boxes → tokens. +- **W5 — Lift Lab / Performance reskin** to Living Annual tokens (lift, + readiness, performance/{groups,live,programs,builder}). +- **W6 — Golf polish**: port RuledStatLine + leader ticks to golf stats + (owner's green/contrast ask); 2 emerald banned-color fixes; golf/join + invisible-orbs fix (helm-primary-* → primary-*); rounds flow fixes from + gap-fill deep read. +- **W7 — Dead code + docs truth**: prune 8 settings redirect stubs (verify + no inbound links) + /coach stub + demo-mode anchor; dead loading/error on + stubs; refresh BASEBALLHELM_FEATURE_READINESS_MATRIX (advisory check green) + + re-grade gap map; baseball not-found handler. +- **W8 — DB remediation**: 8 phantom-migration tables (create if code needs, + else clean migrations — per gap-fill verdict); 4 security_definer_view + fixes (or documented acceptance); un-gated admin SECDEF functions (per + gap-fill); dedupe multiple permissive policies on golf_shots/golf_holes/ + golf_causal_relationships/golf_insight_effectiveness/putt_details; FK + indexes on admin_events/admin_analytics_events/crm_coaches; avatars bucket + listing. Course-tees open-edit: REPORT to owner, do not change. +- **W9 — Admin**: role='admin' post-login → Bridge; Bridge route-level error + boundaries; tracer completion or explicit deferral (size via gap-fill). +- **W10 — API/PWA/email/a11y fixes** from gap-fill findings. +- **Phase D — adversarial verify loop** until 2 consecutive dry rounds. +- **Phase E — advisors re-run + ONE production deploy + final report.** + +## Standing constraints (bind every executor) +- No browser automation on this laptop; verify via tsc/tests/code-reads. +- No destructive writes in save/submit/sync paths; upsert/stage-and-swap. +- EIN… n/a. Golf DB functions untouchable without owner sign-off; baseball + additive migrations pre-approved CHECK-FIRST (verify live schema before). +- Never GRANT to anon; REVOKE after matview/table recreate; verify relacl. +- Vercel: no preview deploys; ONE intentional production deploy at the end. +- Workflow executors: always `model:'sonnet'`; capture real exit codes + (never `cmd | tail` as a gate). +- add columns BEFORE bulk-ingest; verify migrations via information_schema. 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/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md b/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md index 6451418d3..a07e22072 100644 --- a/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md +++ b/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md @@ -47,27 +47,27 @@ instead of becoming another stale audit doc. | Feature | Route(s) | Source of Truth / Spec | Current Status | Highest-Risk Gap | Test Coverage | Owner Issue | Production Readiness | |---|---|---|---|---|---|---|---| -| Auth / Onboarding | `/baseball/login`, `/baseball/signup`, `/baseball/coach`, `/baseball/coach-onboarding`, `/baseball/player`, `/baseball/complete-signup` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §1–2; `src/lib/baseball/nav-registry.ts` | partial | Persisted-shell fast path authorizes from cached Zustand profile state before the background Supabase auth check resolves (`src/hooks/use-baseball-auth.ts`); the public demo gate signs every visitor into one shared demo coach account (`src/app/baseball/actions/demo-access.ts`); password-change UI collects but does not verify `currentPassword` before mutating. | `src/lib/baseball/__tests__/server-route-guards.test.ts`, `coach-onboarding-staff-row.integration.test.ts`, `active-context-staff-status.test.ts`; `e2e/auth.spec.ts` | , , | Not production-ready — 3 open auth/security findings on the entry path. | -| Command Center | `/baseball/dashboard/command-center` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md`; `src/lib/baseball/read-models/command-center.ts` | partial | Page calls `getCommandCenter(team.id)` but also runs parallel direct Supabase queries against `baseball_team_members`, `baseball_events`, and `baseball_player_aggregates` instead of consuming one canonical read model — confirmed in `src/app/baseball/(dashboard)/dashboard/command-center/page.tsx`. | None dedicated; only `e2e/baseball-phase1.spec.ts` route smoke, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`. | | Renders for real users; data contract is split and can drift. | -| Calendar | `/baseball/dashboard/calendar` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/components/baseball/calendar/BaseballCalendarWrapper.tsx` | partial | Player Calendar team resolution bug; Calendar/Event mutations not yet normalized behind the shared action guard. | `src/components/baseball/calendar/__tests__/BaseballCalendarWrapper.rsvp-routing.test.tsx`; `e2e/baseball-phase1.spec.ts` (gated). | , | Renders and has RSVP-routing unit coverage; team-resolution bug is a real correctness defect. | -| Roster | `/baseball/dashboard/roster` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/lib/baseball/read-models/` | partial | Still a client-side `useAuth()` + direct-Supabase leaf route rather than the server `getActiveBaseballContext()` read-model pattern — confirmed in `docs/audits/BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` ("Remaining stale-risk surfaces"). | `src/lib/baseball/__tests__/roster-read-model.test.ts`; `e2e/roster.spec.ts` is **fully skipped** (8/8 `test.skip`). | | Functional but on the legacy data-access pattern with no live E2E coverage. | -| Stats / Box Score | `/baseball/dashboard/stats`, `/baseball/dashboard/stats-center`, `/baseball/dashboard/stats/games`, `/baseball/dashboard/stats/upload`, `/baseball/dashboard/stats/season` | `docs/operations/BASEBALL_STATS_SOURCE_OF_TRUTH.md`; `docs/archive/2026-06/audits/STATS_END_TO_END_REMEDIATION_PLAN_2026-06-06.md` | partial | Three parallel stat data layers (legacy flat `baseball_player_stats`/`baseball_player_aggregates`, box-score/season, elite stat-event) are not reconciled; `saveBoxScoreBatting`/`saveBoxScorePitching` delete existing rows before inserting replacements (not atomic) in `src/app/baseball/actions/games.ts`. | `src/lib/baseball/__tests__/stats-center-derivations.test.ts`, `stats-route-aliases.test.ts`, `stat-event-adapters.test.ts`; `e2e/baseball-box-score.spec.ts` (28 tests, none skipped). | , , | Best E2E coverage in BaseballHelm, but the delete-then-insert save path is a real data-loss risk on partial failure. | -| Import Center | `/baseball/dashboard/import`, `/baseball/dashboard/settings/imports` | `docs/archive/2026-06/audits/STATS_END_TO_END_REMEDIATION_PLAN_2026-06-06.md` | partial | Stat-event import commit path computes `requiresReview` from a client-supplied `detectionAutoCommit` flag instead of recomputing server-side (`src/app/baseball/actions/stat-event-imports.ts`); disabled import sources can still be hit directly via server action even when hidden in the UI. | `src/lib/baseball/__tests__/import-validation.test.ts`, `import-source-registration.test.ts`, `import-source-enabled.test.ts`, `import-registry-policy.test.ts`, `import-formats.test.ts`, `import-duplicate-verdict.test.ts` | , | Good unit coverage on parsing/format detection; the review-band trust boundary is a real security/data-integrity gap. | -| Practice | `/baseball/dashboard/practice`, `/baseball/player/practice` | `docs/archive/2026-06/baseballhelm_revolution_plan_v2/24_subsystem_execution_blueprint_v9/v9_tab_by_tab_subsystem_plan.md` | route-only | `src/app/baseball/actions/practice.ts` is already wrapped in `withBaseballAction` (not a security gap), but no Baseball-specific unit or E2E test was found for the practice planner route, and `HubSubNav` tabs (including Practice's hub) are not yet filtered against the resolved capability context. | None located under `src/lib/baseball/__tests__/` or `e2e/` specific to this route. | (umbrella — hub subnav capability filtering, not Practice-specific) | Action layer looks mature; no dedicated tracking issue exists yet for test coverage on this surface — flagged as a feature-awareness gap (see [Maintenance](#maintenance)). | -| Practice Effectiveness | `/baseball/dashboard/practice-effectiveness` | `docs/archive/2026-06/baseballhelm_revolution_plan_v2/24_subsystem_execution_blueprint_v9/v9_tab_by_tab_subsystem_plan.md` | route-only | `src/app/baseball/actions/practice-effectiveness.ts` and `src/lib/baseball/read-models/practice-effectiveness.ts` exist and use the shared action guard, but there is no contract proving a failed/empty effectiveness load renders differently from "no practices logged yet" (the general pattern flagged in #400). | None located. | (umbrella — empty/error/no-permission state contracts, not feature-specific) | Action/read-model layer exists; zero feature-specific test evidence. | -| Performance / Lifting | `/baseball/dashboard/performance`, `/baseball/dashboard/performance/builder`, `/baseball/dashboard/performance/groups`, `/baseball/dashboard/performance/live`, `/baseball/dashboard/performance/players/[id]`, `/baseball/dashboard/performance/programs`, `/baseball/dashboard/lift`, `/baseball/dashboard/readiness` | `docs/lifting-lab/HELM_LIFTING_LAB_BLUEPRINT.md`; `docs/baseballhelm-finish-runbook.md` Phase B | partial | Performance navigation visibility can disagree with the actual route/capability gate for lifting/readiness surfaces; the lifting-lab finish runbook (Phase B4) notes an athlete-backfill dependency between legacy `baseball_lift_*` rows and the unified `helm_lifting_*` model. | `src/lib/baseball/lifting/__tests__/readiness-compute.test.ts`; `src/lib/baseball/__tests__/exercise-conflict.test.ts` (36 cases per PR #345); `e2e/baseball-phase1.spec.ts` (gated). | | Real unit-test depth on the conflict engine and readiness math; nav-visibility/capability-gate mismatch is the live production risk. | +| Auth / Onboarding | `/baseball/login`, `/baseball/signup`, `/baseball/coach`, `/baseball/coach-onboarding`, `/baseball/player`, `/baseball/complete-signup` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §1–2; `src/lib/baseball/nav-registry.ts` | partial | Persisted-shell fast path authorizes from cached Zustand profile state before the background Supabase auth check resolves (`src/hooks/use-baseball-auth.ts`); the public demo gate signs every visitor into one shared demo coach account (`src/app/baseball/actions/demo-access.ts`); password-change UI collects but does not verify `currentPassword` before mutating. | `src/lib/baseball/__tests__/server-route-guards.test.ts`, `coach-onboarding-staff-row.integration.test.ts`, `active-context-staff-status.test.ts`; `e2e/auth.spec.ts` | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Not production-ready — 3 open auth/security findings on the entry path. | +| Command Center | `/baseball/dashboard/command-center` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md`; `src/lib/baseball/read-models/command-center.ts` | partial | Page calls `getCommandCenter(team.id)` but also runs parallel direct Supabase queries against `baseball_team_members`, `baseball_events`, and `baseball_player_aggregates` instead of consuming one canonical read model — confirmed in `src/app/baseball/(dashboard)/dashboard/command-center/page.tsx`. | None dedicated; only `e2e/baseball-phase1.spec.ts` route smoke, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`. | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Renders for real users; data contract is split and can drift. | +| Calendar | `/baseball/dashboard/calendar` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/components/baseball/calendar/BaseballCalendarWrapper.tsx` | partial | Player Calendar team resolution bug; Calendar/Event mutations not yet normalized behind the shared action guard. | `src/components/baseball/calendar/__tests__/BaseballCalendarWrapper.rsvp-routing.test.tsx`; `e2e/baseball-phase1.spec.ts` (gated). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Renders and has RSVP-routing unit coverage; team-resolution bug is a real correctness defect. | +| Roster | `/baseball/dashboard/roster` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/lib/baseball/read-models/` | partial | Still a client-side `useAuth()` + direct-Supabase leaf route rather than the server `getActiveBaseballContext()` read-model pattern — confirmed in `docs/audits/BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` ("Remaining stale-risk surfaces"). | `src/lib/baseball/__tests__/roster-read-model.test.ts`; `e2e/roster.spec.ts` is **fully skipped** (8/8 `test.skip`). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Functional but on the legacy data-access pattern with no live E2E coverage. | +| Stats / Box Score | `/baseball/dashboard/stats`, `/baseball/dashboard/stats-center`, `/baseball/dashboard/stats/games`, `/baseball/dashboard/stats/upload` | `docs/operations/BASEBALL_STATS_SOURCE_OF_TRUTH.md`; `docs/archive/2026-06/audits/STATS_END_TO_END_REMEDIATION_PLAN_2026-06-06.md` | partial | Three parallel stat data layers (legacy flat `baseball_player_stats`/`baseball_player_aggregates`, box-score/season, elite stat-event) are not reconciled; `saveBoxScoreBatting`/`saveBoxScorePitching` delete existing rows before inserting replacements (not atomic) in `src/app/baseball/actions/games.ts`. | `src/lib/baseball/__tests__/stats-center-derivations.test.ts`, `stats-route-aliases.test.ts`, `stat-event-adapters.test.ts`; `e2e/baseball-box-score.spec.ts` (28 tests, none skipped). | | Best E2E coverage in BaseballHelm, but the delete-then-insert save path is a real data-loss risk on partial failure. | +| Import Center | `/baseball/dashboard/import`, `/baseball/dashboard/settings/imports` | `docs/archive/2026-06/audits/STATS_END_TO_END_REMEDIATION_PLAN_2026-06-06.md` | partial | Stat-event import commit path computes `requiresReview` from a client-supplied `detectionAutoCommit` flag instead of recomputing server-side (`src/app/baseball/actions/stat-event-imports.ts`); disabled import sources can still be hit directly via server action even when hidden in the UI. | `src/lib/baseball/__tests__/import-validation.test.ts`, `import-source-registration.test.ts`, `import-source-enabled.test.ts`, `import-registry-policy.test.ts`, `import-formats.test.ts`, `import-duplicate-verdict.test.ts` | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Good unit coverage on parsing/format detection; the review-band trust boundary is a real security/data-integrity gap. | +| Practice | `/baseball/dashboard/practice`, `/baseball/player/practice` | `docs/archive/2026-06/baseballhelm_revolution_plan_v2/24_subsystem_execution_blueprint_v9/v9_tab_by_tab_subsystem_plan.md` | route-only | `src/app/baseball/actions/practice.ts` is already wrapped in `withBaseballAction` (not a security gap), but no Baseball-specific unit or E2E test was found for the practice planner route, and `HubSubNav` tabs (including Practice's hub) are not yet filtered against the resolved capability context. | None located under `src/lib/baseball/__tests__/` or `e2e/` specific to this route. | (umbrella — hub subnav capability filtering, not Practice-specific) | Action layer looks mature; no dedicated tracking issue exists yet for test coverage on this surface — flagged as a feature-awareness gap (see [Maintenance](#maintenance)). | +| Practice Effectiveness | `/baseball/dashboard/practice-effectiveness` | `docs/archive/2026-06/baseballhelm_revolution_plan_v2/24_subsystem_execution_blueprint_v9/v9_tab_by_tab_subsystem_plan.md` | route-only | `src/app/baseball/actions/practice-effectiveness.ts` and `src/lib/baseball/read-models/practice-effectiveness.ts` exist and use the shared action guard, but there is no contract proving a failed/empty effectiveness load renders differently from "no practices logged yet" (the general pattern flagged in #400). | None located. | (umbrella — empty/error/no-permission state contracts, not feature-specific) | Action/read-model layer exists; zero feature-specific test evidence. | +| Performance / Lifting | `/baseball/dashboard/performance`, `/baseball/dashboard/performance/builder`, `/baseball/dashboard/performance/groups`, `/baseball/dashboard/performance/live`, `/baseball/dashboard/performance/players/[id]`, `/baseball/dashboard/performance/programs`, `/baseball/dashboard/lift`, `/baseball/dashboard/readiness` | `docs/lifting-lab/HELM_LIFTING_LAB_BLUEPRINT.md`; `docs/baseballhelm-finish-runbook.md` Phase B | partial | Performance navigation visibility can disagree with the actual route/capability gate for lifting/readiness surfaces; the lifting-lab finish runbook (Phase B4) notes an athlete-backfill dependency between legacy `baseball_lift_*` rows and the unified `helm_lifting_*` model. | `src/lib/baseball/lifting/__tests__/readiness-compute.test.ts`; `src/lib/baseball/__tests__/exercise-conflict.test.ts` (36 cases per PR #345); `e2e/baseball-phase1.spec.ts` (gated). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Real unit-test depth on the conflict engine and readiness math; nav-visibility/capability-gate mismatch is the live production risk. | | Player Today | `/baseball/player/today` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §2; `src/lib/baseball/read-models/player-today.ts` | partial | Named explicitly in #377 as a PR #345 surface added without a product-truth contract lane — no test proves the daily contract/today view can't silently render fabricated or stale data. | `e2e/baseball-phase1.spec.ts` (player block, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`). | | Renders for real players; no business-contract test lane yet. | -| Signals | `/baseball/dashboard/signals` | `src/app/baseball/actions/signals.ts`, `src/app/baseball/actions/operational-signals.ts` | partial | CoachHelm/operational signals can still derive claims from `baseball_player_stats` while the polished Stats Center derives displayed truth from box scores — no contract ensures the two agree (#384); also named in #377's product-truth gap list. | `src/lib/baseball/__tests__/operational-rule-engine.test.ts`, `signal-from-insight.test.ts`, `scheduled-evaluator.test.ts`, `outcome-sweep-verdict.test.ts`, `outcome-sweep-insight-resolve.test.ts` | , | Strong rule-engine unit coverage; the stat-source-of-truth mismatch with CoachHelm claims is the real risk. | -| Decision Room | `/baseball/dashboard/decision-room` | `src/app/baseball/actions/decision-room.ts`; `src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx` | needs decision | Write mutations target `baseball_meeting_items` (agenda CRUD) and `baseball_decision_log` (append-only ledger) — confirmed by repo search that **neither table exists in any file under `supabase/migrations/`**. The action file documents this directly: a hand-rolled `LooseClient` type loosens `.from()` calls "because `baseball_meeting_items` and `baseball_decision_log` are defined in unapplied migrations (shared prod DB)." RLS is asserted to still apply once the migration lands, but the migration itself is not in the repo. | None located for the agenda/ledger read models under `src/lib/baseball/read-models/decision-room/__tests__/`. | (best-available umbrella — "stale types will be regenerated" / typed-gap cleanup; **no dedicated issue exists yet for applying the `baseball_meeting_items`/`baseball_decision_log` migration** — see [Maintenance](#maintenance)) | Cannot be called ready until the migration is reviewed (golf-shared prod DB) and applied; current behavior is contingent on RLS that hasn't been verified against a real schema. | -| Videos | `/baseball/dashboard/videos`, `/baseball/dashboard/videos/[id]`, `/baseball/dashboard/videos/[id]/edit` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | Named explicitly in #377 as a PR #345 surface (videos) added without a product-truth/empty-state contract; `BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` shows `baseball_videos: 0` in the live demo account, meaning the surface is effectively unverifiable end to end against real data today. | None located under `src/lib/baseball/__tests__/`. | | Renders; demo data gap + missing contract tests mean it has not been proven against a real upload/playback flow. | -| Documents | `/baseball/dashboard/documents` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | `src/app/baseball/actions/documents.ts` accepts caller-supplied `teamId`/`isCoach` instead of resolving them server-side, is not fully wrapped in the shared action guard, returns public storage URLs, and surfaces raw error messages. | `src/lib/baseball/__tests__/documents-capability.test.ts` | | Functional but has an open security finding (public storage URLs + caller-supplied scope) that should block calling this production-ready. | +| Signals | `/baseball/dashboard/signals` | `src/app/baseball/actions/signals.ts`, `src/app/baseball/actions/operational-signals.ts` | partial | CoachHelm/operational signals can still derive claims from `baseball_player_stats` while the polished Stats Center derives displayed truth from box scores — no contract ensures the two agree (#384); also named in #377's product-truth gap list. | `src/lib/baseball/__tests__/operational-rule-engine.test.ts`, `signal-from-insight.test.ts`, `scheduled-evaluator.test.ts`, `outcome-sweep-verdict.test.ts`, `outcome-sweep-insight-resolve.test.ts` | | Strong rule-engine unit coverage; the stat-source-of-truth mismatch with CoachHelm claims is the real risk. | +| Decision Room | `/baseball/dashboard/decision-room` | `src/app/baseball/actions/decision-room.ts`; `src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx` | needs decision | Write mutations target `baseball_meeting_items` (agenda CRUD) and `baseball_decision_log` (append-only ledger) — confirmed by repo search that **neither table exists in any file under `supabase/migrations/`**. The action file documents this directly: a hand-rolled `LooseClient` type loosens `.from()` calls "because `baseball_meeting_items` and `baseball_decision_log` are defined in unapplied migrations (shared prod DB)." RLS is asserted to still apply once the migration lands, but the migration itself is not in the repo. | None located for the agenda/ledger read models under `src/lib/baseball/read-models/decision-room/__tests__/`. | (best-available umbrella — "stale types will be regenerated" / typed-gap cleanup; **no dedicated issue exists yet for applying the `baseball_meeting_items`/`baseball_decision_log` migration** — see [Maintenance](#maintenance)) | Cannot be called ready until the migration is reviewed (golf-shared prod DB) and applied; current behavior is contingent on RLS that hasn't been verified against a real schema. | +| Videos | `/baseball/dashboard/videos` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | Named explicitly in #377 as a PR #345 surface (videos) added without a product-truth/empty-state contract; `BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` shows `baseball_videos: 0` in the live demo account, meaning the surface is effectively unverifiable end to end against real data today. | None located under `src/lib/baseball/__tests__/`. | | Renders; demo data gap + missing contract tests mean it has not been proven against a real upload/playback flow. | +| Documents | `/baseball/dashboard/documents` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | `src/app/baseball/actions/documents.ts` accepts caller-supplied `teamId`/`isCoach` instead of resolving them server-side, is not fully wrapped in the shared action guard, returns public storage URLs, and surfaces raw error messages. | `src/lib/baseball/__tests__/documents-capability.test.ts` | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Functional but has an open security finding (public storage URLs + caller-supplied scope) that should block calling this production-ready. | | Travel | `/baseball/dashboard/travel` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1/§5.1 | partial | `src/app/baseball/actions/travel.ts` is named explicitly in #394 as one of the legacy action files still mixing bespoke auth checks with the shared `withBaseballAction` model (split-era risk); also listed in `BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` as a client-`useAuth()` leaf route. | None located under `src/lib/baseball/__tests__/` or `e2e/`. | | Route exists and has real itinerary actions, but auth-guard migration is incomplete and there is zero test coverage. | -| Camps | `/baseball/dashboard/camps`, `/baseball/dashboard/camps/[id]` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | `e2e/camps.spec.ts` is **fully skipped** (6/6 `test.skip`) — the only E2E spec for this feature has zero live assertions despite #344/#345 claiming broad route coverage. | `e2e/camps.spec.ts` (all skipped). | | Renders and has create/edit/browse/register actions per the dashboard audit, but is unverified end to end. | -| Recruiting Pipeline | `/baseball/dashboard/pipeline` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | `e2e/baseball-pipeline.spec.ts` is **fully skipped** (5/5 `test.skip`) — drag-between-stages, filtering, notes, and keyboard navigation are all unverified live. | `e2e/baseball-pipeline.spec.ts` (all skipped). | | 5-stage kanban renders per the dashboard audit; zero live E2E coverage on the core workflow. | -| Watchlist / Compare | `/baseball/dashboard/watchlist`, `/baseball/dashboard/compare`, `/baseball/dashboard/comparisons` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | Watchlist add/remove actions verify the calling coach owns `coachId` but do not verify the target player is discoverable/recruitable for that coach before writing `baseball_watchlists`/engagement rows (`src/app/baseball/actions/watchlist.ts`). | `e2e/watchlist.spec.ts` (10 tests, **not skipped**); `e2e/discover.spec.ts` (12 tests, not skipped). | | Best live E2E coverage among the recruiting surfaces; the missing recruitability check is a real data-integrity gap on writes. | -| Scout Packets | `/baseball/dashboard/scout-packets`, `/baseball/dashboard/players/[id]/scout-packet`, `/baseball/dashboard/players/[id]/scout-packet/preview`, `/baseball/packet/[token]` | `src/app/baseball/actions/scout-packet.ts` | partial | The public scout-packet CSV route (`/baseball/packet/[token]/csv`) returns HTTP 200 with a downloadable CSV even for invalid, revoked, expired, or non-exposed tokens, by design ("minimal CSV for browser download convenience") — making failures silent and unauditable. | None located under `src/lib/baseball/__tests__/`. | | Public-facing surface with a real trust/auditability gap on the unauthenticated path. | -| Settings | `/baseball/dashboard/settings` (+ `ai`, `appearance`, `audit`, `data-retention`, `demo-mode`, `guardian-access`, `imports`, `integrations`, `permissions`, `philosophy`, `player-access`, `privacy`, `program`, `recruiting-preferences`, `roles`, `season`, `showcase-profile`, `staff`, `teams` subpages) | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §"Settings Architecture" | partial | Password-change form collects `currentPassword` but never verifies it against Supabase before calling the password-update API (`src/app/baseball/(dashboard)/dashboard/settings/page.tsx`). | None located covering the settings page itself; subpage-specific tests vary (see Staff/Roles, Notifications rows). | | Reachable and functional for most subpages; the unverified reauthentication path on a security-sensitive form blocks "ready." | -| Staff / Roles | `/baseball/dashboard/settings/staff`, `/baseball/dashboard/settings/roles`, `/baseball/staff/join/[code]` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §"Staff Collaboration Layer" | partial | Inactive/suspended/removed staff can still resolve active team context and read team/player data because membership-existence checks aren't paired with active-status checks (#405); staff player-scope enforcement may read stale JSON-key metadata instead of the structured `scope_player_ids` columns (#406). | `src/lib/baseball/__tests__/active-context-staff-status.test.ts`, `coach-onboarding-staff-row.integration.test.ts` | , | Two open access-control findings on a staff-data-scoping surface — treat as not production-ready until resolved. | +| Camps | `/baseball/dashboard/camps`, `/baseball/dashboard/camps/[id]` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | `e2e/camps.spec.ts` is **fully skipped** (6/6 `test.skip`) — the only E2E spec for this feature has zero live assertions despite #344/#345 claiming broad route coverage. | `e2e/camps.spec.ts` (all skipped). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Renders and has create/edit/browse/register actions per the dashboard audit, but is unverified end to end. | +| Recruiting Pipeline | `/baseball/dashboard/pipeline` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | `e2e/baseball-pipeline.spec.ts` is **fully skipped** (5/5 `test.skip`) — drag-between-stages, filtering, notes, and keyboard navigation are all unverified live. | `e2e/baseball-pipeline.spec.ts` (all skipped). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | 5-stage kanban renders per the dashboard audit; zero live E2E coverage on the core workflow. | +| Watchlist / Compare | `/baseball/dashboard/watchlist`, `/baseball/dashboard/compare`, `/baseball/dashboard/comparisons` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | partial | Watchlist add/remove actions verify the calling coach owns `coachId` but do not verify the target player is discoverable/recruitable for that coach before writing `baseball_watchlists`/engagement rows (`src/app/baseball/actions/watchlist.ts`). | `e2e/watchlist.spec.ts` (10 tests, **not skipped**); `e2e/discover.spec.ts` (12 tests, not skipped). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Best live E2E coverage among the recruiting surfaces; the missing recruitability check is a real data-integrity gap on writes. | +| Scout Packets | `/baseball/dashboard/scout-packets`, `/baseball/dashboard/players/[id]/scout-packet`, `/baseball/dashboard/players/[id]/scout-packet/preview`, `/baseball/packet/[token]` | `src/app/baseball/actions/scout-packet.ts` | partial | The public scout-packet CSV route (`/baseball/packet/[token]/csv`) returns HTTP 200 with a downloadable CSV even for invalid, revoked, expired, or non-exposed tokens, by design ("minimal CSV for browser download convenience") — making failures silent and unauditable. | None located under `src/lib/baseball/__tests__/`. | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Public-facing surface with a real trust/auditability gap on the unauthenticated path. | +| Settings | `/baseball/dashboard/settings` (+ `ai`, `appearance`, `audit`, `data-retention`, `demo-mode`, `guardian-access`, `imports`, `integrations`, `permissions`, `philosophy`, `player-access`, `privacy`, `program`, `recruiting-preferences`, `roles`, `season`, `showcase-profile`, `staff`, `teams` subpages) | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §"Settings Architecture" | partial | Password-change form collects `currentPassword` but never verifies it against Supabase before calling the password-update API (`src/app/baseball/(dashboard)/dashboard/settings/page.tsx`). | None located covering the settings page itself; subpage-specific tests vary (see Staff/Roles, Notifications rows). | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Reachable and functional for most subpages; the unverified reauthentication path on a security-sensitive form blocks "ready." | +| Staff / Roles | `/baseball/dashboard/settings/staff`, `/baseball/dashboard/settings/roles`, `/baseball/staff/join/[code]` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §"Staff Collaboration Layer" | partial | Inactive/suspended/removed staff can still resolve active team context and read team/player data because membership-existence checks aren't paired with active-status checks (#405); staff player-scope enforcement may read stale JSON-key metadata instead of the structured `scope_player_ids` columns (#406). | `src/lib/baseball/__tests__/active-context-staff-status.test.ts`, `coach-onboarding-staff-row.integration.test.ts` | Resolved 2026-07-09 (issues closed; row re-grade lands with the W7 matrix refresh) | Two open access-control findings on a staff-data-scoping surface — treat as not production-ready until resolved. | | Notifications | `/baseball/dashboard/settings/notifications` → `permanentRedirect` alias into `/baseball/dashboard/settings/program#notifications` | `src/app/baseball/(dashboard)/dashboard/settings/notifications/page.tsx` (header comment cites "v4 §Settings Architecture") | hidden | None — this is an accepted, documented consolidation: notification controls live as a section of the single Program Settings page (one save surface, one capability gate) per the route's own header comment, and the dedicated spec route resolves via `permanentRedirect` so the URL stays deep-linkable. | N/A — intentional redirect, nothing to test beyond the redirect itself resolving (which the route-resolution check below already covers). | N/A (intentional) | Working as designed; not a gap. | --- 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..017cb1aeb 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,39 @@ 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(); +} + +/** + * Shared select→compute-ids→delete sweep used by both the pre-run leftover + * sweep (beforeAll) and the real teardown (afterAll) below — the only + * difference between the two call sites is the filter used to find games. + */ +async function deleteGamesAndEvents( + supabase: NonNullable>, + games: { id: string; event_id: string | null }[] | null, +) { + 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); + } +} + async function loginCoachOrSkip(page: Page) { try { await loginAsCoach(page); @@ -65,12 +102,58 @@ 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[] = []; + + // Pre-run sweep: a previous CANCELLED/timed-out run (Playwright's + // cancel-in-progress) skips the afterAll teardown below, leaving orphaned + // "E2E Created Opponent …" games (and their linked calendar events) behind + // on the shared/demo team. Delete any such leftovers before this run so junk + // can't accumulate across cancelled runs, regardless of whether this run + // reaches its own afterAll. + test.beforeAll(async () => { + const supabase = getServiceRoleClient(); + if (!supabase) return; + try { + const { data: games } = await supabase + .from('baseball_games') + .select('id, event_id') + .ilike('opponent_name', 'E2E Created Opponent%'); + await deleteGamesAndEvents(supabase, games); + } catch (err) { + // Cleanup must never fail an otherwise-passing run (see file docstring). + console.warn('[e2e cleanup] leftover-opponent sweep failed:', err); + } + }); + 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. + try { + const { data: games } = await supabase + .from('baseball_games') + .select('id, event_id') + .in('opponent_name', createdOpponents); + await deleteGamesAndEvents(supabase, games); + } catch (err) { + // Cleanup must never fail an otherwise-passing run (see file docstring). + console.warn('[e2e cleanup] created-opponent teardown failed:', err); + } + }); + 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 +175,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); @@ -308,28 +392,16 @@ test.describe('Coach - Games List and Box Score View', () => { // --------------------------------------------------------------------------- // Group 5: Coach - Season stats summary page +// +// REMOVED (not repointed): /baseball/dashboard/stats/season is a 404 — the +// season stats flow now lives at /baseball/dashboard/stats-center +// (/baseball/dashboard/stats redirects there), which renders a card/"plate" +// magazine layout (StatSpread/PlayerRowPlate), not a heading matching +// `/— Stats$/` or a `` element. The old assertions here don't +// translate 1:1 onto that surface, so this block is removed rather than +// repointed at selectors that were never verified against the real page. // --------------------------------------------------------------------------- -test.describe('Coach - Season Stats Dashboard', () => { - test.skip(!SEEDED, 'no seeded baseball team fixture (set PLAYWRIGHT_BASEBALL_SEEDED=1)'); - - test.beforeEach(async ({ page }) => { - await loginCoachOrSkip(page); - await page.goto('/baseball/dashboard/stats/season'); - await waitForPageLoad(page); - }); - - test('should display the season stats page with the team name', async ({ page }) => { - await expect(page.getByRole('heading', { name: /— Stats$/ })).toBeVisible(); - }); - - test('should show the seeded batting stats in the season stats table', async ({ page }) => { - await expect(page.locator('table').first()).toBeVisible({ timeout: 8000 }); - // Bench players from the seeded completed game must show up with non-zero AB. - await expect(page.getByText('Bennett').or(page.getByText('Ortiz'))).toBeVisible(); - }); -}); - // --------------------------------------------------------------------------- // Group 6: Player views their personal stats // --------------------------------------------------------------------------- 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..a0a5d5a98 --- /dev/null +++ b/scripts/backfill-baseball-slash-lines.ts @@ -0,0 +1,210 @@ +/** + * 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; +} + +/** Page through a table past PostgREST's 1000-row default cap, with a stable order. */ +async function fetchAllRows( + build: (from: number, to: number) => PromiseLike<{ data: T[] | null; error: { message: string } | null }>, +): Promise { + const PAGE = 1000; + const out: T[] = []; + for (let from = 0; ; from += PAGE) { + const { data, error } = await build(from, from + PAGE - 1); + if (error) throw error; + const rows = data ?? []; + out.push(...rows); + if (rows.length < PAGE) break; + } + return out; +} + +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 rows = await fetchAllRows((from, to) => + supabase + .from('baseball_player_aggregates') + .select('player_id, team_id, career_avg, career_obp, career_slg, career_ops') + .order('player_id', { ascending: true }) + .order('team_id', { ascending: true }) + .range(from, to) + .returns(), + ); + + 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. + let statsRows: SlashLineStatRow[]; + try { + statsRows = await fetchAllRows((from, to) => + supabase + .from('baseball_player_stats') + .select('*') + .eq('player_id', row.player_id) + .eq('team_id', row.team_id) + .order('id', { ascending: true }) + .range(from, to) + .returns(), + ); + } catch (statsError) { + const message = statsError instanceof Error ? statsError.message : String(statsError); + console.warn(` ⚠ ${row.player_id.slice(0, 8)}/${row.team_id.slice(0, 8)}: failed to load stats — ${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 (errors > 0) process.exitCode = 1; + + 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..6ae3cc160 --- /dev/null +++ b/scripts/e2e-supabase-admin.ts @@ -0,0 +1,35 @@ +/** + * 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. + * + * `playwright test` does NOT auto-load `.env.local` (unlike `next dev`), so a + * local `npm run test:e2e` would otherwise see empty `process.env` here and + * silently no-op teardown even when `.env.local` has real creds. Load it here + * (same pattern as the sibling `scripts/*.ts` seed/backfill utilities) — + * `dotenv` never overwrites already-exported vars, so CI's real env still wins. + */ +import { config as loadEnv } from 'dotenv'; +import { createClient } from '@supabase/supabase-js'; + +loadEnv({ path: '.env.local' }); + +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..21ea6b2de 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,7 +81,12 @@ 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 { IconSettings, IconLogout, IconHome, IconUsers, IconCalendar } from '@/components/icons'; +import { + BreadcrumbLabelProvider, + useBreadcrumbLabel, +} from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; +import { buildBreadcrumbs } from '@/app/baseball/(dashboard)/_components/breadcrumbs'; +import { IconSettings, IconLogout, IconHome, IconUsers, IconCalendar, IconArrowLeft } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -104,14 +102,28 @@ 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); + // Exclude the showcase "Back to Organization" row (buildShowcaseTeamSections) + // from the mobile bottom-tab bar — it's a rail-only affordance, not a + // top-3 destination, and would otherwise leak into the slice(0, 5) fallback + // below when a showcase coach's dashboard hub tab is capability-hidden. + // Matched by label (not href) since the org rail's own "Dashboard" row + // legitimately shares the same destination href and must stay eligible. + const items = sections + .flatMap((section) => section.items) + .filter((item) => item.label !== 'Back to Organization'); 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)) @@ -121,12 +133,14 @@ function buildBottomNavFromSections(sections: NavSection[], role: Role): NavItem return items.slice(0, 5); } -/** Next adapter for the shell's link contract (module scope = stable identity). */ -const ShellLink: ShellLinkComponent = ({ href, children, ...rest }) => ( - - {children} - -); +/** + * Showcase org-level home route — the destination for BOTH the org rail's own + * "Dashboard" row (buildShowcaseOrgSections) and the team rail's "Back to + * Organization" row (buildShowcaseTeamSections). Shared so the team-store + * intercept in the `shellLink` adapter (BaseballFairwayContent) matches + * exactly one route. + */ +const SHOWCASE_ORG_HOME_HREF = '/baseball/dashboard/organization'; /** Segment-boundary route match — shared by rail items and hub cluster rows. */ function matchesRoutePrefix(pathname: string, prefix: string): boolean { @@ -258,33 +272,48 @@ 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 [ { heading: 'Organization', items: [ - { label: 'Dashboard', href: '/baseball/dashboard/organization', icon: IconHome }, + { label: 'Dashboard', href: SHOWCASE_ORG_HOME_HREF, 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), ], }, ]; @@ -296,9 +325,18 @@ function buildShowcaseOrgSections(unreadCount: number): NavSection[] { * / Development only (no Recruiting/Academics/Management — those are org-level * or not part of the showcase team surface), plus Dashboard + Messages so the * rail is never just three orphaned sections with no way back to Today. + * + * The FIRST row is always "Back to Organization" — without it a showcase + * coach who lands in team scope (auto-selected on mount by the team store) + * has no control anywhere to clear the selection and return to the org-level + * rail (Organization/Teams/Events). The row's href is intercepted by the + * `shellLink` adapter in BaseballFairwayContent, which clears the store's + * selectedTeamId before navigating. */ function buildShowcaseTeamSections(ctx: BaseballNavContext, unreadCount: number): NavSection[] { - const items: NavItem[] = []; + const items: NavItem[] = [ + { label: 'Back to Organization', href: SHOWCASE_ORG_HOME_HREF, icon: IconArrowLeft }, + ]; const dashboardTabs = filterHubTabsByCapabilities(COACH_HUB_DEFS.dashboard.tabs, 'coach', ctx.capabilities); if (dashboardTabs.length) { items.push( @@ -309,7 +347,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 +366,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 }) { @@ -456,7 +474,7 @@ function BaseballFairwayContent({ // Same team-identity source the legacy Sidebar's TeamSwitcher reads. const coachTeams = useTeams(); const playerTeams = usePlayerTeams(); - const { selectedTeam } = role === 'coach' ? coachTeams : playerTeams; + const { selectedTeam, setSelectedTeamId } = role === 'coach' ? coachTeams : playerTeams; // Fail-closed fallback (empty capability map) mirrors BaseballDashboardShell // when navContext hasn't resolved yet — gated verticals stay hidden, never @@ -478,7 +496,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, @@ -506,6 +531,35 @@ function BaseballFairwayContent({ window.dispatchEvent(new Event('helm:open-command-palette')); }, []); + // Showcase-aware adapter: the AppShell nav model (NavItem) has no + // per-row onClick, so the "Back to Organization" row built by + // buildShowcaseTeamSections can only signal the team-store reset through + // its href. This intercepts clicks on that one route and clears + // selectedTeamId before/alongside the normal navigation — a no-op for every + // other row (college/HS/JUCO nav never uses this href) and a no-op if the + // org rail's own "Dashboard" row (same href) is clicked while already + // org-scoped. + const shellLink: ShellLinkComponent = useCallback( + ({ href, children, onClick, ...rest }) => ( + { + setSelectedTeamId(null); + onClick?.(); + } + : onClick + } + {...rest} + > + {children} + + ), + [isShowcaseCoach, setSelectedTeamId], + ); + // Same "Skip to main content" anchor the legacy BaseballDashboardShell // renders (and GolfHelm's FairwayDashboardShell mirrors) — keyboard/SR users // must keep skip-nav when the flag is ON, not just flag-OFF. @@ -533,7 +587,7 @@ function BaseballFairwayContent({ sidebarFooter={} topBarActions={} pathname={pathname} - linkComponent={ShellLink} + linkComponent={shellLink} breadcrumbs={breadcrumbs} collapsible mobileOpen={mobileOpen} @@ -543,7 +597,7 @@ function BaseballFairwayContent({ // P413-equivalent: persistent mobile bottom-tab bar for the core // destinations (md:hidden; drawer keeps the long tail). bottomNav={ - + } // Pages own their own gutters + titles (the legacy
in // dashboard-shell.tsx had no content padding either) — the shell keeps @@ -564,15 +618,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 +637,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 +653,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.test.ts b/src/app/baseball/(dashboard)/_components/breadcrumbs.test.ts new file mode 100644 index 000000000..0763a286a --- /dev/null +++ b/src/app/baseball/(dashboard)/_components/breadcrumbs.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + buildBreadcrumbs, + isIdShapedSegment, + singularize, + toTitle, +} from '@/app/baseball/(dashboard)/_components/breadcrumbs'; +import type { BaseballNavContext } from '@/lib/baseball/nav-registry'; + +// Table-driven — locks in buildBreadcrumbs's hub-vs-subpage label logic (the +// settings-collapse bug fixed at breadcrumbs.ts L80-91 already slipped +// through review once without a test). + +const HOME_HREF = '/baseball/dashboard/command-center'; +const COACH_CTX: BaseballNavContext = { role: 'coach', capabilities: {} }; + +describe('toTitle', () => { + it('replaces dashes with spaces and title-cases each word', () => { + expect(toTitle('dev-plans')).toBe('Dev Plans'); + expect(toTitle('permissions')).toBe('Permissions'); + }); +}); + +describe('singularize', () => { + it('strips a trailing non-"ss" s', () => { + expect(singularize('players')).toBe('player'); + expect(singularize('games')).toBe('game'); + }); + + it('leaves a trailing "ss" alone', () => { + expect(singularize('progress')).toBe('progress'); + }); + + it('leaves a single-character or already-singular segment alone', () => { + expect(singularize('s')).toBe('s'); + expect(singularize('team')).toBe('team'); + }); +}); + +describe('isIdShapedSegment', () => { + it('recognizes a UUID', () => { + expect(isIdShapedSegment('5e03752b-d7fe-5e31-ab39-f588ba3649d2')).toBe(true); + }); + + it('recognizes a purely-numeric segment', () => { + expect(isIdShapedSegment('123')).toBe(true); + }); + + it('rejects an ordinary route segment', () => { + expect(isIdShapedSegment('settings')).toBe(false); + expect(isIdShapedSegment('dev-plans')).toBe(false); + }); +}); + +describe('buildBreadcrumbs', () => { + it('returns just "Dashboard" on the home route', () => { + expect(buildBreadcrumbs(HOME_HREF, COACH_CTX, HOME_HREF)).toEqual([{ label: 'Dashboard' }]); + }); + + it('hub landing page uses the registry entry\'s own label (Settings)', () => { + const crumbs = buildBreadcrumbs('/baseball/dashboard/settings', COACH_CTX, HOME_HREF); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Settings' }, + ]); + }); + + it('a subpage folded under the Settings hub uses its OWN trailing segment, not the hub label', () => { + // Regression case for the settings-collapse bug: /settings/permissions + // must read "Permissions", never fall back to the hub's "Settings" crumb. + const crumbs = buildBreadcrumbs('/baseball/dashboard/settings/permissions', COACH_CTX, HOME_HREF); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Permissions' }, + ]); + }); + + it('a UUID-shaped trailing segment falls back to the singularized parent segment, never the raw id', () => { + const crumbs = buildBreadcrumbs( + '/baseball/dashboard/players/5e03752b-d7fe-5e31-ab39-f588ba3649d2', + COACH_CTX, + HOME_HREF, + ); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Player' }, + ]); + }); + + it('a numeric-shaped trailing segment falls back the same way as a UUID', () => { + const crumbs = buildBreadcrumbs('/baseball/dashboard/games/123', COACH_CTX, HOME_HREF); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Game' }, + ]); + }); + + it('overrideLabel wins over the id-shaped-segment fallback', () => { + const crumbs = buildBreadcrumbs( + '/baseball/dashboard/players/5e03752b-d7fe-5e31-ab39-f588ba3649d2', + COACH_CTX, + HOME_HREF, + 'Jordan Smith', + ); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Jordan Smith' }, + ]); + }); + + it('overrideLabel wins over a non-id-shaped trailing segment too', () => { + const crumbs = buildBreadcrumbs( + '/baseball/dashboard/players/5e03752b-d7fe-5e31-ab39-f588ba3649d2/stats', + COACH_CTX, + HOME_HREF, + 'Jordan Smith — Stats', + ); + expect(crumbs).toEqual([ + { label: 'Dashboard', href: HOME_HREF }, + { label: 'Jordan Smith — Stats' }, + ]); + }); +}); diff --git a/src/app/baseball/(dashboard)/_components/breadcrumbs.ts b/src/app/baseball/(dashboard)/_components/breadcrumbs.ts new file mode 100644 index 000000000..2ffbdb563 --- /dev/null +++ b/src/app/baseball/(dashboard)/_components/breadcrumbs.ts @@ -0,0 +1,91 @@ +// ============================================================================= +// 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 }]; + } + + // `best` may be a HUB entry whose href is only a route PREFIX of the + // current page rather than the page itself — e.g. the Settings entry + // (nav-registry.ts) explicitly folds permissions/roles/audit/imports/ + // integrations/philosophy/season under its own href for nav-highlighting + // purposes, none of which register their own entry. Trusting `best.label` + // unconditionally collapses every one of those subpages to the SAME crumb + // ("Settings" for both /settings and /settings/roles). Only trust it when + // the pathname IS that entry's own href; otherwise the URL's own trailing + // segment names the actual current page more specifically than its hub. + const label = overrideLabel ?? (best && pathname === best.href ? best.label : toTitle(lastSegment)); + return [{ label: 'Dashboard', href: homeHref }, { label }]; +} 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..8dc5acbb9 100644 --- a/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/academics/AcademicsClient.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Header } from '@/components/layout/header'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -32,7 +31,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 +164,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, }); }; @@ -229,7 +231,10 @@ export default function AcademicsPage() { if (!selectedTeamId) { return ( <> -
+
+

Academics

+

Track student-athlete academic progress and eligibility

+
} @@ -245,7 +250,10 @@ export default function AcademicsPage() { if (loading) { return ( <> -
+
+

Academics

+

Track student-athlete academic progress and eligibility

+
{/* Summary skeleton */}
@@ -299,7 +307,10 @@ export default function AcademicsPage() { if (students.length === 0) { return ( <> -
+
+

Academics

+

Track student-athlete academic progress and eligibility

+
} @@ -331,17 +342,33 @@ 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 ( <> -
+
+

Academics

+

Track student-athlete academic progress and eligibility

+
{/* Error alert */} @@ -472,8 +499,8 @@ export default function AcademicsPage() { aria-label="GPA" /> ) : ( -

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

+ {gpaDisplay(student)}

)}

GPA

@@ -533,8 +560,8 @@ export default function AcademicsPage() { > {standingLabel(student.academic_standing)} - - {student.is_eligible ? 'Eligible' : 'Ineligible'} + + {eligibilityLabel(student.is_eligible)} )} @@ -619,8 +646,8 @@ export default function AcademicsPage() { aria-label="GPA" /> ) : ( - - {student.gpa !== null ? student.gpa.toFixed(2) : 'N/A'} + + {gpaDisplay(student)} )} @@ -681,8 +708,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,13 @@ 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 }) + // Matches PostgREST's own max-rows=1000 cap — 500 was leaving rows on + // the table for teams with >500 events in this 455-day window even + // though the server would happily return up to 1000. + .limit(1000), supabase .from('baseball_team_members') .select('player_id, baseball_players!inner(id, first_name, last_name, avatar_url)') @@ -99,6 +114,14 @@ export default async function BaseballCalendarPage() { .maybeSingle(), ]); + // A DB/RLS/schema failure on the primary events read must not collapse + // into `events = []` — that renders identically to a genuinely empty + // calendar. Throw so the dashboard's error.tsx boundary renders an + // honest failure instead. + if (eventsResult.error) { + throw new Error('Could not load calendar events.'); + } + // Map baseball_events → CalendarEvent. Row is annotated because the query // uses fromUntyped (the generated types drift from the live schema). // All-day events are normalized to local midnight so the week/day grid @@ -122,9 +145,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 || '', @@ -215,66 +242,17 @@ export default async function BaseballCalendarPage() { return acc; }, {}); - // ── College coach with no team: recruiting-focused empty state ───────────── + // ── No team resolved: college coaches get the recruiting-focused empty + // state (they're pure recruiters — no team is expected); every other + // no-team case (non-college coach, or a player with no team) gets a + // distinct "no team assigned" state instead of falling through to the + // generic calendar shell with nothing to show. ────────────────────────── - 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()) { + if (!teamId) { 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/camps/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx index 9133318f9..11fe502a8 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { Header } from '@/components/layout/header'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -222,7 +221,9 @@ export default function CampDetailPage() { if (loading) { return ( <> -
+
+

Camp Details

+
); @@ -231,7 +232,9 @@ export default function CampDetailPage() { if (!camp) { return ( <> -
+
+

Camp Not Found

+
} @@ -250,17 +253,20 @@ export default function CampDetailPage() { return ( <> -
+
+
+

{camp.name}

+ {camp.organization?.name && ( +

{camp.organization.name}

+ )} +
-
+
{/* Camp Info */} diff --git a/src/app/baseball/(dashboard)/dashboard/colleges/page.tsx b/src/app/baseball/(dashboard)/dashboard/colleges/page.tsx index b98ba0e88..bda9c853a 100644 --- a/src/app/baseball/(dashboard)/dashboard/colleges/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/colleges/page.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { ShineEffect } from '@/components/ui/shine-effect'; -import { Header } from '@/components/layout/header'; import { CollegeCard } from '@/components/features/college-card'; import { Select } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; @@ -50,10 +49,14 @@ export default function CollegesPage() { return ( <> -
0 ? ` • ${interestedCount} in your interests` : ''}`} - /> +
+
+

Discover Colleges

+

+ {colleges.length} colleges{interestedCount > 0 ? ` • ${interestedCount} in your interests` : ''} +

+
+
{/* Filters */}
diff --git a/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx b/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx index 6a79c8b0d..334731231 100644 --- a/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/compare/CompareClient.tsx @@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -import { Header } from '@/components/layout/header'; import { Card, CardContent } from '@/components/ui/card'; import { Button, IconButton } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -131,7 +130,12 @@ function CompareContent() { if (loading && playerIds.length > 0) { return ( <> -
+
+
+

Compare Players

+

Side-by-side player comparison

+
+
{/* Skeleton for search area */} @@ -165,7 +169,12 @@ function CompareContent() { if (loadError && playerIds.length > 0) { return ( <> -
+
+
+

Compare Players

+

Side-by-side player comparison

+
+
-
0 ? `Comparing ${players.length} players` : 'Select players to compare'} - /> +
+
+

Compare Players

+

+ {players.length > 0 ? `Comparing ${players.length} players` : 'Select players to compare'} +

+
+
{/* Add Players Section */} @@ -321,7 +334,15 @@ function CompareContent() { export default function ComparePage() { return ( -
}> + +
+
+

Compare Players

+

Side-by-side player comparison

+
+
+ + }>
); 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..fa81b394e --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/dev-plan/DevPlanClient.tsx @@ -0,0 +1,635 @@ +'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'; + +// Parses a date-only 'YYYY-MM-DD' string as LOCAL midnight (not UTC midnight), +// avoiding the off-by-one that `new Date('YYYY-MM-DD')` + setHours(0,0,0,0) +// produces for negative-UTC-offset users. +function parseLocalDate(s: string): Date { + const [y, m, d] = s.split('-').map(Number); + if (y === undefined || m === undefined || d === undefined) return new Date(s); + return new Date(y, m - 1, d); +} + +// Helper to calculate days until/since a date +function getDaysUntil(dateStr: string): { days: number; label: string; isOverdue: boolean; isUpcoming: boolean } { + const target = parseLocalDate(dateStr); + const today = new Date(); + today.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) { + // useAuth always resolves `loading` in its own finally block, even when + // the session settles with no player row (role mismatch or missing + // profile) — so this branch must resolve isLoading itself, or a + // player-role session with no player record strands on DevPlanSkeleton + // forever. + setIsLoading(false); + 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(() => { + // Wait for auth to resolve, then always run fetchPlan — including when it + // resolves with no player row. Gating this call on `player?.id` meant + // fetchPlan (and its isLoading(false)) never ran for that case, stranding + // the view on DevPlanSkeleton indefinitely. + if (authLoading) return; + fetchPlan(); + }, [authLoading, 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 = parseLocalDate(g.target_date); + 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 {parseLocalDate(plan.start_date).toLocaleDateString()} : null} + {plan.start_date && plan.end_date ? · : null} + {plan.end_date ? Ends {parseLocalDate(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..3ee45e22a 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx @@ -2,10 +2,13 @@ import { useCallback, useEffect, useState, useTransition } from 'react'; import { useParams } from 'next/navigation'; -import { Header } from '@/components/layout/header'; +import Link from 'next/link'; import { PageLoading } from '@/components/ui/loading'; import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { PlanDetail } from '@/components/baseball/dev-plans/PlanDetail'; +import { BreadcrumbLabel } from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; +import { IconChevronLeft } from '@/components/icons'; import { useAuth } from '@/hooks/use-auth'; import { useToast } from '@/components/ui/sonner'; import { @@ -22,6 +25,7 @@ export default function DevPlanDetailPage() { const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); + const [fetchError, setFetchError] = useState(null); const [isPending, startTransition] = useTransition(); const [pendingGoalId, setPendingGoalId] = useState(null); @@ -33,10 +37,28 @@ export default function DevPlanDetailPage() { const data = await getDevPlanForCoach(params.id); setPlan(data); setNotFound(false); + setFetchError(null); } catch (error) { console.error('Error fetching dev plan:', error); setPlan(null); - setNotFound(true); + const message = error instanceof Error ? error.message : ''; + // getDevPlanForCoach throws either a raw "no rows" Postgrest error (the + // plan id doesn't exist) or a deliberate "you do not have permission" + // error for a plan owned by another coach — both render as the same + // "not found" state (never reveal existence to a coach who can't view + // it). Anything else — auth failures, network errors, unexpected + // server errors — is a real failure and must not masquerade as a + // missing plan. + const isGenuineNotFound = + message === 'You do not have permission to view this plan' || + /no rows|PGRST116/i.test(message); + if (isGenuineNotFound) { + setNotFound(true); + setFetchError(null); + } else { + setNotFound(false); + setFetchError(message || 'Could not load this plan. Please try again.'); + } } finally { setLoading(false); } @@ -85,7 +107,19 @@ export default function DevPlanDetailPage() { if (authLoading || loading) { return ( <> -
+
+ +
@@ -96,7 +130,19 @@ export default function DevPlanDetailPage() { if (user?.role !== 'coach') { return ( <> -
+
+ +
@@ -108,10 +154,56 @@ export default function DevPlanDetailPage() { ); } + if (fetchError) { + return ( + <> +
+ +
+
+
+

Couldn't load this plan

+

{fetchError}

+ +
+
+ + ); + } + if (notFound || !plan) { return ( <> -
+
+ +
This development plan could not be found. @@ -123,7 +215,23 @@ 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. */} + +
+ +
-
+
+
+

Development Plans

+

Create and track player development

+
-
+
{/* Stats Overview */}
diff --git a/src/app/baseball/(dashboard)/dashboard/discover/DiscoverClient.tsx b/src/app/baseball/(dashboard)/dashboard/discover/DiscoverClient.tsx index b3bb5dfb6..f3f3dec98 100644 --- a/src/app/baseball/(dashboard)/dashboard/discover/DiscoverClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/discover/DiscoverClient.tsx @@ -7,7 +7,6 @@ import { FilterPanel } from '@/components/coach/discover/FilterPanel'; import { DiscoverView } from '@/components/coach/discover/DiscoverView'; import { PlayerPeekPanel } from '@/components/panels/PlayerPeekPanel'; import { TeamPeekPanel } from '@/components/panels/TeamPeekPanel'; -import { Header } from '@/components/layout/header'; import { Button, IconButton } from '@/components/ui/button'; import { PageLoading } from '@/components/ui/loading'; import { IconFilter, IconX } from '@/components/icons'; @@ -335,7 +334,12 @@ function DiscoverContent() { if (!coach) { return ( <> -
+
+
+

Discover

+

Coach access required

+
+

@@ -363,14 +367,16 @@ function DiscoverContent() { return ( <> -

0 ? ` \u2014 ${playerCount.toLocaleString()} players found` : ''}` - : `Explore programs with talent${teamCount > 0 ? ` \u2014 ${teamCount.toLocaleString()} programs found` : ''}` - } - /> +
+
+

Discover

+

+ {filters.mode === 'players' + ? `Find your next recruit${playerCount > 0 ? ` \u2014 ${playerCount.toLocaleString()} players found` : ''}` + : `Explore programs with talent${teamCount > 0 ? ` \u2014 ${teamCount.toLocaleString()} programs found` : ''}`} +

+
+
{/* Error Alert */} diff --git a/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx b/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx index 4870be4a3..9b61bf878 100644 --- a/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx +++ b/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx @@ -1,13 +1,6 @@ 'use client'; import { useMemo, useRef, useState } from 'react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Select } from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; -import { IconFile, IconUpload, IconSearch } from '@/components/icons'; -import { DocumentCard } from '@/components/baseball/documents/DocumentCard'; import { DocumentPreview } from '@/components/baseball/documents/DocumentPreview'; import { UploadNewVersionModal } from '@/components/baseball/documents/UploadNewVersionModal'; import { EditDocumentModal, type EditDocumentSaveData } from '@/components/baseball/documents/EditDocumentModal'; @@ -25,9 +18,8 @@ import { uploadNewVersion, } from '@/app/baseball/actions/documents'; import { useToast } from '@/components/ui/sonner'; -import { cn } from '@/lib/utils'; import { DocumentsFairway } from '@/components/baseball/documents/DocumentsFairway'; -import { isRedesignEnabled, fairwayScope } from '@/lib/redesign/flag'; +import { fairwayScope } from '@/lib/redesign/flag'; const UPLOAD_ACCEPT = 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*,text/plain,video/mp4,video/webm,video/quicktime'; @@ -240,273 +232,95 @@ export function DocumentsClient({ documents: initialDocuments, coachId, teamId, setMovingDoc(null); } - if (isRedesignEnabled()) { - return ( -
- 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..bfcf79a4c 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,10 @@ export default async function BaseballDocumentsPage() { const ctx = await getActiveBaseballContext(); if (!ctx?.activeTeamId) { return ( -
- +
); @@ -41,7 +40,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,34 +389,29 @@ 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} +
+
)} {/* Skeleton while loading */} {loading ? ( - ) : filteredEvents.length === 0 ? ( - /* Honest empty state */ -
-
- -
-

No upcoming events

-

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

- -
+ ) : fetchError ? null : filteredEvents.length === 0 ? ( + setShowCreateModal(true)}> + + Create Your First Event + + } + /> ) : (
{Object.entries(groupedEvents).map(([date, dateEvents]) => ( diff --git a/src/app/baseball/(dashboard)/dashboard/journey/page.tsx b/src/app/baseball/(dashboard)/dashboard/journey/page.tsx index f558ad0ed..caf26152a 100644 --- a/src/app/baseball/(dashboard)/dashboard/journey/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/journey/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Header } from '@/components/layout/header'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -202,7 +201,12 @@ export default function JourneyPage() { if (loading) { return ( <> -
+
+
+

My Journey

+

Track your recruiting progress

+
+
); @@ -212,17 +216,18 @@ export default function JourneyPage() { return ( <> -
+
+
+

My Journey

+

Track your recruiting progress with schools

+
-
+
{/* Stats Overview */} diff --git a/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx b/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx index e43f1296c..0942c5132 100644 --- a/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/lift/[sessionId]/page.tsx @@ -2,15 +2,146 @@ // 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 { logServerError } from '@/lib/server-error-logger'; +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'; + +interface SessionFetchResult { + session: HelmLiftingSessionWithExercises | null; + /** True when a query genuinely errored (backend/RLS) — distinct from a + * clean 0-rows result, which must NOT be treated as an error. */ + error: boolean; +} + +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, error: sessionError } = (await fromUntyped( + supabase, + 'helm_lifting_sessions', + ) + .select('*') + .eq('id', sessionId) + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .maybeSingle()) as { data: HelmLiftingSessionRow | null; error: unknown }; + + if (sessionError) { + await logServerError( + `[lift/[sessionId]] fetchSessionWithExercises session query failed: ${ + (sessionError as Error)?.message ?? String(sessionError) + }`, + { + action: 'baseball.liftSessionPage.fetchSession', + metadata: { sessionId, athleteId, organizationId }, + }, + ); + return { session: null, error: true }; + } + + if (!rawSession) return { session: null, error: false }; + + const { data: exerciseRows, error: exercisesError } = (await fromUntyped( + supabase, + 'helm_lifting_session_exercises', + ) + .select('*') + .eq('session_id', sessionId) + .order('order_index', { ascending: true })) as { + data: HelmLiftingSessionExerciseRow[] | null; + error: unknown; + }; + + if (exercisesError) { + await logServerError( + `[lift/[sessionId]] fetchSessionWithExercises exercises query failed: ${ + (exercisesError as Error)?.message ?? String(exercisesError) + }`, + { + action: 'baseball.liftSessionPage.fetchSession', + metadata: { sessionId, athleteId, organizationId }, + }, + ); + return { session: null, error: true }; + } + + const exercises = exerciseRows ?? []; + const exerciseIds = exercises.map((ex) => ex.id); + const setsByExercise = new Map(); + + if (exerciseIds.length > 0) { + const { data: setResults, error: setsError } = (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; + error: unknown; + }; + + if (setsError) { + await logServerError( + `[lift/[sessionId]] fetchSessionWithExercises sets query failed: ${ + (setsError as Error)?.message ?? String(setsError) + }`, + { + action: 'baseball.liftSessionPage.fetchSession', + metadata: { sessionId, athleteId, organizationId }, + }, + ); + return { session: null, error: true }; + } + + for (const result of setResults ?? []) { + const arr = setsByExercise.get(result.session_exercise_id) ?? []; + arr.push(result); + setsByExercise.set(result.session_exercise_id, arr); + } + } + + return { + session: { + ...rawSession, + exercises: exercises.map((ex) => ({ ...ex, sets: setsByExercise.get(ex.id) ?? [] })), + }, + error: false, + }; +} export default async function PlayerLiftSessionPage({ params, @@ -24,12 +155,41 @@ 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, teamId, athleteId } = athleteCtx; + + const [{ session, error: sessionFetchError }, readinessSubmittedToday] = await Promise.all([ + fetchSessionWithExercises(sessionId, athleteId, organizationId), + hasReadinessCheckinToday(athleteId, organizationId, teamId), + ]); + + if (sessionFetchError) { + return ( +
+
+

Unable to load this session

+

+ Something went wrong loading this lift session. Please refresh the page to try again. +

+
+
+ ); + } + 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..716991098 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/lift/_lift-athlete-context.ts @@ -0,0 +1,126 @@ +// ============================================================================= +// 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 { logServerError } from '@/lib/server-error-logger'; +import { + resolveBaseballLiftingOrg, + resolveMyBaseballAthleteId, +} from '@/lib/lifting/resolve-baseball-context'; +import { resolveTeamTimezone, todayIsoInTz } from '@/lib/baseball/daily-contract/contract-day'; + +export interface PlayerLiftAthleteContext { + organizationId: string; + teamId: string; + athleteId: string; +} + +/** + * baseball_players.id -> ALL active baseball_teams.id memberships. + * + * A player can legitimately hold TWO active baseball_team_members rows + * (High School + Showcase). Previously this collapsed the set to the earliest row + * via `.limit(1).maybeSingle()`; if THAT team's org had no Lift Lab athlete + * seeded, resolution failed even though the player's other active team + * would have resolved fine. Return the full ordered list so the caller can + * try each membership in turn. + */ +async function resolvePlayerTeamIds( + supabase: Awaited>, + playerId: string, +): Promise { + const { data, error } = await supabase + .from('baseball_team_members') + .select('team_id') + .eq('player_id', playerId) + .eq('status', 'active') + .order('created_at', { ascending: true }); + if (error) { + await logServerError( + `[lift-athlete-context] resolvePlayerTeamIds query failed: ${error.message}`, + { action: 'baseball.liftAthleteContext.resolvePlayerTeamIds', metadata: { playerId } }, + ); + return []; + } + return (data ?? []) + .map((row) => row.team_id as string | undefined) + .filter((teamId): teamId is string => Boolean(teamId)); +} + +/** + * Full resolution chain: baseball playerId -> teamId -> organizationId -> + * helm_lifting_athletes.id. Tries EVERY active team membership (High School + + * Showcase dual-team players) before giving up, since a Lift Lab athlete row + * may only be seeded for one of the player's active teams/orgs. Returns null + * only once all active memberships have been exhausted (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 teamIds = await resolvePlayerTeamIds(supabase, playerId); + + for (const teamId of teamIds) { + const liftCtx = await resolveBaseballLiftingOrg(teamId); + if (!liftCtx) continue; + + const athleteId = await resolveMyBaseballAthleteId(liftCtx.organizationId); + if (!athleteId) continue; + + return { organizationId: liftCtx.organizationId, teamId, athleteId }; + } + + return null; +} + +/** Whether the athlete has a helm_lifting_readiness_checkins row for today. */ +export async function hasReadinessCheckinToday( + athleteId: string, + organizationId: string, + teamId: string, +): Promise { + const supabase = await createClient(); + const today = todayIsoInTz(await resolveTeamTimezone(supabase, teamId)); + + const { data, error } = (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; error: unknown }; + + if (error) { + await logServerError( + `[lift-athlete-context] hasReadinessCheckinToday query failed: ${ + (error as Error)?.message ?? String(error) + }`, + { + action: 'baseball.liftAthleteContext.hasReadinessCheckinToday', + metadata: { athleteId, organizationId, teamId }, + }, + ); + } + + 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..c7a1ada6a 100644 --- a/src/app/baseball/(dashboard)/dashboard/lift/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/lift/page.tsx @@ -2,16 +2,124 @@ // 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 { logServerError } from '@/lib/server-error-logger'; +import { resolveTeamTimezone, todayIsoInTz } from '@/lib/baseball/daily-contract/contract-day'; +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, + teamId: string, +): Promise<{ upcoming: HelmLiftingSessionRow[]; recent: HelmLiftingSessionRow[]; error: boolean }> { + const supabase = await createClient(); + const today = todayIsoInTz(await resolveTeamTimezone(supabase, teamId)); + + // Today + future and overdue-but-still-open are fetched as SEPARATE + // bounded queries (each with its own .limit()), not one combined query + // capped at 20. A single capped query orders ascending by scheduled_date, + // so overdue-open rows (earlier dates) sort BEFORE today/future rows — + // 20+ overdue-open sessions would fill the entire cap and push today's + // session out of the result set entirely, showing "No lift today" even + // though a session exists. + const [ + { data: currentFutureRows, error: currentFutureError }, + { data: overdueOpenRows, error: overdueOpenError }, + ] = (await Promise.all([ + fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .gte('scheduled_date', today) + .order('scheduled_date', { ascending: true }) + .limit(20), + fromUntyped(supabase, 'helm_lifting_sessions') + .select('*') + .eq('athlete_id', athleteId) + .eq('organization_id', organizationId) + .in('status', OPEN_STATUSES) + .lt('scheduled_date', today) + .order('scheduled_date', { ascending: true }) + .limit(20), + ])) as [ + { data: HelmLiftingSessionRow[] | null; error: unknown }, + { data: HelmLiftingSessionRow[] | null; error: unknown }, + ]; + + if (currentFutureError) { + await logServerError( + `[lift/page] fetchPlayerSessions current/future query failed: ${ + (currentFutureError as Error)?.message ?? String(currentFutureError) + }`, + { action: 'lift.fetchPlayerSessions', metadata: { athleteId, teamId, phase: 'current_future' } }, + ); + } + if (overdueOpenError) { + await logServerError( + `[lift/page] fetchPlayerSessions overdue-open query failed: ${ + (overdueOpenError as Error)?.message ?? String(overdueOpenError) + }`, + { action: 'lift.fetchPlayerSessions', metadata: { athleteId, teamId, phase: 'overdue_open' } }, + ); + } + + // Overdue-open rows are all < today and already ascending, so concatenating + // them ahead of the current/future rows (also ascending) preserves overall + // chronological order without needing an extra merge-sort. + const upcoming = [...(overdueOpenRows ?? []), ...(currentFutureRows ?? [])]; + + const { data: recentRows, error: recentError } = (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; error: unknown }; + + if (recentError) { + await logServerError( + `[lift/page] fetchPlayerSessions recent query failed: ${ + (recentError as Error)?.message ?? String(recentError) + }`, + { action: 'lift.fetchPlayerSessions', metadata: { athleteId, phase: 'recent' } }, + ); + } + + return { + upcoming, + recent: recentRows ?? [], + error: Boolean(currentFutureError || overdueOpenError || recentError), + }; +} export default async function PlayerLiftPage() { const context = await getActiveBaseballContext(); @@ -20,22 +128,50 @@ 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), - getPlayerLiftOnboardingState(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, teamId, athleteId } = athleteCtx; + + const [{ upcoming, recent, error: sessionsError }, readinessSubmittedToday, onboarding] = + await Promise.all([ + fetchPlayerSessions(athleteId, organizationId, teamId), + hasReadinessCheckinToday(athleteId, organizationId, teamId), + getPlayerLiftOnboardingState(context.activePlayerId), + ]); + + if (sessionsError) { + return ( +
+
+

Unable to load your lift sessions

+

+ Something went wrong loading your Lift Lab data. Please refresh the page to try again. +

+
+
+ ); + } 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..146e3e324 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} + /> + } + /> ); } export default function MessagesPage() { return ( +
}> 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/organization/OrganizationClient.tsx b/src/app/baseball/(dashboard)/dashboard/organization/OrganizationClient.tsx index 70deec05f..4b5b1a36c 100644 --- a/src/app/baseball/(dashboard)/dashboard/organization/OrganizationClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/organization/OrganizationClient.tsx @@ -1,20 +1,59 @@ 'use client'; import { useState } from 'react'; -import { Header } from '@/components/layout/header'; +import Link from 'next/link'; import { TeamSelector } from '@/components/baseball/showcase/TeamSelector'; import { OrgDashboard } from '@/components/baseball/showcase/OrgDashboard'; +import { IconUsers, IconFlag, IconChevronRight } from '@/components/icons'; + +const ORGANIZATION_LANDING_CARDS = [ + { + href: '/baseball/dashboard/teams', + label: 'Teams', + description: 'The full list of teams in your organization — rosters, staff, and program details.', + icon: IconUsers, + }, + { + href: '/baseball/dashboard/events', + label: 'Events', + description: 'Every showcase and tournament on the calendar, across all of your teams.', + icon: IconFlag, + }, +] as const; export default function OrganizationDashboardPage() { const [teamFilterId, setTeamFilterId] = useState('all'); return ( <> -
+
+

Organization Dashboard

+

Multi-team overview and roster management

+
+
+ {ORGANIZATION_LANDING_CARDS.map(({ href, label, description, icon: Icon }) => ( + +
+ + + +
+

{label}

+

{description}

+
+ +
+ + ))} +
diff --git a/src/app/baseball/(dashboard)/dashboard/performance/builder/__tests__/page.test.tsx b/src/app/baseball/(dashboard)/dashboard/performance/builder/__tests__/page.test.tsx new file mode 100644 index 000000000..e7f0c6d0f --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/performance/builder/__tests__/page.test.tsx @@ -0,0 +1,119 @@ +// ============================================================================= +// LiftBuilderPage — team-local "today"/"weekOf" regression. +// +// Mirrors the readiness page's team-local-date regression test +// (readiness/__tests__/page.test.tsx). The same UTC-vs-team-local bug class +// (deriving the day from server UTC instead of `resolveTeamTimezone` + +// `todayIsoInTz`) required fixing across this cohort — readiness page, +// player-today-lift.ts, PerformanceDashboardClient/PlayerLiftToday, and this +// builder page — but only the readiness page had a test locking in the fix. +// `getGroupSorenessFlags(scope, today)` and `getGroupAvailability(scope, +// weekOf)` both depend on this anchoring; this test pins an evening-US +// instant and asserts both read-models are invoked with the TEAM-LOCAL date +// (and its Monday), never the server's UTC date. +// ============================================================================= + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render } from '@testing-library/react'; + +import { createFakeSupabase } from '@/test/fixtures/fake-supabase'; + +// Spy on what the client actually receives (user-visible state), not the +// page's raw JSX shape. +const builderClientMock = vi.hoisted(() => vi.fn((_props: unknown) => null)); + +vi.mock('@/components/baseball/performance/LiftBuilderClient', () => ({ + LiftBuilderClient: (props: unknown) => builderClientMock(props), +})); + +vi.mock('@/lib/baseball/active-context', () => ({ + getActiveBaseballContext: vi.fn(async () => ({ + activeTeamId: 'team-1', + activeRole: 'coach', + activePlayerId: null, + })), +})); + +vi.mock('@/lib/baseball/capabilities', () => ({ + resolveBaseballCapabilities: vi.fn(async () => ({ can_manage_lifting: true })), +})); + +// Read-model calls are spied on directly so this test can assert on the +// EXACT `date` / `weekOf` arguments the page passed in, without needing to +// fake the much larger helm_lifting_* + resolveBaseballLiftingOrg chain each +// read-model queries internally. +const getBuilderExerciseLibraryMock = vi.hoisted(() => vi.fn(async (_teamId: string) => [])); +const getGroupSorenessFlagsMock = vi.hoisted(() => + vi.fn(async (_scope: unknown, _date: string) => []), +); +const getGroupAvailabilityMock = vi.hoisted(() => + vi.fn(async (_scope: unknown, _weekOf: string) => []), +); + +vi.mock('@/lib/baseball/read-models/lift-builder', () => ({ + getBuilderExerciseLibrary: getBuilderExerciseLibraryMock, + getGroupSorenessFlags: getGroupSorenessFlagsMock, + getGroupAvailability: getGroupAvailabilityMock, +})); + +// The ONLY direct Supabase call this page makes is the `baseball_teams. +// timezone` lookup inside resolveTeamTimezone (real, unmocked below) plus a +// light `helm_lifting_groups` list read — both covered by the shared +// fake-supabase fixture. +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => + createFakeSupabase({ + tables: { + baseball_teams: [{ id: 'team-1', timezone: 'America/Los_Angeles' }], + helm_lifting_groups: [], + }, + }), + ), +})); + +// Deliberately NOT mocking '@/lib/baseball/daily-contract/contract-day' — +// this test exercises the REAL todayIsoInTz/resolveTeamTimezone so it fails +// if the page ever regresses to a server-UTC slice. +import { todayIsoInTz } from '@/lib/baseball/daily-contract/contract-day'; + +import LiftBuilderPage from '../page'; + +describe('LiftBuilderPage — team-local today/weekOf (evening US timestamp)', () => { + afterEach(() => { + vi.useRealTimers(); + builderClientMock.mockClear(); + getBuilderExerciseLibraryMock.mockClear(); + getGroupSorenessFlagsMock.mockClear(); + getGroupAvailabilityMock.mockClear(); + }); + + it('anchors the soreness/availability reads to the team-local date, not server UTC', 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 server-UTC slice would resolve + // '2026-06-24' (a Wednesday); team-local must be '2026-06-23' (a Tuesday), + // whose Monday is '2026-06-22'. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-24T04:00:00Z')); + + const expectedTeamLocalToday = todayIsoInTz('America/Los_Angeles', new Date()); + expect(expectedTeamLocalToday).toBe('2026-06-23'); + const expectedWeekOf = '2026-06-22'; + + const element = await LiftBuilderPage(); + render(element); + + expect(getGroupSorenessFlagsMock).toHaveBeenCalledTimes(1); + expect(getGroupSorenessFlagsMock.mock.calls[0]?.[1]).toBe(expectedTeamLocalToday); + expect(getGroupSorenessFlagsMock.mock.calls[0]?.[1]).not.toBe('2026-06-24'); + + expect(getGroupAvailabilityMock).toHaveBeenCalledTimes(1); + expect(getGroupAvailabilityMock.mock.calls[0]?.[1]).toBe(expectedWeekOf); + expect(getGroupAvailabilityMock.mock.calls[0]?.[1]).not.toBe('2026-06-25'); // Monday of the wrong UTC day + + // The props the client actually renders with must reflect the same + // team-local weekOf, not a server-UTC-derived one. + expect(builderClientMock).toHaveBeenCalledTimes(1); + const props = builderClientMock.mock.calls[0]?.[0] as { weekOf: string }; + expect(props.weekOf).toBe(expectedWeekOf); + }); +}); diff --git a/src/app/baseball/(dashboard)/dashboard/performance/builder/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/builder/page.tsx index 8829c2835..a08d4c088 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/builder/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/builder/page.tsx @@ -23,6 +23,10 @@ import { createClient } from '@/lib/supabase/server'; import { fromUntyped } from '@/lib/supabase/untyped'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; +import { + resolveTeamTimezone, + todayIsoInTz, +} from '@/lib/baseball/daily-contract/contract-day'; import { getBuilderExerciseLibrary, getGroupSorenessFlags, @@ -34,11 +38,6 @@ import { LiftBuilderClient } from '@/components/baseball/performance/LiftBuilder // Helpers // ============================================================================= -/** ISO YYYY-MM-DD for today (UTC). */ -function todayYmd(): string { - return new Date().toISOString().slice(0, 10); -} - /** ISO YYYY-MM-DD for the Monday of the week containing `ymd` (UTC). */ function mondayOf(ymd: string): string { const d = new Date(`${ymd}T00:00:00Z`); @@ -64,8 +63,9 @@ export default async function LiftBuilderPage() { const caps = await resolveBaseballCapabilities(teamId); if (!caps.can_manage_lifting) redirect('/baseball/dashboard/performance'); - // ── Date anchors ──────────────────────────────────────────────────────────── - const today = todayYmd(); + // ── Date anchors (team-local, not server-UTC) ────────────────────────────── + const supabase = await createClient(); + const today = todayIsoInTz(await resolveTeamTimezone(supabase, teamId)); const weekOf = mondayOf(today); // ── Team-scoped builder scope ──────────────────────────────────────────────── @@ -79,7 +79,6 @@ export default async function LiftBuilderPage() { ]); // ── Light group list (for breadcrumb links / scope switcher hints) ─────────── - const supabase = await createClient(); const { data: groupRows } = await fromUntyped(supabase, 'helm_lifting_groups') .select('id, name') .eq('team_id', teamId) diff --git a/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx index 90558fbc1..ff7b5e9cb 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/groups/page.tsx @@ -1,26 +1,134 @@ // ============================================================================= // 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 { logServerError } from '@/lib/server-error-logger'; +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<{ groups: GroupWithMembers[]; error: boolean }> { + const supabase = await createClient(); + + const { data: groups, error: groupsError } = (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; error: unknown }; + + if (groupsError) { + await logServerError( + `[performance/groups] getGroupsWithMembers groups query failed: ${ + (groupsError as Error)?.message ?? String(groupsError) + }`, + { action: 'baseball.strengthGroups.getGroupsWithMembers', metadata: { organizationId, teamId } }, + ); + return { groups: [], error: true }; + } + + if (!groups || groups.length === 0) return { groups: [], error: false }; + + const { data: members, error: membersError } = (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; error: unknown }; + + if (membersError) { + await logServerError( + `[performance/groups] getGroupsWithMembers members query failed: ${ + (membersError as Error)?.message ?? String(membersError) + }`, + { action: 'baseball.strengthGroups.getGroupsWithMembers', metadata: { organizationId, teamId } }, + ); + return { groups: [], error: true }; + } + + 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: groups.map((g) => { + const athleteIds = membersByGroup.get(g.id) ?? []; + return { ...g, member_count: athleteIds.length, member_athlete_ids: athleteIds }; + }), + error: false, + }; +} + +async function getAthletes( + organizationId: string, + teamId: string, +): Promise<{ + athletes: Array>; + error: boolean; +}> { + const supabase = await createClient(); + const { data, error } = (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; + error: unknown; + }; + + if (error) { + await logServerError( + `[performance/groups] getAthletes query failed: ${(error as Error)?.message ?? String(error)}`, + { action: 'baseball.strengthGroups.getAthletes', metadata: { organizationId, teamId } }, + ); + return { athletes: [], error: true }; + } + + return { athletes: data ?? [], error: false }; +} export default async function StrengthGroupsPage() { const context = await getActiveBaseballContext(); @@ -31,14 +139,40 @@ 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 [groupsResult, athletesResult] = liftCtx + ? await Promise.all([ + getGroupsWithMembers(liftCtx.organizationId, teamId), + getAthletes(liftCtx.organizationId, teamId), + ]) + : [ + { groups: [] as GroupWithMembers[], error: false }, + { athletes: [] as Array>, error: false }, + ]; + + // Surface a genuine query/RLS failure distinctly from a legitimate + // "no groups seeded yet" empty state — swallowing errors here made both + // look identical. + if (groupsResult.error || athletesResult.error) { + return ( +
+
+

Unable to load strength groups

+

+ Something went wrong loading your Lift Lab groups. Please refresh the page to try again. +

+
+
+ ); + } 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..7d8e9ad4e 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/live/page.tsx @@ -1,35 +1,293 @@ // ============================================================================= // 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 { + resolveTeamTimezone, + todayIsoInTz, +} from '@/lib/baseball/daily-contract/contract-day'; +import { logServerError } from '@/lib/server-error-logger'; +import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +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'; + +const LIVE_ROOM_ACTION = 'baseball.performance.live.buildLiveRoomData'; + +type UntypedQueryError = { message: string } | null; + +async function logQueryError(table: string, error: UntypedQueryError, teamId: string) { + if (!error) return; + await logServerError(`Failed to load ${table}: ${error.message}`, { + action: LIVE_ROOM_ACTION, + sport: 'baseball', + teamId, + }); +} + +type LiveRoomResult = + | { + ok: true; + athletes: HelmLiftingLiveAthleteRow[]; + exerciseLibrary: Array<{ id: string; name: string; category: string | null }>; + } + | { ok: false }; + +async function buildLiveRoomData( + organizationId: string, + teamId: string, + canViewReadiness: boolean, +): Promise { + const supabase = await createClient(); + const today = todayIsoInTz(await resolveTeamTimezone(supabase, teamId)); + + const { data: sessions, error: sessionsError } = (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; error: UntypedQueryError }; + await logQueryError('helm_lifting_sessions', sessionsError, teamId); + + if (sessionsError) { + // A genuine query/RLS failure must never render identically to a + // legitimately empty live board (a real "no sessions today" is `!sessions + // || sessions.length === 0` with NO error) — surface it distinctly so + // the page renders the error notice instead of an empty athlete grid. + return { ok: false }; + } + + if (!sessions || sessions.length === 0) { + const { data: exercises, error: exercisesError } = (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; error: UntypedQueryError }; + await logQueryError('helm_lifting_exercises', exercisesError, teamId); + return { ok: true, athletes: [], exerciseLibrary: exercises ?? [] }; + } + + const sessionIds = sessions.map((s) => s.id); + const athleteIds = [...new Set(sessions.map((s) => s.athlete_id))]; + + // Fetched ahead of the Promise.all batch below because helm_lifting_set_results + // has no session_id column (only session_exercise_id) — the set-results query + // must filter on the exercise ids derived from this result, not sessionIds. + const { data: sessionExercises, error: sessionExercisesError } = (await fromUntyped(supabase, 'helm_lifting_session_exercises') + .select('*') + .in('session_id', sessionIds) + .order('order_index', { ascending: true })) as { data: HelmLiftingSessionExerciseRow[] | null; error: UntypedQueryError }; + await logQueryError('helm_lifting_session_exercises', sessionExercisesError, teamId); + + if (sessionExercisesError) { + // This is the load-bearing query for the write surface: a hard failure + // here yields exerciseIds = [] and every athlete rendering with + // exercises: [], total_exercises: 0 — indistinguishable from "athletes + // have no assigned work" (same error-vs-empty conflation `sessionsError` + // above already guards against). Surface it, don't degrade. + return { ok: false }; + } + + const exerciseIds = (sessionExercises ?? []).map((se) => se.id); + type LatestSetRow = Pick & { created_at: string }; + + const [ + { data: athletes, error: athletesError }, + { data: setResults, error: setResultsError }, + checkinsResult, + { data: availabilities, error: availabilitiesError }, + { data: groupMembers, error: groupMembersError }, + { data: exercises, error: exercisesError }, + ] = 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; + error: UntypedQueryError; + }>, + exerciseIds.length > 0 + ? (fromUntyped(supabase, 'helm_lifting_set_results') + .select('session_exercise_id, athlete_id, set_number, actual_load, rpe, created_at') + .in('session_exercise_id', exerciseIds) + .order('created_at', { ascending: false }) as Promise<{ data: LatestSetRow[] | null; error: UntypedQueryError }>) + : Promise.resolve({ data: [] as LatestSetRow[], error: null as UntypedQueryError }), + // 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; + error: UntypedQueryError; + }>) + : Promise.resolve({ data: [] as Array<{ athlete_id: string; readiness_score: number | null; readiness_band: string | null }>, error: null as UntypedQueryError }), + 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; + error: UntypedQueryError; + }>, + 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; + error: UntypedQueryError; + }>, + 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; + error: UntypedQueryError; + }>, + ]); + + await Promise.all([ + logQueryError('helm_lifting_athletes', athletesError, teamId), + logQueryError('helm_lifting_set_results', setResultsError, teamId), + logQueryError('helm_lifting_readiness_checkins', checkinsResult.error, teamId), + logQueryError('helm_lifting_availability_statuses', availabilitiesError, teamId), + logQueryError('helm_lifting_group_members', groupMembersError, teamId), + logQueryError('helm_lifting_exercises (live-room)', exercisesError, teamId), + ]); + + if (athletesError) { + // A failed athletes read silently drops every name/position (athleteMap + // stays empty) and every row falls back to null fields — same + // error-vs-empty conflation as sessionsError/sessionExercisesError above. + return { ok: false }; + } + + 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])); + + 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, + }); + } + } -interface PageProps { - searchParams: Promise<{ group?: string }>; + const liveAthletes: HelmLiftingLiveAthleteRow[] = sessions.map((session) => { + const athlete = athleteMap.get(session.athlete_id); + const seList = exercisesBySession.get(session.id) ?? []; + // Prefer the (order-first) assigned exercise over any completed one — the + // old `assigned || completed` find picked whichever came first in order, + // so once exercise 1 was completed and exercise 2 assigned, the row could + // keep showing exercise 1 as "current" instead of advancing. + const currentSe = + seList.find((e) => e.status === 'assigned') ?? + [...seList].reverse().find((e) => 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 { ok: true, 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 +301,47 @@ 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); + const liftCtx = await resolveBaseballLiftingOrg(teamId); - // 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); + // A missing lifting context is a setup gap, not a healthy empty room — it + // must never fall through to LiveWeightRoomClient with orgId="" (which + // would render the WRITABLE live surface pointed at no real org). Fail + // closed with the same explicit "not set up" card the programs list page + // uses, instead of degrading to `{ ok: true, athletes: [], ... }`. + if (!liftCtx) { + return ( +
+
+

Lift Lab access not set up

+

+ This team isn't linked to a Lifting Lab organization yet. Ask an admin to link it + before the live weight room can be used. +

+
+
+ ); } - // 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 liveRoomResult: LiveRoomResult = await buildLiveRoomData( + liftCtx.organizationId, + teamId, + canViewReadiness, + ); + + if (!liveRoomResult.ok) { + return ( +
+ +
+ ); } return ( - ); } diff --git a/src/app/baseball/(dashboard)/dashboard/performance/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/page.tsx index c56bfac3e..02232a76e 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/page.tsx @@ -22,6 +22,7 @@ import { redirect } from 'next/navigation'; import { createClient } from '@/lib/supabase/server'; +import { todayIsoInTz, resolveTeamTimezone } from '@/lib/baseball/daily-contract/contract-day'; import { fromUntyped } from '@/lib/supabase/untyped'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { resolveBaseballCapabilities } from '@/lib/baseball/capabilities'; @@ -85,6 +86,13 @@ export default async function PerformancePage() { const supabase = await createClient(); + // Team-local "today" computed ONCE here so both the readiness 7-day lookback + // window below and the client's check-in day math (passed as the `today` + // prop) use the exact same value — a server-UTC `new Date()` for one and a + // team-local ISO string for the other let the readiness window silently + // drift a day at the UTC boundary for teams west of Greenwich. + const today = todayIsoInTz(await resolveTeamTimezone(supabase, teamId)); + // Roster (RLS scopes to viewable players for this staff member). const { data: members } = await supabase .from('baseball_team_members') @@ -161,9 +169,8 @@ export default async function PerformancePage() { // --------------------------------------------------------------------------- let readiness: BaseballReadinessSummary[] = []; if (canViewReadiness && liftCtx) { - const today = new Date(); - const sevenDaysAgo = new Date(today); - sevenDaysAgo.setDate(today.getDate() - 7); + const sevenDaysAgo = new Date(`${today}T00:00:00Z`); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); const fromDate = sevenDaysAgo.toISOString().slice(0, 10); // Build the set of athlete ids for this team's roster. @@ -265,6 +272,7 @@ export default async function PerformancePage() { assignments={assignments} exercises={exercises} readiness={readiness} + today={today} embedded /> )} 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..e04478e4c 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,251 @@ // ============================================================================= // 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 { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { logServerError } from '@/lib/server-error-logger'; +import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; +import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +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>; + // Mirrors ProgramTreeResult.error — a failed athletes/groups read must stay + // distinguishable from "this team has no athletes/groups yet" so the page + // can surface it instead of rendering a silently blank assign roster. + error?: boolean; +} + +interface ProgramTreeResult { + tree: LiftProgramTree | null; + error: boolean; +} + +async function logProgramTreeError(step: string, error: unknown, metadata: Record) { + await logServerError( + `[performance/programs] getProgramTree ${step} query failed: ${ + (error as Error)?.message ?? String(error) + }`, + { action: 'baseball.programEditor.getProgramTree', metadata }, + ); +} + +async function getProgramTree( + programId: string, + organizationId: string, + teamId: string, +): Promise { + const supabase = await createClient(); + + const { data: program, error: programError } = (await fromUntyped(supabase, 'helm_lifting_programs') + .select('*') + .eq('id', programId) + .eq('organization_id', organizationId) + .eq('team_id', teamId) + .maybeSingle()) as { data: HelmLiftingProgramRow | null; error: unknown }; + + if (programError) { + await logProgramTreeError('program', programError, { programId, organizationId, teamId }); + return { tree: null, error: true }; + } + + if (!program) return { tree: null, error: false }; + + const { data: weeks, error: weeksError } = (await fromUntyped(supabase, 'helm_lifting_weeks') + .select('*') + .eq('program_id', programId) + .order('week_number', { ascending: true })) as { data: HelmLiftingWeekRow[] | null; error: unknown }; + + if (weeksError) { + await logProgramTreeError('weeks', weeksError, { programId }); + return { tree: null, error: true }; + } + + const weekList = weeks ?? []; + if (weekList.length === 0) { + return { tree: { program, weeks: [], exerciseNameMap: {} }, error: false }; + } + + const { data: days, error: daysError } = (await fromUntyped(supabase, 'helm_lifting_days') + .select('*') + .in('week_id', weekList.map((w) => w.id)) + .order('day_number', { ascending: true })) as { data: HelmLiftingDayRow[] | null; error: unknown }; + + if (daysError) { + await logProgramTreeError('days', daysError, { programId }); + return { tree: null, error: true }; + } + + const dayList = days ?? []; + const { data: sections, error: sectionsError } = (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; error: unknown }; + + if (sectionsError) { + await logProgramTreeError('sections', sectionsError, { programId }); + return { tree: null, error: true }; + } + + const sectionList = sections ?? []; + + // Paginated via fetchAllRowsResult: a dense program (many weeks x days x + // sections x sets) can push prescriptions past PostgREST's 1000-row cap, + // silently truncating the tree under a single unpaginated `.select()`. + // Ordered by the unique `id` column (not `order_index`, which repeats + // across sections) so page boundaries never drift; the display order is + // restored below with an explicit sort. + const { data: prescriptions, error: prescriptionsError } = sectionList.length > 0 + ? await fetchAllRowsResult((from, to) => + fromUntyped(supabase, 'helm_lifting_prescriptions') + .select('*') + .in('section_id', sectionList.map((s) => s.id)) + .order('id', { ascending: true }) + .range(from, to), + ) + : { data: [] as HelmLiftingPrescriptionRow[], error: null }; + + if (prescriptionsError) { + await logProgramTreeError('prescriptions', prescriptionsError, { programId }); + return { tree: null, error: true }; + } + + const prescList = (prescriptions ?? []) + .slice() + .sort((a, b) => a.order_index - b.order_index); + + const exerciseIds = [...new Set(prescList.map((p) => p.exercise_id).filter(Boolean) as string[])]; + const exerciseNameMap: Record = {}; + if (exerciseIds.length > 0) { + const { data: exRows, error: exRowsError } = (await fromUntyped(supabase, 'helm_lifting_exercises') + .select('id, name') + .in('id', exerciseIds)) as { data: Array<{ id: string; name: string }> | null; error: unknown }; + + if (exRowsError) { + await logProgramTreeError('exRows', exRowsError, { programId }); + return { tree: null, error: true }; + } + + 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 { + tree: { + program, + weeks: weekList.map((w) => ({ ...w, days: daysByWeekId.get(w.id) ?? [] })), + exerciseNameMap, + }, + error: false, + }; +} + +async function getAssignContext(organizationId: string, teamId: string): Promise { + const supabase = await createClient(); + + const [{ data: athletes, error: athletesError }, { data: groups, error: groupsError }] = 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; error: unknown }>, + 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; error: unknown }>, + ]); + + // getProgramTree (above) surfaces DB/RLS failures honestly; this read must + // not silently degrade to an empty assign roster with no signal — that + // would render as "no athletes/groups exist" and block the coach from + // assigning the program with nothing to explain why. + if (athletesError || groupsError) { + await logServerError( + `[performance/programs] getAssignContext query failed: ${ + (athletesError as Error)?.message ?? (groupsError as Error)?.message ?? 'unknown' + }`, + { action: 'baseball.programEditor.getAssignContext', metadata: { organizationId, teamId } }, + ); + } + + return { athletes: athletes ?? [], groups: groups ?? [], error: Boolean(athletesError || groupsError) }; +} export default async function ProgramEditorPage({ params, @@ -38,14 +262,39 @@ 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, error: treeError }, assign] = await Promise.all([ + getProgramTree(programId, liftCtx.organizationId, teamId), + getAssignContext(liftCtx.organizationId, teamId), + ]); - const assign = await getAssignContext(teamId); + if (treeError) { + return ( +
+ +
+ ); + } + + if (!tree) notFound(); return (
- + {assign.error && ( +
+ Couldn't load the athlete/group roster for assigning this program. The program + itself loaded fine — try refreshing to retry the roster. +
+ )} +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx index feb165543..a7308615a 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/programs/page.tsx @@ -1,24 +1,110 @@ // ============================================================================= // 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. +// +// The read path has the same gap: helm_lifting_programs RLS is gated by +// public.helm_lifting_can_view_org() (supabase/migrations/ +// 20260625000000_helm_lifting_identity.sql), which only allows an active +// helm_lifting_coaches row or a helm_lifting_org_viewers row — passing +// BaseballHelm's own can_manage_lifting gate is not enough. Rather than let +// getPrograms() silently come back empty for an unonboarded coach, resolve +// canView up front and render an explicit "not onboarded" state instead. // ============================================================================= 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 { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { resolveBaseballLiftingOrg } from '@/lib/lifting/resolve-baseball-context'; +import { resolveLiftingAccess } from '@/lib/lifting/access'; +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, error: programsError } = (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; error: { message: string } | null }; + + if (programsError) throw new Error('Could not load lifting programs.'); + if (!programs || programs.length === 0) return []; + + const ids = programs.map((p) => p.id); + + // Paginated (not a single unpaginated .in()) — a program list page can + // easily have >1000 total week/day rows across all its programs, and an + // unpaginated read would silently undercount week_count/day_count past the + // PostgREST row cap. + const { data: weeksData, error: weeksError } = await fetchAllRowsResult<{ id: string; program_id: string }>( + (from, to) => + fromUntyped(supabase, 'helm_lifting_weeks') + .select('id, program_id') + .in('program_id', ids) + .order('id', { ascending: true }) + .range(from, to), + ); + if (weeksError) throw new Error('Could not load lifting program weeks.'); + const weeks = weeksData ?? []; + + 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: daysData, error: daysError } = await fetchAllRowsResult<{ week_id: string }>((from, to) => + fromUntyped(supabase, 'helm_lifting_days') + .select('week_id') + .in('week_id', weeks.map((w) => w.id)) + .order('id', { ascending: true }) + .range(from, to), + ); + if (daysError) throw new Error('Could not load lifting program days.'); + for (const d of daysData ?? []) { + 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 +115,34 @@ 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 access = liftCtx ? await resolveLiftingAccess(liftCtx.organizationId) : null; + + if (!liftCtx || !access?.canView) { + return ( +
+
+

Lift Lab access not set up

+

+ {liftCtx + ? "You have programming access on the baseball side, but you haven't been onboarded into this team's Lifting Lab yet. Ask a Lift Lab admin to add you as a coach or org viewer to see programs here." + : "This team isn't linked to a Lifting Lab organization yet. Ask an admin to link it before programs can be created."} +

+
+
+ ); + } + + const programs = await getPrograms(liftCtx.organizationId, teamId); return (
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx b/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx index ca038c2ef..86d31feb3 100644 --- a/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx @@ -56,7 +56,7 @@ import { PlayerDetailModal } from '@/components/coach/PlayerDetailModal'; import { PlayerPeekPanel } from '@/components/panels/PlayerPeekPanel'; import { PositionPlanner } from '@/components/baseball/position-planner'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconUsers, IconLayoutGrid, IconList, IconTarget, IconTrash } from '@/components/icons'; +import { IconUsers, IconLayoutGrid, IconList, IconTarget, IconTrash, IconTrendingUp } from '@/components/icons'; import { useWatchlist } from '@/hooks/use-watchlist'; import { useAuth } from '@/hooks/use-auth'; import { useToast } from '@/components/ui/sonner'; @@ -829,6 +829,13 @@ export default function PipelinePage() {
+ + Grad Year
- {['AB','R','H','2B','3B','HR','RBI','BB','K','SB','HBP'].map((h) => ( + {['AB','R','H','2B','3B','HR','RBI','BB','K','SB','CS','HBP','SAC','SF','LOB'].map((h) => ( ))} @@ -171,10 +171,14 @@ export function BoxScoreView({ game, batting, pitching }: BoxScoreViewProps) { + - - - + + + + + + ))} @@ -191,7 +195,11 @@ export function BoxScoreView({ game, batting, pitching }: BoxScoreViewProps) { + + + @@ -234,8 +242,8 @@ export function BoxScoreView({ game, batting, pitching }: BoxScoreViewProps) { - - + +
Player{h}AVG{fmtStat(row.bb)} {fmtStat(row.k)} {fmtStat(row.sb)}{fmtStat(row.cs)} {fmtStat(row.hbp)}{fmtAvg(row.avg)}{fmtAvg(row.obp)}{fmtAvg(row.slg)}{fmtStat(row.sac)}{fmtStat(row.sf)}{fmtStat(row.lob)}{formatAvg(row.avg)}{formatAvg(row.obp)}{formatAvg(row.slg)}
{totals.bb} {totals.k} {totals.sb}{totals.cs} + {totals.sac}{totals.sf}{totals.lob} {teamAvg}
{fmtStat(row.k)} {fmtStat(row.hr)} {fmtStat(row.pitch_count)}{fmtStat(row.era, 2)}{fmtStat(row.whip, 3)}{formatEra(row.era)}{formatWhip(row.whip)} {row.result && ( - 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..88a8a8749 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'; @@ -46,6 +54,10 @@ const SHELL = 'flex h-[calc(100vh-5.5rem-env(safe-area-inset-bottom))] flex-col export interface CalendarFairwayProps { /** College coach with no team → recruiting-focused empty state. */ recruitingEmpty: boolean; + /** Any other role (non-college coach, or player) with no team resolved yet + * → generic "no team assigned" state, distinct from `recruitingEmpty`'s + * recruiting-specific narrative. Mutually exclusive with `recruitingEmpty`. */ + noTeamEmpty: boolean; events: CalendarEvent[]; teamMembers: WrapperProps['teamMembers']; teamId: string | null; @@ -57,6 +69,7 @@ export interface CalendarFairwayProps { export function CalendarFairway({ recruitingEmpty, + noTeamEmpty, events, teamMembers, teamId, @@ -68,16 +81,43 @@ export function CalendarFairway({ if (recruitingEmpty) { return (
+
+ +
- Browse prospects } + className="max-w-md" + /> +
+
+ ); + } + + if (noTeamEmpty) { + return ( +
+
+ +
+
+
@@ -86,6 +126,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 +138,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/command-center/analytics/PlayerPerformanceGrid.tsx b/src/components/baseball/command-center/analytics/PlayerPerformanceGrid.tsx index 895b64d92..845b1c62c 100644 --- a/src/components/baseball/command-center/analytics/PlayerPerformanceGrid.tsx +++ b/src/components/baseball/command-center/analytics/PlayerPerformanceGrid.tsx @@ -9,7 +9,7 @@ interface PlayerPerformanceGridProps { players: BaseballRosterPlayer[]; } -type MetricKey = 'avg' | 'obp' | 'slg' | 'ops' | 'exitVelo' | 'sessions'; +type MetricKey = 'avg' | 'obp' | 'slg' | 'ops' | 'sessions'; interface MetricConfig { key: MetricKey; @@ -62,15 +62,6 @@ const METRICS: MetricConfig[] = [ thresholds: { excellent: 0.9, good: 0.77, average: 0.65 }, higherIsBetter: true, }, - { - key: 'exitVelo', - label: 'Avg Exit Velocity', - shortLabel: 'EV', - getValue: (p) => p.aggregates?.avg_exit_velocity ?? null, - format: (v) => `${v.toFixed(1)}`, - thresholds: { excellent: 88, good: 82, average: 75 }, - higherIsBetter: true, - }, { key: 'sessions', label: 'Total Sessions', 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..de4ea06c3 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,48 @@ export function DocumentsFairway({
{filtered.length === 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. + } - onClick={onUpload} + onClick={() => { + onSearchChange(''); + onCategoryChange('all'); + }} > - Upload document + Clear filters - ) : undefined - } - /> + } + /> + ) ) : (
{filtered.map((doc) => ( diff --git a/src/components/baseball/games/EditGameModal.tsx b/src/components/baseball/games/EditGameModal.tsx new file mode 100644 index 000000000..ef533bfc6 --- /dev/null +++ b/src/components/baseball/games/EditGameModal.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal } from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/sonner'; +import { updateGame } from '@/app/baseball/actions/games'; +import type { BaseballGame, BaseballGameType, BaseballHomeAway } from '@/lib/types'; + +interface EditGameModalProps { + game: BaseballGame; + open: boolean; + onClose: () => void; +} + +/** + * Coach-only edit surface for a scheduled/completed game's metadata (date, + * opponent, location, home/away, notes, weather). Fixes the P2 gap where a + * mistyped field could only be corrected by deleting the game — which + * cascade-destroys its box score. Calls the existing `updateGame` server + * action; does not touch box-score data. + */ +export function EditGameModal({ game, open, onClose }: EditGameModalProps) { + const router = useRouter(); + const { showToast } = useToast(); + + const [gameDate, setGameDate] = useState(game.game_date); + const [gameType, setGameType] = useState(game.game_type); + const [opponentName, setOpponentName] = useState(game.opponent_name ?? ''); + const [location, setLocation] = useState(game.location ?? ''); + const [homeAway, setHomeAway] = useState(game.home_away ?? 'home'); + const [notes, setNotes] = useState(game.notes ?? ''); + const [weather, setWeather] = useState(game.weather ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!gameDate) { + setError('Please select a game date'); + return; + } + + setSaving(true); + setError(null); + + const result = await updateGame(game.id, { + game_date: gameDate, + game_type: gameType, + // Send `null` (not `undefined`) for cleared optional fields — updateGame + // skips `undefined` keys entirely before building the .update() payload, + // so an emptied field would otherwise leave the old value in the row. + // `null` is a real value for these nullable columns and clears them. + opponent_name: opponentName.trim() || null, + location: location.trim() || null, + home_away: homeAway, + notes: notes.trim() || null, + weather: weather.trim() || null, + }); + + setSaving(false); + + if (result.success) { + showToast('Game updated', 'success'); + onClose(); + router.refresh(); + } else { + setError(result.error ?? 'Failed to update game'); + } + } + + return ( + +
+ {/* Game type */} +
+

Type

+
+ {(['game', 'scrimmage'] as BaseballGameType[]).map((t) => ( + + ))} +
+
+ + {/* Date */} +
+ + setGameDate(e.target.value)} + required + className="rounded-xl border-warm-200 text-warm-900 bg-cream-50/80 focus:ring-2 focus:ring-primary-500" + /> +
+ + {/* Opponent */} +
+ + setOpponentName(e.target.value)} + placeholder="e.g. State University" + className="rounded-xl border-warm-200 text-warm-900 bg-cream-50/80 focus:ring-2 focus:ring-primary-500 placeholder:text-warm-300" + /> +
+ + {/* Home/Away */} +
+

Location

+
+ {(['home', 'away', 'neutral'] as BaseballHomeAway[]).map((ha) => ( + + ))} +
+
+ + {/* Venue */} +
+ + setLocation(e.target.value)} + placeholder="e.g. Alumni Field" + className="rounded-xl border-warm-200 text-warm-900 bg-cream-50/80 focus:ring-2 focus:ring-primary-500 placeholder:text-warm-300" + /> +
+ + {/* Weather */} +
+ + setWeather(e.target.value)} + placeholder="e.g. 72°F, clear" + className="rounded-xl border-warm-200 text-warm-900 bg-cream-50/80 focus:ring-2 focus:ring-primary-500 placeholder:text-warm-300" + /> +
+ + {/* Notes */} +