diff --git a/.env.example b/.env.example index c0b20b4b7..3ab10c005 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,23 @@ SENTRY_READ_TOKEN=your-sentry-read-token-here # SENTRY_READ_TOKEN is absent and the token has the read scopes above. SENTRY_AUTH_TOKEN=your-auth-token-here +# ----------------------------------------------------------------------------- +# Datadog RUM + Browser Logs (Optional — src/lib/datadog/index.ts) +# ----------------------------------------------------------------------------- +# This module runs entirely client-side, so every var it reads MUST be +# NEXT_PUBLIC_-prefixed — un-prefixed vars are stripped at build time and +# read as undefined in the browser, silently falling back to defaults. +# Get application ID / client token from: https://app.datadoghq.com/organization-settings/rum +NEXT_PUBLIC_DD_APPLICATION_ID=YOUR_APPLICATION_ID +NEXT_PUBLIC_DD_CLIENT_TOKEN=your-datadog-client-token-here +# Datadog site (defaults to 'datadoghq.com'; use 'datadoghq.eu' etc for other regions). +# NEXT_PUBLIC_DD_SITE=datadoghq.com +# Service name tag (defaults to 'helm-sports-labs'). +# NEXT_PUBLIC_DD_SERVICE=helm-sports-labs +# Env tag override. Leave unset to reuse NEXT_PUBLIC_VERCEL_ENV (production/ +# preview/development) — Next.js inlines that one automatically, see next.config.mjs. +# NEXT_PUBLIC_DD_ENV=production + # ----------------------------------------------------------------------------- # Feature Flags (Optional) # ----------------------------------------------------------------------------- @@ -287,10 +304,9 @@ RLS_DIAGNOSTIC_TEST_PASSWORD= # RINI_DEMO_FILLER_PASSWORD= # ----------------------------------------------------------------------------- -# Fairway redesign feature flag +# Fairway redesign feature flag (REMOVED — Wave W1, 2026-07-09) # ----------------------------------------------------------------------------- -# Gates the "Fairway" warm-premium design system (see ui-intelligence/ -# DESIGN-SYSTEM.md). Default OFF — the current app is 100% unchanged; only -# redesigned components that opt in via src/lib/redesign/flag.ts + the -# `.fairway-ds` scope consume the new tokens/fonts. Accepts 1/true/on/yes. -# NEXT_PUBLIC_REDESIGN=false +# NEXT_PUBLIC_REDESIGN used to gate the "Fairway" warm-premium design system +# (see ui-intelligence/DESIGN-SYSTEM.md). Fairway is now the only dashboard +# tree — src/lib/redesign/flag.ts's isRedesignEnabled()/useRedesign() are +# hardcoded true — so this env var no longer has any effect. No action needed. diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e30773d37..ce42c609c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -47,7 +47,6 @@ permissions: env: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy-ci-build.supabase.co' }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'dummy-anon-key-ci-build-only' }} - NEXT_PUBLIC_REDESIGN: "true" E2E_GOLF_EMAIL: ${{ secrets.E2E_GOLF_EMAIL }} E2E_GOLF_PASSWORD: ${{ secrets.E2E_GOLF_PASSWORD }} SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} diff --git a/.github/workflows/pr-smoke.yml b/.github/workflows/pr-smoke.yml index 12c2927ad..9d959586c 100644 --- a/.github/workflows/pr-smoke.yml +++ b/.github/workflows/pr-smoke.yml @@ -18,7 +18,6 @@ permissions: env: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://dummy-ci-build.supabase.co' }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'dummy-anon-key-ci-build-only' }} - NEXT_PUBLIC_REDESIGN: "true" NODE_OPTIONS: --max-old-space-size=4096 jobs: diff --git a/docs/audits/BASEBALLHELM_LIFTLAB_GAP_MAP_2026-06-25.md b/docs/audits/BASEBALLHELM_LIFTLAB_GAP_MAP_2026-06-25.md index b051dd867..65d17339f 100644 --- a/docs/audits/BASEBALLHELM_LIFTLAB_GAP_MAP_2026-06-25.md +++ b/docs/audits/BASEBALLHELM_LIFTLAB_GAP_MAP_2026-06-25.md @@ -3,6 +3,46 @@ **Source:** 5-reader parallel audit, 109 raw items → 71 deduped gaps **Method:** Deduplicated near-duplicates, re-prioritized (P0=blocking/security/data-loss, P1=core unbuilt feature, P2=polish), grouped into 9 parallel build waves. +> **⚠️ Historical snapshot (2026-06-25) — superseded.** This gap map predates +> ~40 commits of remediation, including the Lift Lab unification train +> (completed 2026-07-08) and the W0–W9 production-readiness mission +> (2026-07-09). Most items below have shipped; the raw list is kept as-is +> for historical record rather than rewritten in place. See +> `docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md` for the +> current, row-by-row readiness state (re-graded 2026-07-09). +> +> **Current state (2026-07-09):** +> - **W1 (DB foundation) — done.** All `helm_lifting_*` migrations +> (`20260625000000`–`20260625000030` and later hardening/drift-closure +> migrations) are confirmed applied to prod via `list_migrations`; types +> have been regenerated and the `as any`/`fromUntyped` casts flagged in W1 +> are gone from the baseball performance page's core reads. +> - **W2 (DB security) — done.** The anon-EXECUTE SECDEF RPCs, the +> `baseball_notifications` `WITH CHECK (true)` policy, and the anon +> GRANT-ALL findings from this pass were all closed in later hardening +> migrations (`baseball_anon_revoke_wave1/2`, `baseball_notifications_revoke_anon`, +> `revoke_anon_secdef_rpc_drift_full`, and the 2026-07-08/09 gate-and-revoke +> passes) — verified via the live migration list, not just the repo. +> - **W7 (Lift Lab core) — built.** Exercise CRUD, program editor add/edit +> flows, player session execution (`/lifting/dashboard/lift` + +> `[sessionId]`), and a Today Board now exist; the baseball performance +> dashboard was rewired off legacy `baseball_lift_*` tables onto unified +> `helm_lifting_*` reads (the "W2-G REWIRE" banner in +> `src/app/baseball/(dashboard)/dashboard/performance/page.tsx`). +> - **Design system — applied.** The Living Annual (LA) chrome (W5 in the +> production-readiness mission) has been applied across baseball surfaces +> including Lift Lab-adjacent screens. +> - **Legacy tables — graveyarded**, not left dangling: three graveyard +> migrations (`graveyard_dead_tables_phase1`, `graveyard_dead_liftlab_tables_phase2`, +> `graveyard_legacy_liftlab_tables_phase3`) retired the superseded +> `baseball_lift_*`/legacy Lift Lab tables once the unified model took over. +> - **What's still open:** treat the readiness matrix (not this document) as +> the source of truth for exactly which BaseballHelm/Lift Lab surfaces +> still have a real, currently-tracked gap — several of the W9 UI-polish +> items (skeletons, off-brand colors) and some W6/W8 feature items below +> may still be outstanding and were not re-verified item-by-item as part +> of this pass. + --- ## Summary Table diff --git a/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md b/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md index 2bbe09a78..636ea0fda 100644 --- a/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md +++ b/docs/audits/PRODUCTION_READINESS_MISSION_2026-07-09.md @@ -11,7 +11,7 @@ **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 coach | **≤8 hubs** ✅ (8 standard: Dashboard, Messages, Team, Stats & Performance, Development, Recruiting, Academics, Management; 7 for High School — Recruiting hidden by design, `RECRUITING_PROGRAM_TYPES`; showcase variant has its own two-level org→team rail) | 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** | @@ -101,7 +101,7 @@ policies on hot golf tables, 199 unindexed FKs, 256 unused indexes). ## Target IA (~8 tabs, owner directive) -### Baseball coach — unchanged (8 hubs) ✅ +### Baseball coach — unchanged (≤8 hubs: 8 standard; 7 for HS — Recruiting hidden by design; showcase variant has its own two-level rail) ✅ ### Baseball player — 8 (move Settings out of rail to pinned footer) Today · Schedule · My Profile · Stats · Development · Team · Messages · [exposureNoun] @@ -114,7 +114,7 @@ Today · Schedule · My Profile · Stats · Development · Team · Messages · [ 6. **Messages** (Messages · Announcements) 7. **Operations** (Tasks · Documents) 8. **Courses** -Footer: Settings · Sign out. Mobile bottom bar: Home, CoachHelm, Roster, Calendar, Messages (unchanged). +Footer: Settings · Sign out. Mobile bottom bar: Home, CoachHelm, Team, Calendar, Messages (code renders label "Team", opening the Team hub — not "Roster"; unchanged from live behavior). ### Golf player — 8 1. **Dashboard** (merge Hub into Dashboard landing — two "homes" is duplicative) @@ -193,3 +193,92 @@ Footer: Settings · Sign out. - 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. + +--- + +- **Phase D — adversarial verify loop** until 2 consecutive dry rounds + (ledger below; loop still open as of `8f820639`). +- **Phase E — advisors re-run + ONE production deploy + final report** + (checklist below; merge/deploy both pending Nick). + +## Phase D — verification ledger + +Six numbered adversarial-verify rounds plus one mega-wave have landed on +`integration/mission-verify` (15 commits ahead of `origin/main` as of +`8f820639`). Each round: an adversarial pass re-reads the diff/runtime +behavior against its own claims, fixes what it confirms, and gates before +merge — no round has yet come back dry. + +| Round | Commit | Scope | Confirmed findings | Gates | +|---|---|---|---|---| +| 1 | `c9935b63` | P0 recruiting-guard trigger widened (flip-type-then-activate laundering closed); golf CoachHelm breadcrumb derivation; 16 baseball `loading.tsx` rebuilds; docs/registry truth | **19** | tsc 0, lint 0, unit 4614, build 0 | +| 2 | `fbcac248` | JUCO write-ordering fix (2nd `recruiting_activated` site the round-1 trigger note missed); golf mobile-nav active-state; glass purge on 46 baseball files + 14 more emerald-hue files; 3 pre-existing CodeQL findings; box-score colspan; stale e2e specs deleted | **6** + follow-ups | tsc 0, lint 0, unit green, build 0 | +| — | `2019db30` | **W7 re-grade** (docs, not an adversarial round): every one of 22 readiness-matrix rows re-verified against live source + prod migrations. Rollup moved **0 ready / 18 partial → 10 ready / 10 partial / 1 route-only / 1 hidden**. `check:readiness-matrix` exit 0; `readiness-matrix-routes` 204 tests passed | n/a | n/a | +| 3 | `afa2bfaa` | Final glass residue: `glass-standard/prominent/subtle` → **zero** under `src/app/baseball/**` (join/staff-join/demo-sessions/public-program); `EventsClient` badges onto `InkBadge` tone matrix | not itemized in commit trailer [verify] | tsc 0, lint 0, unit green, build 0 | +| 4 | `70aa5dab` | Signal Inbox silently discarded mutation results (toast now surfaces failures, convert-dialog stays open on failure); program-profile loading-header color flash fixed | **2** | tsc 0, lint 0, unit 4611/455 files | +| 5 | `2f0eb19f` | One finding-class, 7 sites: client awaits a server action and discards the result, so failures render as success (golf insight rate/dismiss ×3, baseball import approve, readiness check-in, lift program editor, recruiting pipeline) | **7** | tsc 0, lint 0, unit 4611/455 files | +| **mega** | `11180c54` | **"Phase D mega-wave"** — 4 fleets in one gated commit: Defect Sweep 1 (CRM unsubscribe false-ok, LLM spend-record logging, JUCO revert-only-if-it-flipped, 4 silent-failure fixes, double-submit guards, bounded chat fetch, paginated admin queries past the 1000-row cap, 4 tz day-bucketing fixes, round-submit a11y, 2 error boundaries); Defect Sweep 2 (email-route truth, HTML-injection escaping, 6 settings-stub regressions, Datadog client env, idempotent digest crons, registered refresh-engagement cron, batched golf N+1s, auth-redirect fallback chain, 2 mission tests made to actually gate); Authz Hardening (4 unauthenticated admin-client drivers de-exported from `'use server'` modules, zero client callers, coverage tripwire honestly re-derived **425 → 419**, −6 verified); Taste-Polish Wave (28 packets from a 15-cluster Opus design review: ~14 `loading.tsx` rewrites, legacy-island rebuilds onto Living Annual/Fairway, date/numeral unification) | **22 + 16** (Defect Sweeps 1+2); 28 polish packets not counted as "findings" | build 0, tsc 0, lint 0, unit 4616/455 files (4 test files repaired in-wave) | +| 6 | `8f820639` | Rebuild regressions from the mega-wave's own rewrites (Escape-key focus trap, overdue-task badge collision, deterministic aria-labels, off-by-one date anchoring, unearned "verified" checkmark, `InkNotice` error-ink var, Messages scroll guard + dead-component deletion, safe-area double-counting, 4 skeleton/board mismatches); missed-adjacent glass/color residue (PositionPlanner/PositionPlayerPill, 5 more components onto `InkBadge`/`InkNotice`, `CalendarFairway` event-ink map); 2 comment-terminator bugs from the fixer wave itself, caught by gates | **26** | build 0, tsc 0, lint 0, unit 0 (no count restated in trailer) | + +Running total of discretely-counted confirmed findings across rounds 1, 2, +4, 5, 6 and the mega-wave's two defect sweeps: **19 + 6 + 2 + 7 + 22 + 16 + +26 = 98** [verify — sum of trailer-stated counts; round 3's residue batch +and the mega-wave's 28 taste packets are excluded as not discretely +numbered in their own commits]. + +**Status**: no round has come back dry yet, so the stated exit criterion +(2 consecutive dry rounds) is not met. A round-7 pass is in flight — +**uncommitted at HEAD** as of this ledger — touching +`snapshot-cards/shared.tsx`, `SnapshotHeaderBand.tsx`, `ProfileTimeline.tsx`, +`PlayerProfileClient.tsx`, and `PositionPlayerPill.tsx`: raw +`amber-*`/`warm-*`/`red-*` Tailwind swatches on the player-profile snapshot +cards and the position-planner pill are being converted to the ink system +(`--notice-error-ink` via `color-mix()`, `pursuit` clay-ink ramp), with +`PlayerNotesSection.tsx` and `PlayerPerformanceTab.tsx` explicitly +name-flagged in-code as deferred siblings still carrying raw color. This is +the same class of fix as round 6's residue sweep and overlaps the +ink-conversion follow-up wave below — it has not been gated or committed. + +## Phase E — closeout checklist + +- [x] **Advisors re-run** — Supabase advisors re-checked against live prod + post-Phase-D fixes. +- [ ] **Merge to `main`** — pending Nick. Branch `integration/mission-verify` + is 15 commits ahead of `origin/main` (`a3946332`); not yet opened/merged + as a PR. +- [ ] **Production deploy** — pending, and gated on the merge above. Standing + constraint: ONE intentional production deploy for the whole mission, no + incremental previews. +- [x] **Trigger apply sequenced** — `supabase/migrations/20260709010200_baseball_players_recruiting_guard.sql` + (the `BEFORE UPDATE` guard on `recruiting_activated`) carries a header + banner marking it **DEPLOY-SEQUENCED**: it must land together with, or + immediately after, the production deploy that ships W0a's + `activateRecruitingExposure`/`deactivateRecruitingExposure` rewrite in + `src/app/baseball/actions/player-access.ts` (now writing via + `createAdminClient()`/service_role). Applying the trigger before that + deploy lands would `42501` every legitimate activation/withdrawal on live + prod. Its two W0b siblings — `20260709010000` (public-view visibility + filters) and `20260709010100` (gate `get_admin_event_summary`) — have + **already been applied to prod**, independently, with no such dependency. +- [ ] **Final report** — not yet written; blocked on the merge/deploy above. + +## Two follow-up waves (queued after Phase D/E close — both already have a down payment in flight) + +- **Agent-legibility sweep** (repo map doc, stale-doc truth pass, dead-code + deletion) — tracked as not-yet-started, but the current uncommitted diff + already contains one instance of the stale-doc truth pass: `docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md`'s + Stats/Box-Score row cited a nonexistent migration filename + (`20260709042343`); corrected in-flight to the real applied migration + (`20260708150000_baseball_box_score_upsert_and_error_detail.sql`). +- **Tree-wide red/amber → ink conversion remainder** — tracked at 182 hits / + 57 baseball files [verify — this session's own broader grep for + `(bg|text|border)-...-(red|amber)-[0-9]+` under `src/app/baseball` + + `src/components/baseball` at HEAD returns a substantially higher count + (~470 hits / ~75 files); the tracked figure likely predates several + since-landed conversions (round 6, mega-wave taste-polish) or excludes + the deliberately-preserved graduated readiness/status-color legends — + reconcile scope before treating either number as authoritative]. In + flight uncommitted right now on the player-profile snapshot-card cluster + and `PositionPlayerPill` (see Phase D status above); `PlayerNotesSection.tsx` + and `PlayerPerformanceTab.tsx` are explicitly flagged in-code as the next + deferred targets. diff --git a/docs/baseball/ui-migration-map.md b/docs/baseball/ui-migration-map.md index 28927322d..54251765e 100644 --- a/docs/baseball/ui-migration-map.md +++ b/docs/baseball/ui-migration-map.md @@ -9,9 +9,19 @@ kit so the product reads as a single publication. No surface keeps a bespoke card, header, empty state, or stat display once the kit covers it. Consistency is the deliverable. -Delivery mechanism = the existing **Fairway redesign layer** (`NEXT_PUBLIC_REDESIGN`, -`.fairway-ds` scope, `--fw-*` tokens). The Living-Annual kit is the **baseball-native -vocabulary layered on top of the Fairway tokens** — not a second design system. +Delivery mechanism = the existing **Fairway redesign layer** (`.fairway-ds` scope, +`--fw-*` tokens — unconditional, no flag). The Living-Annual kit is the +**baseball-native vocabulary layered on top of the Fairway tokens** — not a second +design system. + +> **Update (2026-07-09, golf Wave W1):** `NEXT_PUBLIC_REDESIGN` no longer gates +> anything — `isRedesignEnabled()`/`useRedesign()` in `src/lib/redesign/flag.ts` +> are hardcoded `true` (the legacy `GolfDashboardShell`/`GolfSidebar` fork they +> used to gate was deleted). Setting the env var has zero effect. Baseball's +> `BaseballFairwayShell` renders unconditionally and was never flag-gated the +> way golf's dual tree was; `src/components/layout/header.tsx` still calls +> `isRedesignEnabled()` (always `true`) to suppress its own legacy chrome, but +> that is a hardcoded return, not env-driven behavior. --- diff --git a/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md b/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md index a07e22072..4db54fc8e 100644 --- a/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md +++ b/docs/operations/BASEBALLHELM_FEATURE_READINESS_MATRIX.md @@ -25,6 +25,15 @@ This matrix separates **route-rendered** from **partially wired**, to a real, currently-open tracking issue so the backlog stays actionable instead of becoming another stale audit doc. +**2026-07-09 re-grade (W7):** every row below was re-verified against the +current code on `integration/mission-verify` — ~40 commits of remediation +since the last full pass (statsync verification, the W0–W9 production- +readiness mission, two Phase-D fix rounds). Ten rows moved to `ready` on +concrete evidence (see each row's Highest-Risk Gap for file/line citations +and the closed issue that tracked the fix); the rest have real, currently +open findings. Issue open/closed state was verified with `gh issue view` +against `njrini99-code/helmv3`, not assumed from prior matrix text. + ## Status legend | Status | Meaning | @@ -47,51 +56,75 @@ 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` | 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` | `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). | 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. | +| 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` | ready | Fixed 2026-07-09, verified in code — all three originally-flagged findings: (1) the persisted-shell fast path (`src/hooks/use-baseball-auth.ts`) always revalidates against the server via `verifyServerSession()` (`supabase.auth.getUser()` on every call); a 5s resolved-result cache only collapses duplicate same-navigation mounts, it never skips the server round trip (#416). (2) The public demo gate (`src/app/baseball/actions/demo-access.ts`) still signs every visitor into one shared demo coach account by design, but every server action not explicitly marked `demoSafe: true` now throws when called from that session (`src/lib/baseball/with-baseball-action.ts`) — the shared account is now structurally read-only rather than isolated (#392). (3) `changePasswordAction` (`src/app/baseball/actions/auth.ts:467-520`) re-authenticates via `signInWithPassword` before calling `updateUser`, confirmed by 7 passing test cases (#371). W0 (commit `d2aa1a5c`, 2026-07-09) additionally closed a related edge case: sign-out now calls `invalidateAuthCache()` so there is no post-logout stale-session window. | `src/lib/baseball/__tests__/server-route-guards.test.ts`, `coach-onboarding-staff-row.integration.test.ts`, `active-context-staff-status.test.ts`, `src/app/baseball/actions/__tests__/change-password.test.ts` (7 cases, passing), `demo-access.test.ts`; `e2e/auth.spec.ts`; `e2e/baseball-smoke.spec.ts` (#372, mandatory, unconditional). | Resolved 2026-07-09 — #416, #392, #371 all closed and independently re-verified against current code; no open issue remains for this row. | Ready — entry path renders, all three previously-open auth/security findings are fixed and regression-tested, and the mandatory smoke suite additionally proves the gated shell renders for both roles. | +| Command Center | `/baseball/dashboard/command-center` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md`; `src/lib/baseball/read-models/command-center.ts` | ready | Fixed 2026-07-09, verified in code (#412) — `src/app/baseball/(dashboard)/dashboard/command-center/page.tsx` now consumes only `getCommandCenter(teamId)` + `getCoachDailyContracts(teamId)` as its data source (plus one lightweight `baseball_teams` name/type/join-code lookup — not the flagged pattern). The ad hoc parallel queries against `baseball_team_members`, `baseball_events`, and `baseball_player_aggregates` are gone, and `src/contracts/baseball/product-trust.contract.test.ts` regression-guards this directly: it asserts the page source no longer contains either table name and does contain `assembleCommandCenterClientProps`/`getCommandCenter`. | `src/lib/baseball/read-models/__tests__/command-center.test.ts` (8 cases); `src/contracts/baseball/product-trust.contract.test.ts`; `e2e/baseball-phase1.spec.ts` (gated); `e2e/baseball-smoke.spec.ts` (mandatory, unconditional). | Resolved 2026-07-09 — #412 closed and verified fixed in code. | Ready — single canonical read model, directly contract-tested against regression, and rendered under both gated and mandatory E2E coverage. | +| Calendar | `/baseball/dashboard/calendar` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/components/baseball/calendar/BaseballCalendarWrapper.tsx` | ready | Both fixed 2026-07-09, verified in code — (1) player-side team resolution (#368) in `src/app/baseball/(dashboard)/dashboard/calendar/page.tsx:60-70` now correctly looks up the player's team via `baseball_team_members.player_id` instead of the prior broken lookup; (2) every calendar/event mutation — create, update, delete — is wrapped in `withBaseballAction` in `src/app/baseball/actions/calendar.ts` (#369), replacing the previously un-normalized mutation guards. | `src/components/baseball/calendar/__tests__/BaseballCalendarWrapper.rsvp-routing.test.tsx`; `e2e/baseball-phase1.spec.ts` (gated); `e2e/baseball-smoke.spec.ts` (mandatory — Calendar renders for both coach and player roles). | Resolved 2026-07-09 — #368, #369 both closed and verified. | Ready — the real correctness bug is fixed, mutations are normalized behind the shared guard, and the route is proven under both gated and mandatory E2E coverage. | +| Roster | `/baseball/dashboard/roster` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1; `src/lib/baseball/read-models/` | ready | Fixed 2026-07-09, verified in code (#411) — `src/app/baseball/(dashboard)/dashboard/roster/page.tsx` is now a server component calling `getActiveBaseballContext()` + `getRoster(ctx.activeTeamId)`; the client-side `useAuth()` + direct-Supabase pattern flagged in `BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` is gone. | `src/lib/baseball/__tests__/roster-read-model.test.ts`; the old fully-skipped `e2e/roster.spec.ts` no longer exists in the repo — Roster is now covered by the mandatory, unconditional `e2e/baseball-smoke.spec.ts` (#372) for both coach and player roles instead of being left orphaned. | Resolved 2026-07-09 — #411 closed and verified fixed in code. | Ready — server read-model pattern confirmed on disk, and the prior zero-assertion E2E gap was closed with real mandatory coverage rather than just deleted. | +| 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 | The data-loss finding is fixed and verified (#399, closed): `saveCsvBoxScoreViaRpc` (`src/app/baseball/actions/games.ts:781-850`) now persists via the atomic `save_baseball_full_box_score` Postgres RPC — applied to prod (migrations `20260630000000`, `20260701009000`, `20260708150000_baseball_box_score_upsert_and_error_detail.sql`, confirmed via `list_migrations`) — instead of the delete-then-insert pattern the code's own comment calls "the forbidden delete-then-insert pattern." The remaining, still-open risk is real: **#379 remains open** — three parallel stat data layers (legacy flat `baseball_player_stats`/`baseball_player_aggregates`, box-score/season, elite stat-event) are not yet reconciled into one canonical read model. | `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; the data-loss risk on the save path is fixed, but the three-layer stat reconciliation (#379, open) still blocks "ready." | +| Import Center | `/baseball/dashboard/import`, `/baseball/dashboard/settings/imports` | `docs/archive/2026-06/audits/STATS_END_TO_END_REMEDIATION_PLAN_2026-06-06.md` | ready | Both fixed 2026-07-09, verified in code — (1) `requiresReview` is now recomputed server-side from the source registry (`src/app/baseball/actions/stat-event-imports.ts:439-452`; comment: "recomputed server-side; never trust client-supplied detectionAutoCommit") instead of trusting the client-supplied flag (#415). (2) Every commit path calls `assertImportSourceAllowed(db, teamId, sourceKey)` (lines 257 and 357) before accepting an upload, so a disabled source can no longer be hit directly via server action even when hidden in the UI (#407). | `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 — #415, #407 both closed and verified. | Ready — both the review-band trust boundary and the disabled-source bypass are fixed and covered by the existing unit suite. | +| 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` | partial | The hub-subnav capability-filtering gap is fixed and verified (#370, closed): `src/lib/baseball/__tests__/nav-capability-gating.test.ts` and `resolve-active-hub.test.ts` now assert Practice/Practice-Effectiveness are gated on `can_manage_practice`, and `e2e/baseball-phase1.spec.ts` exercises "coach can open practice planner" against the real route (gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`). `src/app/baseball/actions/practice.ts` remains fully wrapped in `withBaseballAction`. Remaining gap: no Practice-specific unit test proves the planner's save/publish/attendance flow end to end beyond that single gated E2E render check. | `src/lib/baseball/__tests__/nav-capability-gating.test.ts`, `resolve-active-hub.test.ts`; `e2e/baseball-phase1.spec.ts` ("coach can open practice planner", gated). | (no dedicated issue — #370, the umbrella this row previously cited, is closed and verified; see [Maintenance](#maintenance)) | Improved 2026-07-09 — nav/capability gating now has real regression coverage and the route renders under a gated E2E case; still short of "ready" without a feature-specific save/publish test. | +| 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 | #400 (the umbrella this row previously cited) is closed, but its actual scope — verified via `src/contracts/baseball/product-trust.contract.test.ts` — covered five other named surfaces (Command Center, Stats Center, Documents, DailyBriefPanel, and the command-center/stats-center/signal-inbox read-model envelopes), not Practice Effectiveness. No empty/error/no-permission contract test exists for this surface specifically — the original gap is unchanged. `src/app/baseball/actions/practice-effectiveness.ts` and `src/lib/baseball/read-models/practice-effectiveness.ts` remain fully wrapped in the shared action guard. | `src/lib/baseball/__tests__/nav-capability-gating.test.ts`, `resolve-active-hub.test.ts` cover hub-visibility gating only, not the effectiveness read/write flow itself. | (no dedicated issue — #400 closed but out of scope for this row; see [Maintenance](#maintenance)) | Unchanged — action/read-model layer exists and is now capability-gated (via #370's fix), but zero feature-specific test evidence for the effectiveness workflow itself. | +| 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 | ready | Fixed 2026-07-09, verified in code (#408) — `src/app/baseball/(dashboard)/dashboard/performance/page.tsx:77-84` now gates server-side on `can_manage_lifting` OR `can_view_readiness`, matching `nav-registry.ts`'s `requiredAnyCapabilities: ['can_manage_lifting', 'can_view_readiness']` exactly (a code comment confirms the intentional alignment: "nav hiding alone is not relied upon"). The separate W1/W7 gap-map dependency (legacy `baseball_lift_*` reads) is also done: the page carries a "W2-G REWIRE: all READS now go through helm_lifting_*" banner and reads `helm_lifting_sessions`/`helm_lifting_exercises`/`helm_lifting_readiness_checkins` throughout — per the Lift Lab unification train completed 2026-07-08. | `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); `e2e/baseball-smoke.spec.ts` (mandatory — "Performance (staff lift surface) renders for the coach"). | Resolved 2026-07-09 — #408 closed and verified; nav/route capability gates now match exactly. | Ready — nav-visibility now matches the route guard exactly, the legacy-table dependency this row previously flagged is resolved, and unit-test depth on the conflict/readiness engines is real. | +| Player Today | `/baseball/player/today` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §2; `src/lib/baseball/read-models/player-today.ts` | partial | **#377 remains open.** No test proves the daily contract/today view can't silently render fabricated or stale data — still true. One related improvement since the last pass: the assignments feed now reads from the unified `helm_lifting_sessions` table (post Lift Lab unification, 2026-07-08) instead of a second, possibly-disagreeing legacy source, so the "Lifts Due" count no longer risks disagreeing with the Lift & Check-in card (`src/lib/baseball/read-models/player-today.ts`, comment cites the same fix for issue #456). | `e2e/baseball-phase1.spec.ts` (player block, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`); `e2e/baseball-smoke.spec.ts` (mandatory — "Player Today renders for the player", render-only, no contract assertions). | | Renders for real players under both gated and mandatory E2E; no business-contract test lane yet (#377, open) — unchanged from the prior pass. | +| Signals | `/baseball/dashboard/signals` | `src/app/baseball/actions/signals.ts`, `src/app/baseball/actions/operational-signals.ts` | partial | **#377 remains open** for the same product-truth reason as Player Today/Videos. #384 (product-truth tests for stat-derived CoachHelm claims) is closed and verified: the outcome-ledger honesty rules now have direct regression coverage (`outcome-sweep-verdict.test.ts` — proves a one-game window can never render "improved", `outcome-sweep-insight-resolve.test.ts`, `signal-from-insight.test.ts`). The underlying box-score-vs-`baseball_player_stats` source-of-truth question this row also flagged is shared with Stats/Box Score's still-open #379 — it is not independently resolved here. | `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` (all passing) | | Strong rule-engine unit coverage, now including honest-outcome regression tests (#384 closed); the stat-source-of-truth mismatch with CoachHelm claims tracks with #379/#377 (both open) and remains the real risk. | +| Decision Room | `/baseball/dashboard/decision-room` | `src/app/baseball/actions/decision-room.ts`; `src/components/baseball/staff-decision-room/StaffDecisionRoomClient.tsx` | partial | **Upgraded from `needs decision` 2026-07-09** — the blocking condition is resolved: `baseball_meeting_items` (migration `20260624000230`) and `baseball_decision_log` (migration `20260624000310`) are both confirmed **applied to the live prod database** (verified via Supabase `list_migrations`, not just present in the repo), each with staff-only RLS and anon revoked. The action file's own code comments and `LooseClient` cast (`src/app/baseball/actions/decision-room.ts:45-50`) are now stale — they still say the tables "are defined in unapplied migrations" — and should be cleaned up with a `db:types` regen. Remaining real gap: no read-model test exists for the agenda/ledger flow (`src/lib/baseball/read-models/decision-room/__tests__/` only has `lift-helm-lifting.test.ts`, unrelated). | None located for the agenda/ledger read models under `src/lib/baseball/read-models/decision-room/__tests__/`. | (no dedicated issue — the migration-application blocker is resolved; a follow-up issue for the stale type-cast cleanup + missing test coverage has not been filed; see [Maintenance](#maintenance)) | No longer blocked on a schema decision — the migration is live in prod with RLS. Remaining work is a types/comment cleanup pass plus real test coverage, not a product/schema decision. | +| Videos | `/baseball/dashboard/videos` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | **#377 remains open** (same product-truth gap as Player Today/Signals). Two supporting findings are fixed since the last pass: (1) the page is no longer client-only — `src/app/baseball/(dashboard)/dashboard/videos/page.tsx` now resolves `getActiveBaseballContext()` server-side and every read (`getLibraryVideos`, `getPlayerGroupedVideos`, `getEventGroupedClips`, `getTaggedClips`, `getEvidenceClips`) is wrapped in `withBaseballAction`, including the W0 (2026-07-09) server-guard wave; (2) the "0 rows in demo" gap from `BASEBALLHELM_STALE_SURFACE_AUDIT_2026-06-25.md` is stale — `scripts/seed-baseball-surfaces-demo.ts` now seeds 5 `baseball_videos` rows across 4 players. | None located under `src/lib/baseball/__tests__/` specific to the video library flow itself. | | Renders on real server-resolved context with demo data now available; still lacks a product-truth/empty-state contract test (#377, open) proving the surface behaves honestly end to end. | +| Documents | `/baseball/dashboard/documents` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1 | partial | The security finding is fixed and verified (#393, closed): `src/app/baseball/actions/documents.ts` now wraps every write (`uploadBaseballDocument`, `createBaseballDocument`, `updateBaseballDocument`, `deleteBaseballDocument`, `uploadNewVersion`) in `withBaseballAction` + `requireBaseballCapability(teamId, 'can_manage_documents')` — a caller-supplied `teamId` is now validated against the real capability system rather than trusted blindly — and URLs are signed (`createSignedUrl`, 1-hour TTL) rather than public. Migration `20260630180100_baseball_documents_capability` is confirmed applied to prod. Remaining gap: `documents-capability.test.ts` is a single-assertion smoke test (checks `can_manage_documents` is in the capability key list) — real, but thin; it does not exercise the guard chain end to end. | `src/lib/baseball/__tests__/documents-capability.test.ts` (1 assertion). | Resolved 2026-07-09 — #393 closed and verified fixed in code; remaining gap is test depth, not security. | Functional and no longer has an open security finding; test coverage is too thin to call this "ready" — recommend a fuller server-action test exercising the capability guard on each write path. | +| Travel | `/baseball/dashboard/travel` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §3.1/§5.1 | partial | The auth-guard finding is fixed and verified: `src/app/baseball/actions/travel.ts` now wraps every action (`createItinerary`, `updateItinerary`, `deleteItinerary`, `addExpense`, `deleteExpense`) in `withBaseballAction` — no bespoke auth checks remain in this file. `src/app/baseball/(dashboard)/dashboard/travel/page.tsx` also gained a server-side `getSessionProfile()` guard in the W0 (2026-07-09) security wave (previously a whole-file `'use client'` page with no server check at all). **#394 remains open**, but its own issue body (verified via `gh issue view 394`) confirms `travel.ts` is no longer on the list of files without `withBaseballAction` — the umbrella issue's remaining scope is `discover.ts` and `insights.ts`, not this row. The real residual gap for Travel is unchanged: zero test coverage — no unit or E2E test exists for itinerary/expense actions. | None located under `src/lib/baseball/__tests__/` or `e2e/`. | (no dedicated issue — travel.ts's specific #394 finding is fixed; #394 remains open only for `discover.ts`/`insights.ts`, which this row does not cover; see [Maintenance](#maintenance)) | Route exists, has real itinerary actions, is now server-guarded and fully on the shared capability model — but zero test coverage keeps this at "partial." | +| Camps | `/baseball/dashboard/camps`, `/baseball/dashboard/camps/[id]` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | ready | Fixed 2026-07-09, verified in code (#375, closed) — `e2e/camps.spec.ts` is no longer a zero-assertion skip file: it now runs real seeded assertions (browse/view/register/unregister as player; list/roster/create+delete as coach) against deterministic fixtures from `scripts/seed-baseball-e2e.ts`, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1` — the same pattern already accepted elsewhere in this matrix (e.g. `baseball-phase1.spec.ts`). `src/app/baseball/(dashboard)/dashboard/camps/page.tsx` also gained a server-side auth guard in the W0 security wave. | `e2e/camps.spec.ts` (10 real assertions across player + coach flows, gated). | Resolved 2026-07-09 — #375 closed and verified: the E2E spec now has live assertions, not just skips. | Ready — create/edit/browse/register actions render and are now proven end to end (when seeded), plus a server-side auth guard was added. | +| Recruiting Pipeline | `/baseball/dashboard/pipeline` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | ready | Fixed 2026-07-09, verified in code (#375, closed) — `e2e/baseball-pipeline.spec.ts` is no longer a zero-assertion skip file: it now exercises the real 5-stage board (render, stage-move-and-restore via the accessible advance/retreat buttons, grad-year filter, list-view notes, and j/k/Enter/Escape keyboard navigation) against a seeded fixture, gated behind `PLAYWRIGHT_BASEBALL_SEEDED=1`. | `e2e/baseball-pipeline.spec.ts` (9 real assertions across board, filter, notes, and keyboard-nav flows, gated). | Resolved 2026-07-09 — #375 closed and verified: the E2E spec now has live assertions, not just skips. | Ready — the 5-stage kanban renders and its core workflows (stage moves, filtering, notes, keyboard nav) are now proven end to end (when seeded). | +| Watchlist / Compare | `/baseball/dashboard/watchlist`, `/baseball/dashboard/compare`, `/baseball/dashboard/comparisons` | `docs/BASEBALL_DASHBOARD_AUDIT_REPORT.md` §2.1 | ready | Fixed 2026-07-09, verified in code (#402, closed) — `addToWatchlist`/`removeFromWatchlist` (`src/app/baseball/actions/watchlist.ts`) now call `assertCoachCanRecruitPlayer()` and bail if `!recruitability.allowed` before writing to `baseball_watchlists` or logging an engagement event — the data-integrity gap on writes is closed. `/baseball/dashboard/comparisons` also gained a proper redirect (was returning a raw string) in the W0 security wave. | `e2e/watchlist.spec.ts` (10 tests, not skipped); `e2e/discover.spec.ts` (12 tests, not skipped). | Resolved 2026-07-09 — #402 closed and verified fixed in code. | Ready — best live E2E coverage among the recruiting surfaces, and the missing recruitability check on writes is now enforced and unconditionally live-tested. | +| 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 | Fixed 2026-07-09, verified in code (#396, closed) — the public CSV route (`src/app/baseball/(public)/packet/[token]/csv/route.ts`) now returns a real HTTP status per outcome: `404` for `not_found`, `403` for any other failure reason, `200` only for a genuinely resolved packet — it no longer returns 200 with a "minimal CSV" for invalid/revoked/expired/non-exposed tokens. Remaining gap: still no unit test exists for this route or the CSV model, so the fix has no regression guard beyond manual code review. | None located under `src/lib/baseball/__tests__/`. | Resolved 2026-07-09 — #396 closed and verified fixed in code; remaining gap is test coverage, not security. | The trust/auditability gap on the unauthenticated path is fixed; kept at "partial" (not "ready") until a regression test exists for the CSV route's status-code behavior. | +| 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" | ready | Fixed 2026-07-09, verified in code (#371, closed) — `changePasswordAction` (`src/app/baseball/actions/auth.ts:467-520`) re-authenticates via `signInWithPassword` with the submitted `currentPassword` before calling `updateUser`, and rejects with "Current password is incorrect." on a bad reauth attempt without ever calling `updateUser` — confirmed by 7 passing test cases covering success, wrong password, expired session, weak new password, and rate limiting. | `src/app/baseball/actions/__tests__/change-password.test.ts` (7 cases, passing); subpage-specific tests vary (see Staff/Roles row). | Resolved 2026-07-09 — #371 closed and verified fixed in code, with direct test coverage on the exact security-sensitive path. | Ready — the one open security finding (unverified reauthentication) is fixed and has real regression coverage; most subpages remain reachable and functional. | +| Staff / Roles | `/baseball/dashboard/settings/staff`, `/baseball/dashboard/settings/roles`, `/baseball/staff/join/[code]` | `docs/audits/BASEBALLHELM_CANONICAL_SPEC.md` §"Staff Collaboration Layer" | partial | Both access-control findings are fixed at the code and DB level: (1) `active-context.ts`'s `loadMemberships()` now excludes staff rows with `status IN ('suspended', 'removed', 'invited')`, and — more importantly — the RLS boundary itself was hardened: migration `20260630230000_baseball_is_team_staff_active_status` (confirmed applied to prod) adds the same status guard to `public.is_baseball_team_staff()`, which gates ~20 RLS policies across the schema, closing the finding at the database layer, not just the app layer (#405, closed). (2) Migration `20260630180000_baseball_scope_player_ids_rls` (confirmed applied to prod) canonicalizes `can_view_baseball_player()` on `scope_player_ids`, wired into RLS policies across `baseball_rls_helpers_and_policies`, lifting/performance, elite-stat-event, video-links, and readiness-gate migrations (#406, closed). Residual gap: #406's own acceptance criteria called for pgTAP tests proving scoped-staff isolation (empty/one/multiple-player and legacy-JSON cases) — no such test was found, only the migration and its live policy usage. | `src/lib/baseball/__tests__/active-context-staff-status.test.ts` (covers #405 directly), `coach-onboarding-staff-row.integration.test.ts`. No pgTAP or unit test found proving `scope_player_ids` isolation specifically. | Resolved 2026-07-09 — #405, #406 both closed; #405 has direct app + RLS test/migration evidence, #406's RLS fix is live but its own acceptance-criteria pgTAP test is missing. | Both findings are fixed at the code and database (RLS) layer, applied to prod — kept at "partial" rather than "ready" only because #406's own acceptance criteria (a pgTAP isolation test) was never actually delivered. | | 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. | --- ## Owner-issue groupings (by category) -Per the issue #376 acceptance criteria, the owner issues above group into: - -- **Feature gaps** (incomplete product surface): #412, #368, #369, #411, #381, #379, #408, #377, #384, #370, #400, #394, #375. -- **Route/navigation gaps**: #370 (hub subnav capability filtering), #374 (route/shell contract tests — umbrella for the whole route-resolution risk this matrix's CI check also guards against). -- **Security gaps**: #416, #392, #371, #399, #415, #407, #393, #402, #396, #405, #406. -- **Test-coverage gaps**: #375 (Camps/Pipeline/Box Score E2E), #372 (mandatory authenticated smoke), #373 (route crawler for authenticated Baseball sessions), #377, #400, #384. +**2026-07-09 update:** of the issues previously grouped here, only five remain +open (`gh issue view` verified against `njrini99-code/helmv3`): **#372, #373, +#377, #379, #394**. Every other issue number in the pre-2026-07-09 version of +this section is now closed and has been removed per the Maintenance rule +below (closed issues must not stay linked/listed as if still outstanding). + +- **Feature gaps** (incomplete product surface, open): #377 (Player Today / + Signals / Videos product-truth contracts). +- **Route/navigation gaps** (open): none currently open. +- **Security gaps** (open): none currently open — #405, #406, #392, #393, + #396, #399, #402, #407, #415, #416 are all closed and verified fixed (see + matrix rows above). +- **Test-coverage gaps** (open): #372 (mandatory authenticated smoke — the + suite exists at `e2e/baseball-smoke.spec.ts` but wiring it as a hard CI gate + is still tracked open), #373 (extend the route crawler to authenticated + Baseball sessions), #377, #379. +- **Data-model gap** (open): #379 (reconcile the three parallel stat data + layers — legacy flat, box-score/season, elite stat-event — into one + canonical read model; also the residual risk behind the Signals row). ## Production readiness rollup | Status | Count | |---|---| -| ready | 0 | -| partial | 18 | -| route-only | 2 | +| ready | 10 | +| partial | 10 | +| route-only | 1 | | hidden | 1 | -| needs decision | 1 | - -**No feature currently qualifies as fully `ready`.** Every feature with a live owner issue should be re-graded the same PR that closes its tracking issue(s) — see [Maintenance](#maintenance). +| needs decision | 0 | + +**Ten of 22 features are now `ready`** as of the 2026-07-09 re-grade (up +from 0), on concrete, file-cited evidence — Auth/Onboarding, Command Center, +Calendar, Roster, Import Center, Performance/Lifting, Camps, Recruiting +Pipeline, Watchlist/Compare, and Settings. **Decision Room** moved off +`needs decision` — its blocking migration is confirmed live in prod. The +remaining `partial`/`route-only` rows each have a real, currently-open +finding cited above (five track back to open GitHub issues #372/#373/#377/ +#379/#394; the rest — Practice Effectiveness, Decision Room, Documents, +Travel, Scout Packets, half of Staff/Roles — are real gaps without a +dedicated open issue, flagged honestly rather than force-linked to a closed +one). Every feature with a live owner issue should be re-graded the same PR +that closes its tracking issue(s) — see [Maintenance](#maintenance). --- @@ -101,12 +134,12 @@ This matrix is a living artifact, not a one-time audit: 1. **Column conventions are fixed.** Keep the exact column order (Feature, Route(s), Source of Truth/Spec, Current Status, Highest-Risk Gap, Test Coverage, Owner Issue, Production Readiness) so the validator in `scripts/baseball/check-readiness-matrix.ts` can reliably parse every row. 2. **Route paths must stay in code spans** (`` `/baseball/dashboard/...` ``) with no trailing punctuation inside the span. The validator extracts every code-span string that starts with `/baseball/` from the Route(s) column and resolves it against `src/app/baseball/**` using the same route-group/dynamic-segment logic as `src/app/baseball/actions/__tests__/auth-redirects-resolve.test.ts` (factored into `scripts/baseball/resolve-route.ts`). A route that no longer resolves to a real `page.tsx` fails the advisory CI check. -3. **Owner issues must be full GitHub issue URLs** (`https://github.com/njrini99-code/helmv3/issues/`), not bare `#` references, inside the Owner Issue column — the validator only checks that column. Cells without a URL (e.g. `N/A (intentional)`) are skipped, not flagged. +3. **Owner issues must be full GitHub issue URLs** (`https://github.com/njrini99-code/helmv3/issues/`), not bare `#` references, inside the Owner Issue column — the validator only checks that column. Cells without a URL (e.g. `N/A (intentional)`, or prose explaining there is no dedicated issue) are skipped, not flagged. 4. **The CI advisory check** (`.github/workflows/baseball-readiness-matrix.yml`, running `npm run check:readiness-matrix`) fails only when: - a cited route does not resolve to a real `page.tsx`/`route.ts`, or - a cited owner-issue URL resolves to a 404 (deleted/never existed) or a `closed` issue. - It does **not** require every non-`ready` row to have an owner issue — some risks (like Decision Room's unapplied migration) currently have no dedicated tracking issue, and forcing a fabricated link would be worse than an honest gap. The check is advisory (non-blocking) per the issue's acceptance criteria; it never gates merge on its own. -5. **Known gap:** Practice and Practice Effectiveness currently cite umbrella issues (#370, #400) rather than feature-specific ones, and Decision Room cites #397 as a best-available umbrella for its typed-gap pattern — none of these three issues was filed specifically for the row they're attached to. A maintainer should file dedicated issues for (a) applying the `baseball_meeting_items`/`baseball_decision_log` migration, (b) Practice/Practice Effectiveness test coverage, and replace these placeholders once that happens. + It does **not** require every non-`ready` row to have an owner issue — some risks (like Decision Room's stale type-cast cleanup, or Travel's missing test coverage) currently have no dedicated tracking issue, and forcing a fabricated link would be worse than an honest gap. The check is advisory (non-blocking) per the issue's acceptance criteria; it never gates merge on its own. +5. **Known gap (2026-07-09):** Practice, Practice Effectiveness, Decision Room, Documents, Travel, Scout Packets, and half of Staff/Roles (#406's pgTAP requirement) currently have real, described gaps with no dedicated open issue — either because the umbrella issue that used to cover them closed without covering their specific residual risk (Practice Effectiveness/#400, Travel/#394), or because the fix landed without the acceptance-criteria test that was asked for (Staff-Roles/#406), or because no issue was ever filed for a real-but-minor gap (Documents/Scout Packets test depth, Decision Room's stale comments). A maintainer should file dedicated issues for these rather than leaving them as prose-only gaps indefinitely. 6. **Resolved owner issues must be removed or replaced, not left closed.** When an owner issue closes, either delete it from the row (if the gap was the only listed issue and is now fully resolved) or replace it with the next real gap's issue. A closed-but-still-linked issue will fail the CI check by design — that is the signal to update the row, not to silence the check. 7. **Status changes should ship with the PR that earns them.** If a PR adds real test coverage, applies a migration, or closes a security finding referenced here, update the corresponding row's Current Status, Highest-Risk Gap, Test Coverage, and Owner Issue cells in the same PR. 8. Re-run `npx vitest run src/app/baseball/actions/__tests__/readiness-matrix-routes.test.ts` locally after editing the Route(s) column — it gives the same route-resolution feedback as CI but runs in the standard `npm test` unit lane (no GitHub token / network access required). diff --git a/e2e/course-library.spec.ts b/e2e/course-library.spec.ts index e37667275..0b7d65d21 100644 --- a/e2e/course-library.spec.ts +++ b/e2e/course-library.spec.ts @@ -129,10 +129,8 @@ test.describe('Cloud Course Library — authenticated flow', () => { }); // Capture screenshots of the premium course picker for review as CI artifacts - // (uploaded by .github/workflows/playwright.yml). Requires - // NEXT_PUBLIC_REDESIGN=true so the redesign FairwayCoursePicker renders - // (otherwise this shoots the legacy TeePickerDrawer). Best-effort — the point - // is the image, not a gate; a missing affordance skips rather than fails. + // (uploaded by .github/workflows/playwright.yml). Best-effort — the point is + // the image, not a gate; a missing affordance skips rather than fails. test('capture: premium course picker screenshots', async ({ page }) => { const dir = 'e2e-screenshots'; diff --git a/e2e/golf-dashboard.spec.ts b/e2e/golf-dashboard.spec.ts index a0b1b48bc..758e58e85 100644 --- a/e2e/golf-dashboard.spec.ts +++ b/e2e/golf-dashboard.spec.ts @@ -10,6 +10,12 @@ import { test, expect } from '@playwright/test'; * run against real CI (no such account) failed at login. Set E2E_GOLF_EMAIL * / E2E_GOLF_PASSWORD to a seeded golf coach/player login to run these; * otherwise they self-skip instead of failing. + * + * UI: assertions target the current Fairway redesign (Wave W1 — Fairway is + * the only dashboard tree; see src/lib/redesign/flag.ts), not the legacy + * emerald/`bg-emerald-600` UI. Selectors use accessible roles/names rather + * than color utility classes, which are an implementation detail that + * changes with the design system, not the user-facing contract. */ const GOLF_EMAIL = process.env.E2E_GOLF_EMAIL; @@ -36,8 +42,8 @@ test.describe('Golf Dashboard - Player Flow', () => { // Verify dashboard loaded await expect(page).toHaveURL(/\/golf\/dashboard/); - // Should see dashboard heading - await expect(page.locator('h1')).toContainText(/Good (morning|afternoon|evening)/); + // Should see dashboard heading (FairwayPlayerDashboard's ViewHeader greeting) + await expect(page.getByRole('heading', { level: 1 })).toContainText(/Good (morning|afternoon|evening)/); }); test('should navigate to rounds page', async ({ page }) => { @@ -47,16 +53,18 @@ test.describe('Golf Dashboard - Player Flow', () => { // Should navigate to rounds page await expect(page).toHaveURL(/\/golf\/dashboard\/rounds/); - // Should see rounds heading - await expect(page.locator('h1')).toContainText('Rounds'); + // Should see the rounds masthead (FairwayRoundsLibrary's ViewHeader title + // is "Your rounds." for a player, "The library." for a coach). + await expect(page.getByRole('heading', { level: 1 })).toContainText(/rounds/i); }); test('should access new round page and see all steps', async ({ page }) => { // Navigate to new round page await page.goto('/golf/dashboard/rounds/new'); - // Step 1: Setup - Should see course setup form - await expect(page.locator('h1')).toContainText('New Round'); + // Step 1: Setup - Should see course setup form (FairwayNewRoundEntry's + // cockpit band h1, not a literal "New Round" string). + await expect(page.getByRole('heading', { level: 1 })).toContainText(/track every shot/i); await expect(page.locator('#courseName')).toBeVisible(); // Fill in course setup @@ -64,10 +72,10 @@ test.describe('Golf Dashboard - Player Flow', () => { await page.fill('#courseCity', 'Test City'); await page.fill('#courseState', 'CA'); - // Should see emerald-colored button (modern design) - const nextButton = page.locator('button:has-text("Next: Configure Holes")'); + // Manual entry (no course-library/saved-course pick) → the primary CTA + // submits straight to hole configuration. + const nextButton = page.getByRole('button', { name: 'Next: configure holes →' }); await expect(nextButton).toBeVisible(); - await expect(nextButton).toHaveClass(/bg-emerald-600/); // Click next await nextButton.click(); @@ -76,31 +84,34 @@ test.describe('Golf Dashboard - Player Flow', () => { await expect(page.locator('text=E2E Test Course')).toBeVisible(); await expect(page.locator('text=Total Par')).toBeVisible(); - // Should see hole configuration grid - await expect(page.locator('text=Hole')).toBeVisible(); - await expect(page.locator('text=Par')).toBeVisible(); - await expect(page.locator('text=Yardage')).toBeVisible(); - - // Should see Front 9 / Back 9 tabs - await expect(page.locator('button:has-text("Front 9")')).toBeVisible(); - await expect(page.locator('button:has-text("Back 9")')).toBeVisible(); - - // Should see par selector buttons with modern styling - const parButtons = page.locator('button:has-text("3"), button:has-text("4"), button:has-text("5")').first(); - await expect(parButtons).toBeVisible(); - - // Should see save button with emerald styling - const saveButton = page.locator('button:has-text("Save Course & Start Round")'); + // Should see hole configuration grid headers (FairwayHoleConfig: "Yards", + // not the legacy "Yardage"). Exact match — "Par"/"Total Par" and + // "Yards"/"Total Yards" both appear on this screen, so a bare substring + // match would hit more than one element (strict-mode violation). + await expect(page.getByText('Hole', { exact: true })).toBeVisible(); + await expect(page.getByText('Par', { exact: true })).toBeVisible(); + await expect(page.getByText('Yards', { exact: true })).toBeVisible(); + + // Should see Front 9 / Back 9 segmented options (rendered as role="radio", + // labeled "Front 9 · " / "Back 9 · "). + await expect(page.getByRole('radio', { name: /^Front 9/ })).toBeVisible(); + await expect(page.getByRole('radio', { name: /^Back 9/ })).toBeVisible(); + + // Should see the per-hole par selector chips (accessible name "Hole N par P"). + const parButton = page.getByRole('button', { name: /^Hole 1 par (3|4|5)$/ }).first(); + await expect(parButton).toBeVisible(); + + // Should see the primary action to start tracking + const saveButton = page.getByRole('button', { name: 'Start round →' }); await expect(saveButton).toBeVisible(); - await expect(saveButton).toHaveClass(/bg-emerald-600/); // Click save to proceed to shot tracking await saveButton.click(); - // Step 3: Shot Tracking - Should see ShotTrackingComprehensive + // Step 3: Shot Tracking - Should see the tracking surface for hole 1 await expect(page.locator('text=Hole 1')).toBeVisible({ timeout: 5000 }); - // Should see shot tracking interface + // Should see the shot entry interface // (Note: Full shot tracking test would be more complex, // this just verifies the component loaded) }); @@ -109,39 +120,42 @@ test.describe('Golf Dashboard - Player Flow', () => { // Navigate to stats page await page.goto('/golf/dashboard/stats'); - // Should see stats page - await expect(page.locator('h1')).toContainText('Stats'); - - // Should see category pills (modern design) - await expect(page.locator('button:has-text("Scoring")')).toBeVisible(); - await expect(page.locator('button:has-text("Driving")')).toBeVisible(); - await expect(page.locator('button:has-text("Approach")')).toBeVisible(); - await expect(page.locator('button:has-text("Putting")')).toBeVisible(); - await expect(page.locator('button:has-text("Scrambling")')).toBeVisible(); + // Should see stats page (FairwayPlayerStats' title is "Your stats" for a + // player viewing their own page). + await expect(page.getByRole('heading', { level: 1 })).toContainText(/stats/i); + + // Should see category tabs (FairwayStatsCockpit renders these as + // role="tab" via the Fairway Tabs/Radix primitive, not plain buttons). + await expect(page.getByRole('tab', { name: 'Scoring' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Driving' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Approach' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Putting' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Scrambling' })).toBeVisible(); }); - test('should verify emerald color scheme throughout', async ({ page }) => { + test('should keep the primary setup/holes CTAs keyboard-accessible', async ({ page }) => { // Go to new round page await page.goto('/golf/dashboard/rounds/new'); - // Check for emerald focus rings on inputs + // The primary CTA must be a real, named, focusable control — not just a + // colored div. (This replaces the old bg-emerald-600 color-scheme check, + // which asserted on an implementation-detail utility class rather than + // the accessible contract.) const courseNameInput = page.locator('#courseName'); await courseNameInput.click(); + await courseNameInput.fill('Accessibility Test Course'); - // Check button has emerald background - const nextButton = page.locator('button:has-text("Next: Configure Holes")'); - await expect(nextButton).toHaveClass(/bg-emerald-600/); + const nextButton = page.getByRole('button', { name: 'Next: configure holes →' }); + await expect(nextButton).toBeEnabled(); // Navigate to holes step - await page.fill('#courseName', 'Color Test Course'); await nextButton.click(); - // Verify hole configuration uses emerald colors + // Verify hole configuration loaded and its primary action is likewise a + // real, named, focusable control. await expect(page.locator('text=Total Par')).toBeVisible(); - - // Check save button uses emerald - const saveButton = page.locator('button:has-text("Save Course & Start Round")'); - await expect(saveButton).toHaveClass(/bg-emerald-600/); + const saveButton = page.getByRole('button', { name: 'Start round →' }); + await expect(saveButton).toBeEnabled(); }); }); diff --git a/e2e/golf-round.spec.ts b/e2e/golf-round.spec.ts index 26f04e77a..bad1e089c 100644 --- a/e2e/golf-round.spec.ts +++ b/e2e/golf-round.spec.ts @@ -10,6 +10,13 @@ import { test, expect } from '@playwright/test'; * run against real CI (no such account) failed at login. Set E2E_GOLF_EMAIL * / E2E_GOLF_PASSWORD to a seeded golf coach/player login to run these; * otherwise they self-skip instead of failing. + * + * UI: assertions target the current Fairway redesign (Wave W1 — Fairway is + * the only dashboard tree; see src/lib/redesign/flag.ts). The legacy + * "Add Shot" button → "Shot Details" modal no longer exists — shot entry is + * an always-visible inline panel (FairwayShotEntry) — and the in-round exit + * action is "Save & exit" (FairwayScorecardHeader's desktop band), not a + * generic "Save"/"Pause" button. */ const GOLF_EMAIL = process.env.E2E_GOLF_EMAIL; @@ -41,26 +48,23 @@ test.describe('Golf Round - Complete Flow', () => { await page.fill('#courseCity', 'Test City'); await page.fill('#courseState', 'CA'); - // Click next - await page.click('button:has-text("Next: Configure Holes")'); + // Click next (manual entry has no baseline, so the CTA submits to the + // hole-configuration step) + await page.getByRole('button', { name: 'Next: configure holes →' }).click(); // Step 2: Hole Configuration await expect(page.locator('text=E2E Test Course')).toBeVisible(); // Configure some holes (par values should be prefilled) - await page.click('button:has-text("Save Course & Start Round")'); + await page.getByRole('button', { name: 'Start round →' }).click(); // Step 3: Shot Tracking await expect(page.locator('text=Hole 1')).toBeVisible({ timeout: 5000 }); - // Enter first shot (tee shot) - const shotButton = page.locator('button:has-text("Add Shot")').first(); - if (await shotButton.isVisible()) { - await shotButton.click(); - - // Wait for shot modal - await expect(page.locator('text=Shot Details')).toBeVisible({ timeout: 3000 }); - } + // The shot entry panel is always visible (no "Add Shot" trigger / "Shot + // Details" modal in the current UI) — verify it loaded for the first shot. + const shotResultGroup = page.getByRole('radiogroup', { name: 'Shot result' }); + await expect(shotResultGroup).toBeVisible({ timeout: 3000 }); }); test('should show validation errors for invalid input', async ({ page }) => { @@ -68,7 +72,7 @@ test.describe('Golf Round - Complete Flow', () => { await page.goto('/golf/dashboard/rounds/new'); // Try to proceed without filling course name - const nextButton = page.locator('button:has-text("Next: Configure Holes")'); + const nextButton = page.getByRole('button', { name: 'Next: configure holes →' }); // Should not proceed without required fields await nextButton.click(); @@ -87,20 +91,26 @@ test.describe('Golf Round - Complete Flow', () => { await page.fill('#courseCity', 'Test City'); await page.fill('#courseState', 'TX'); - await page.click('button:has-text("Next: Configure Holes")'); - await page.click('button:has-text("Save Course & Start Round")'); + await page.getByRole('button', { name: 'Next: configure holes →' }).click(); + await page.getByRole('button', { name: 'Start round →' }).click(); // Wait for shot tracking to load await expect(page.locator('text=Hole 1')).toBeVisible({ timeout: 5000 }); - // Look for save/pause button - const saveButton = page.locator('button:has-text("Save"), button:has-text("Pause")').first(); - if (await saveButton.isVisible()) { - await saveButton.click(); + // "Save & exit" (desktop scorecard header) opens the exit-confirmation + // sheet; "Save for later" persists the in-progress round. + const exitButton = page.getByRole('button', { name: 'Save & exit' }); + if (await exitButton.isVisible()) { + await exitButton.click(); + + const saveForLater = page.getByRole('button', { name: /Save for later/i }); + if (await saveForLater.isVisible()) { + await saveForLater.click(); - // Verify round is saved - await page.goto('/golf/dashboard/rounds'); - await expect(page.locator('text=Progress Test Course')).toBeVisible(); + // Verify round is saved + await page.goto('/golf/dashboard/rounds'); + await expect(page.locator('text=Progress Test Course')).toBeVisible(); + } } }); @@ -108,8 +118,8 @@ test.describe('Golf Round - Complete Flow', () => { // Navigate to rounds list await page.goto('/golf/dashboard/rounds'); - // Look for an in-progress round - const continueButton = page.locator('button:has-text("Continue")').first(); + // Look for an in-progress round (FairwayUnfinishedBanner's primary CTA) + const continueButton = page.getByRole('button', { name: 'Continue' }).first(); if (await continueButton.isVisible()) { await continueButton.click(); @@ -131,7 +141,7 @@ test.describe('Golf Round - Complete Flow', () => { // Simulate offline mode await page.context().setOffline(true); - await page.click('button:has-text("Next: Configure Holes")'); + await page.getByRole('button', { name: 'Next: configure holes →' }).click(); // Should show error message or handle gracefully // Re-enable network @@ -154,12 +164,12 @@ test.describe('Golf Round - Stats Calculation', () => { // Navigate to stats page await page.goto('/golf/dashboard/stats'); - // Should see stats categories - await expect(page.locator('button:has-text("Scoring")')).toBeVisible(); - await expect(page.locator('button:has-text("Putting")')).toBeVisible(); + // Should see stats categories (rendered as role="tab") + await expect(page.getByRole('tab', { name: 'Scoring' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Putting' })).toBeVisible(); // Click on putting to see detailed stats - await page.click('button:has-text("Putting")'); + await page.getByRole('tab', { name: 'Putting' }).click(); // Should see putting stats (if rounds exist) // Stats visibility depends on having rounds @@ -172,7 +182,7 @@ test.describe('Golf Round - Stats Calculation', () => { const categories = ['Scoring', 'Driving', 'Approach', 'Putting', 'Scrambling']; for (const category of categories) { - await page.click(`button:has-text("${category}")`); + await page.getByRole('tab', { name: category }).click(); // Wait for the category's content fetch to settle instead of a flat // 500ms guess. Tolerant (matches e2e/helpers/common.ts's // waitForPageLoad) since this loop has no single stable selector to diff --git a/e2e/golf-team-join.spec.ts b/e2e/golf-team-join.spec.ts deleted file mode 100644 index a4a51b2a4..000000000 --- a/e2e/golf-team-join.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Golf Team Management E2E Test - * Tests the complete flow: create team → generate invite code → player joins - * Part of GENIUS audit Phase 4: User Journey Testing - */ - -const TEST_COACH = { - email: 'coach@helmsportslabs.com', - password: 'TestPassword123!', -}; - -const TEST_PLAYER = { - email: 'player@helmsportslabs.com', - password: 'TestPassword123!', -}; - -test.describe('Golf Team Management - Player Join Flow', () => { - test.skip('should show error for invalid invite code', async ({ page }) => { - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_PLAYER.email); - await page.fill('input[type="password"]', TEST_PLAYER.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - // Navigate to invalid invite code - await page.goto('http://localhost:3000/golf/join/INVALID1'); - - // Should see error message - await expect(page.locator('text=Invalid Invite Code, text=does not exist')).toBeVisible({ timeout: 5000 }); - }); - - test.skip('should display team join confirmation for valid code', async ({ page }) => { - // This test requires a valid invite code from a real team - // In a real test environment, we would: - // 1. Create a team as coach - // 2. Get the invite code - // 3. Login as player - // 4. Navigate to /golf/join/{code} - // 5. Verify the join confirmation UI - - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_PLAYER.email); - await page.fill('input[type="password"]', TEST_PLAYER.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - // For now, just verify the join page structure exists - // Replace VALIDCODE with an actual code for integration testing - // await page.goto('http://localhost:3000/golf/join/VALIDCODE'); - // await expect(page.locator('h1')).toContainText(/Join/i); - // await expect(page.locator('button:has-text("Confirm & Join")')).toBeVisible(); - }); - - test.skip('should show already on team message', async ({ page }) => { - // This test verifies the error message when a player is already on a team - // and tries to join another team - - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_PLAYER.email); - await page.fill('input[type="password"]', TEST_PLAYER.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - // If player is already on a team and tries to join another, - // they should see the error from validateGolfPlayerCanJoinTeam - // "You are already on {team name}. Golf players can only be on one team at a time." - }); -}); - -test.describe('Golf Team Management - Roster from Coach Perspective', () => { - test.skip('should display roster with player status badges', async ({ page }) => { - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_COACH.email); - await page.fill('input[type="password"]', TEST_COACH.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - // Navigate to roster - await page.goto('http://localhost:3000/golf/dashboard/roster'); - - // Should see roster heading - await expect(page.locator('h1')).toContainText(/Roster/i); - - // Should see player cards or empty state - const playerCards = page.locator('[data-testid="player-card"], .player-card, tr'); - const emptyState = page.locator('text=No players, text=Add players'); - - const hasPlayers = await playerCards.count() > 0; - const isEmpty = await emptyState.isVisible(); - - expect(hasPlayers || isEmpty).toBe(true); - - // If players exist, should see status badges - if (hasPlayers) { - const statusBadge = page.locator('text=Active, text=Injured, text=Redshirt, text=Inactive').first(); - await expect(statusBadge).toBeVisible({ timeout: 3000 }); - } - }); - - test.skip('should change player status via dropdown', async ({ page }) => { - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_COACH.email); - await page.fill('input[type="password"]', TEST_COACH.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - await page.goto('http://localhost:3000/golf/dashboard/roster'); - - // Find a status badge that's editable - const statusBadge = page.locator('button:has-text("Active")').first(); - - if (await statusBadge.isVisible()) { - await statusBadge.click(); - - // Should see dropdown with status options - await expect(page.locator('text=Injured')).toBeVisible({ timeout: 2000 }); - await expect(page.locator('text=Redshirt')).toBeVisible({ timeout: 2000 }); - await expect(page.locator('text=Inactive')).toBeVisible({ timeout: 2000 }); - - // Click a different status - await page.click('text=Injured'); - - // Wait for the actual status change instead of a flat 1000ms guess. - await expect(statusBadge).toContainText('Injured', { timeout: 5000 }).catch(() => {}); - } - }); - - test.skip('should remove player from team', async ({ page }) => { - await page.goto('http://localhost:3000/golf/login'); - await page.fill('input[type="email"]', TEST_COACH.email); - await page.fill('input[type="password"]', TEST_COACH.password); - await page.click('button[type="submit"]'); - await page.waitForURL('**/golf/dashboard**', { timeout: 10000 }); - - await page.goto('http://localhost:3000/golf/dashboard/roster'); - - // Find player actions menu - const actionsMenu = page.locator('button[aria-label="Player actions"], button:has-text("...")').first(); - - if (await actionsMenu.isVisible()) { - await actionsMenu.click(); - - // Should see remove option - const removeOption = page.locator('text=Remove from Team'); - await expect(removeOption).toBeVisible({ timeout: 2000 }); - - // Note: Actually clicking remove would modify data, - // so we just verify the option exists - } - }); -}); - -test.describe('Golf Team - Player Onboarding with Team Join', () => { - test.skip('should allow entering invite code during onboarding', async ({ page }) => { - // This tests the team join step in player onboarding - // Navigate directly to the onboarding page (would need a new account) - - await page.goto('http://localhost:3000/golf/player'); - - // Should see team join step eventually - // Look for invite code input (verification only, full test needs new account) - const _inviteCodeInput = page.locator('input[placeholder*="invite"], input[placeholder*="code"]'); - void _inviteCodeInput; // Placeholder for future integration test - - // Note: Full onboarding test would require: - // 1. Creating a new account - // 2. Going through all onboarding steps - // 3. Entering a valid invite code - // 4. Verifying team join success - }); -}); diff --git a/memory/context/golfhelm-features.md b/memory/context/golfhelm-features.md index 9ae7feff3..a968d72fe 100644 --- a/memory/context/golfhelm-features.md +++ b/memory/context/golfhelm-features.md @@ -24,7 +24,7 @@ 3. Qualifiers ✅ **Team Management Features:** -4. Calendar & Events ✅ +4. Calendar & Events ⚠️ 5. Roster Management ✅ 6. Task Management ⚠️ 7. Messaging ✅ @@ -43,7 +43,7 @@ 18. Coaching Intelligence Settings ✅ **Player-Facing Features:** -19. Player Hub (Home) ✅ +19. Player Hub (merged into Dashboard Action Center, 2026-07-09) ✅ 20. Player CoachHelm Dashboard ✅ 21. My Development ✅ 22. My Qualifiers ✅ @@ -220,10 +220,10 @@ golf_qualifiers, golf_qualifier_entries --- -## 4. CALENDAR & EVENTS ✅ +## 4. CALENDAR & EVENTS ⚠️ ### What It Does -Full event management with RSVP, attendance tracking, recurring events, availability polling, iCal feeds, and academic conflict detection. +Full event management with RSVP, attendance tracking, recurring events, iCal feeds, and academic conflict detection. (Availability polling is NOT built — see Sub-Features below.) ### Data Flow ``` @@ -246,7 +246,7 @@ Calendar views: MonthView, WeekView, DayView, MobileListView ### Sub-Features **Recurring Events** ✅ — RRULE parsing, edit scopes: this | thisAndFuture | all -**Availability Polling** ✅ — Coach creates polls, players vote, convert to event +**Availability Polling** ❌ — NOT BUILT (backlog). No `availability-polling.ts`/`availability-locking.ts` or any poll UI exists in code; `golf_availability_polls`/`golf_poll_responses` are orphaned schema only (verified 2026-07-09, zero references in `src/`). **iCal Feeds** ✅ — Token auth, RFC 5545 compliant, rate limited **Event Status Lifecycle** ✅ — draft → confirmed → completed | cancelled **Academic Conflict Detection** ✅ — Checks classes, blocked time, exclusions @@ -258,7 +258,7 @@ Calendar views: MonthView, WeekView, DayView, MobileListView | Wrapper | `src/components/golf/calendar/GolfCalendarWrapper.tsx` | | Views | `MonthView.tsx`, `WeekView.tsx`, `DayView.tsx`, `MobileCalendarWrapper.tsx` | | Actions | `event-lifecycle.ts`, `recurring-events.ts`, `attendance.ts` | -| Polling | `availability-polling.ts`, `availability-locking.ts` | +| Polling | _not built — no files exist (backlog)_ | | Feeds | `calendar-feeds.ts`, `calendar-sync.ts`, `caldav-sync.ts` | | Conflicts | `src/lib/calendar/conflicts.ts`, `src/lib/calendar/ical.ts` | @@ -433,7 +433,7 @@ createTravelItinerary() → INSERT golf_travel_itineraries (transport, hotel, flight, gear list, room assignments) → Links to golf_events via event_id -Player view (via Player Hub): +Player view (via the Dashboard's Action Center, formerly the standalone Player Hub — see Feature 19): → Trip cards with destination, transport type, dates → Hotel info (name, address, phone, confirmation #) → Packing list, room assignments, uniform requirements @@ -796,49 +796,70 @@ golf_coach_philosophy (all philosophy columns) ## 19. PLAYER HUB (HOME) ✅ +> **2026-07-09 — merged into the Dashboard.** The standalone "Hub" front door +> described below no longer exists as its own destination. Wave W2 nav +> consolidation (Target IA, `PRODUCTION_READINESS_MISSION_2026-07-09.md`) +> folded its triage content into the Player Dashboard as an "Action Center" +> section, one home instead of two. `/golf/dashboard/hub` is now a permanent +> server-redirect to `/golf/dashboard` (old links/bookmarks still land +> somewhere real). Full create/edit/manage surfaces for tasks and travel — +> the Hub was always read-mostly for those — remain in the **Team Hub** +> (`/golf/dashboard/team-hub`, tasks + travel tabs); full RSVP/scheduling +> remains in Calendar. `PlayerHub.tsx` / `PlayerHubWrapper.tsx` were deleted +> in Wave W1 (golf legacy-tree deletion); this section documents the current +> Action Center surface that replaced them. + ### What It Does -Personal action center for players. Central dashboard showing upcoming travel, assigned tasks, and event invitations with inline RSVP. +A "needs you now" triage section on the player's Dashboard: top pending +tasks, events awaiting RSVP, recent announcements, and upcoming trips — +plus the player's top CoachHelm signal. Renders only the first few items of +each (3 tasks, 3 events, 2 trips) with a link out to the Team Hub / Calendar +for the full list; renders nothing at all when there's genuinely nothing to +triage (honest-empty, not a placeholder). ### Data Flow ``` -/golf/dashboard/hub → PlayerHubWrapper → PlayerHub - Section 1: TRAVEL - → Query golf_travel_itineraries WHERE team_id = player's team - → Display: destination, transport type (✈🚌🚐🚗), dates, hotel, packing list - → Status: upcoming, in transit, completed, days away - - Section 2: TASKS - → Query golf_tasks + golf_task_assignments WHERE player_id - → Display: title, due date, category, upload requirement - → ⚠️ DUAL TABLE BUG: Hub page READS from golf_task_completions, but - completeTask() action WRITES to golf_task_assignments (status + completed_at) - These two tables can get out of sync. - → Status: pending, overdue, completed (color-coded) - - Section 3: EVENTS - → Query golf_events WHERE team_id, start_time >= now - 24h - → Query golf_event_attendance for RSVP status - → Display: event name, type badge, time, location, mandatory flag, going/maybe counts - → Action: respondToEvent(eventId, status) → UPSERT golf_event_attendance - → RSVP buttons: Accept | Decline | Maybe +/golf/dashboard → page.tsx (Dashboard route) + → getPlayerHubSummaryData(teamId, playerId) [player-hub-data.ts] + → golf_travel_itineraries WHERE team_id, departure_date >= now-120d + → golf_task_assignments WHERE player_id (status, completed_at) + joined to golf_tasks for title/description/due_date/category + (same table completeTask() writes to — no dual-table read/write + mismatch in this data path) + → RPC get_player_hub_events(team_id, player_id, since) → golf_events + + golf_event_attendance (RSVP status, going/maybe counts) + → getPlayerHubAnnouncements() → RPC get_player_hub_announcements() + → getTopInsightForPlayer() → top evidence-backed CoachHelm insight + → passed as the `actionCenter` prop into FairwayPlayerDashboard + → renders: + Tasks (top 3, "Open Team Hub" → team-hub?tab=tasks) + Awaiting RSVP (top 3, "View calendar" → /golf/dashboard/calendar) + Action: respondToEvent(eventId, status) → UPSERT golf_event_attendance + Announcements (AnnouncementsList) + Upcoming trips (top 2, "Open Team Hub" → team-hub?tab=travel) + Action: completeTask(taskId) → golf_task_assignments (optimistic) + +/golf/dashboard/hub → PlayerHubRedirectPage → redirect('/golf/dashboard') ``` ### Key Files | Type | Path | |------|------| -| Route | `src/app/golf/(dashboard)/dashboard/hub/page.tsx` | -| Components | `src/components/golf/player-hub/PlayerHub.tsx` (40KB), `PlayerHubWrapper.tsx` | +| Route (Dashboard, fetches the data) | `src/app/golf/(dashboard)/dashboard/page.tsx` | +| Redirect (former Hub route) | `src/app/golf/(dashboard)/dashboard/hub/page.tsx` | +| Data | `src/app/golf/actions/player-hub-data.ts` (`getPlayerHubSummaryData`) | +| Component | `src/components/fairway/pages/dashboard/PlayerActionCenter.tsx` | +| Host component | `src/components/fairway/pages/dashboard/FairwayPlayerDashboard.tsx` | +| Shared presentational parts | `src/components/fairway/pages/hub/hub-parts.tsx` (TaskRow, RSVPRow, TripRow, TripDetailSheet, AnnouncementsList) | +| Management surface | `src/app/golf/(dashboard)/dashboard/team-hub/**` (full tasks/travel CRUD) | ### DB Tables -golf_travel_itineraries, golf_tasks, golf_task_assignments, golf_task_completions (⚠️ read here but writes go to golf_task_assignments), golf_events, golf_event_attendance - -### Known Gaps -| Gap | Severity | Details | -|-----|----------|---------| -| Task completion dual-table bug | **High** | Hub page reads `golf_task_completions` for completion status, but `completeTask()` action writes to `golf_task_assignments`. Tasks may appear incomplete in the Hub even after completion. | +golf_travel_itineraries, golf_tasks, golf_task_assignments, golf_events, golf_event_attendance, golf_announcements (via RPC) ### Dependencies - **Depends on**: Travel, Tasks, Calendar & Events +- **Feeds into**: nothing feeds off it directly — it is a read-mostly triage + view over Team Hub / Calendar data --- @@ -1278,7 +1299,7 @@ Reads from ALL major tables: users, golf_coaches, golf_players, golf_teams, golf | 1 | Round Tracking | Both | ✅ | 95% | Auto-save | Offline shots, SG calc | | 2 | Stats & Analytics | Both | ✅ | 90% | — | SG not populated | | 3 | Qualifiers | Both | ✅ | 100% | ✅ | — | -| 4 | Calendar & Events | Both | ✅ | 100% | — | — | +| 4 | Calendar & Events | Both | ⚠️ | 90% | — | Availability Polling not built (backlog) | | 5 | Roster Management | Both | ✅ | 100% | — | — | | 6 | Task Management | Both | ⚠️ | 85% | ✅ | Reminder auto-trigger | | 7 | Messaging | Both | ✅ | 100% | ✅ | — | @@ -1293,7 +1314,7 @@ Reads from ALL major tables: users, golf_coaches, golf_players, golf_teams, golf | 16 | Intelligence Dashboard | Coach | ✅ | 90% | — | — | | 17 | CoachHelm Analytics | Coach | ⚠️ | 70% | — | Effectiveness data sparse | | 18 | Coaching Intel Settings | Coach | ✅ | 100% | — | — | -| 19 | Player Hub (Home) | Player | ✅ | 100% | — | — | +| 19 | Player Hub (merged into Dashboard Action Center) | Player | ✅ | 100% | — | Standalone Hub route now redirects; management in Team Hub | | 20 | Player CoachHelm | Player | ✅ | 95% | — | — | | 21 | My Development | Player | ✅ | 100% | — | — | | 22 | My Qualifiers | Player | ✅ | 100% | — | — | @@ -1309,7 +1330,7 @@ Reads from ALL major tables: users, golf_coaches, golf_players, golf_teams, golf ## PRIORITY GAPS (by business impact) ### High Priority -1. **Player Hub task completion bug** — Hub reads `golf_task_completions` but `completeTask()` writes to `golf_task_assignments`. Tasks show as incomplete in Hub after completion. Needs unified to one table. +1. ~~Player Hub task completion bug~~ — **RESOLVED / stale.** `golf_task_completions` does not exist in the live schema; the current Action Center data layer (`player-hub-data.ts`, post-2026-07-09 Hub→Dashboard merge) reads `golf_task_assignments`, the same table `completeTask()` writes to. No dual-table mismatch in this path. 2. **CoachHelm effectiveness tracking** — DB ready, needs server actions + UI. Without this, coaches can't measure if AI insights are working. 3. **CoachHelm outcome measurement** — No way to close the feedback loop (mark insights as improved/no_change/worsened). 4. **Strokes Gained calculation** — Framework exists, data exists (shots table), but SG columns in stats cache are null. This is the most important golf statistic. diff --git a/memory/registry.yml b/memory/registry.yml index ab3bc69c9..e022063fa 100644 --- a/memory/registry.yml +++ b/memory/registry.yml @@ -536,7 +536,7 @@ features: - src/components/golf/coachhelm/v3/IntentPill/** - src/components/golf/coachhelm/v3/IntentDrawer/** - src/components/golf/coachhelm/v3/StandingBar/** - - src/components/golf/coachhelm/v3/CounterfactualLine.tsx + - src/app/golf/(dashboard)/dashboard/my-standing/page.tsx - src/components/golf/coachhelm/v3/HeroNarrativeCard.tsx api: - src/app/api/coachhelm/v3/** @@ -720,7 +720,7 @@ features: - src/app/golf/(dashboard)/dashboard/team/** components: - src/components/golf/roster/** - - src/components/golf/dashboard/team-pulse-card.tsx + - src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx api: - src/app/api/roster/** - src/app/api/team/** @@ -736,7 +736,7 @@ features: - supabase/migrations/*coach_staff*.sql tests: - e2e/roster.spec.ts - - src/components/golf/dashboard/__tests__/team-pulse-card.test.tsx + - src/app/golf/actions/__tests__/dashboard-data.test.ts integrations: - supabase - team_access_control @@ -835,7 +835,6 @@ features: - supabase/migrations/*join*.sql tests: - e2e/auth.spec.ts - - e2e/golf-team-join.spec.ts - src/test/lib/auth/** integrations: - supabase @@ -848,7 +847,7 @@ features: required_checks: - npm run typecheck - npm run test -- --run src/test/lib/auth - - npm run test:e2e -- e2e/auth.spec.ts e2e/golf-team-join.spec.ts + - npm run test:e2e -- e2e/auth.spec.ts settings_preferences: name: Settings And Preferences @@ -938,7 +937,7 @@ features: - supabase/migrations/*task*.sql - supabase/migrations/*event*.sql tests: - - src/components/golf/dashboard/__tests__/team-pulse-card.test.tsx + - src/lib/golf/nav-registry.test.ts integrations: - team_operations - calendar diff --git a/middleware.ts b/middleware.ts index b3fba3400..2684066c3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,15 +1,54 @@ import { updateSession } from '@/lib/supabase/middleware'; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import * as Sentry from '@sentry/nextjs'; + +/** + * updateSession throws `NEXT_PUBLIC_SUPABASE_URL is missing or a placeholder` + * / `NEXT_PUBLIC_SUPABASE_ANON_KEY is missing` (src/lib/supabase/middleware.ts) + * when the Supabase env vars aren't configured for this deploy. That is a + * deploy-time MISCONFIGURATION, not a transient runtime failure — matched by + * message since that guard-throw lives in a separate file this middleware + * doesn't otherwise need to import from. + */ +function isConfigError(error: unknown): boolean { + return error instanceof Error && /NEXT_PUBLIC_SUPABASE_(URL|ANON_KEY)/.test(error.message); +} export async function middleware(request: NextRequest) { try { return await updateSession(request); } catch (error) { - console.warn( - '[Middleware] Session update failed:', - error instanceof Error ? error.message : String(error) - ); + if (isConfigError(error)) { + // FAIL CLOSED: this specific error means Supabase env vars are missing + // or placeholders, so updateSession never even reached auth.getUser(). + // Failing open here (as EVERY error used to, via a bare console.warn + + // NextResponse.next()) would silently disable every auth/authorization + // check in the app for the duration of the misconfiguration — every + // request to every protected /baseball, /golf, /lifting, and /admin + // dashboard route would pass straight through with NO session + // validation at all. A loud 500 is far safer than a silent open door. + Sentry.captureException(error, { + level: 'fatal', + tags: { middleware_failure: 'config' }, + }); + return new NextResponse('Service temporarily unavailable. Please try again shortly.', { + status: 500, + }); + } + + // Genuinely transient (a network blip talking to Supabase/GoTrue, a + // read failure inside checkRouteAuthorization, cookie parsing edge + // cases, …) — capture to Sentry for visibility (previously only a + // console.warn, invisible in production unless someone was tailing + // logs), but keep failing OPEN: a temporary Supabase/GoTrue hiccup must + // not lock every signed-in user out of the entire app. Route-level + // guards (getSessionProfile/server-route-guards, requireBaseballAction, + // etc.) remain the backstop for any request that slips through here. + Sentry.captureException(error, { + level: 'warning', + tags: { middleware_failure: 'transient' }, + }); return NextResponse.next(); } } diff --git a/scripts/__tests__/canonical-motion-tokens.test.mjs b/scripts/__tests__/canonical-motion-tokens.test.mjs index a316ddd4f..0edf0a6c1 100644 --- a/scripts/__tests__/canonical-motion-tokens.test.mjs +++ b/scripts/__tests__/canonical-motion-tokens.test.mjs @@ -44,7 +44,10 @@ process.chdir(repoRoot); const TARGETS = [ 'src/components/ui/reveal.tsx', 'src/components/golf/dashboard/premium-components.tsx', - 'src/app/golf/(dashboard)/dashboard/coachhelm/components/PlayerCoachHelmDashboard.tsx', + // The legacy `PlayerCoachHelmDashboard.tsx` this target used to cover was + // deleted in Wave W1 (2026-07-09, Fairway-unconditional cutover) — the + // Fairway-only CoachHelm player surface (FairwayPlayerCoachHelm) is the + // live replacement and was never part of this Tier-1 motion-vocabulary set. ]; const CANONICAL_IMPORT = '@/lib/coachhelm/v3/motion'; diff --git a/scripts/__tests__/sidebar-md-breakpoint.test.mjs b/scripts/__tests__/sidebar-md-breakpoint.test.mjs deleted file mode 100644 index 56ac100ca..000000000 --- a/scripts/__tests__/sidebar-md-breakpoint.test.mjs +++ /dev/null @@ -1,172 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, stat } from 'node:fs/promises'; -import { join, resolve, relative } from 'node:path'; - -/** - * Regression guard for the Wave W4C responsive sidebar breakpoint shift. - * - * W4C moves the GolfHelm persistent sidebar from a `lg` (1024px) breakpoint - * down to `md` (768px) so iPad-portrait (768–1023px) gets the full desktop - * rail instead of the hamburger-only mobile chrome. The change lives entirely - * in the dashboard shell (`GolfDashboardShell.tsx`): - * - * - the persistent sidebar wrapper flips from `hidden lg:block` to - * `hidden md:block` - * - the main-content compensation padding (`pl`/`pb`) and the desktop - * notification bell flip from `lg:` to `md:` - * - the mobile drawer (overlay + sliding panel) flips its hide breakpoint - * from `lg:hidden` to `md:hidden` so it is PRESERVED for 320–767px and - * never double-renders alongside the persistent rail at md+. - * - * The `ContainerGrid` `wide` primitive must keep its `2xl:max-w-[1536px]` - * affordance so data-dense dashboards can stretch on very large viewports. - * - * This test asserts that: - * 1. The persistent sidebar wrapper uses `md:block` (not `lg:block`). - * 2. No `lg:`-gated layout token survives in the shell (full migration). - * 3. The mobile drawer panel + overlay are still rendered AND gated at - * `md:hidden` so the 320–767 drawer is preserved. - * 4. The drawer dialog + focus-trap/Escape wiring and the mobile-open - * trigger plumbing (setMobileOpen) remain present. - * 5. ContainerGrid `wide` still yields `2xl:max-w-[1536px]`. - * - * It FAILS on regression to the `lg` breakpoint, on removal of the mobile - * drawer, or on loss of the wide-container affordance. - * - * Audit reference: - * docs/operations/2026-05-28-ultra-audit-MASTER-synthesis.md — responsive / - * surface remediation (W4 wave): iPad-portrait sidebar + wide containers. - */ - -const REPO_ROOT = resolve(import.meta.dirname, '..', '..'); - -const SHELL = join(REPO_ROOT, 'src', 'app', 'golf', '(dashboard)', 'GolfDashboardShell.tsx'); -const SIDEBAR = join(REPO_ROOT, 'src', 'components', 'golf', 'layout', 'GolfSidebar.tsx'); -const CONTAINERS = join(REPO_ROOT, 'src', 'components', 'ui', 'containers.tsx'); - -async function exists(path) { - return (await stat(path).catch(() => null)) !== null; -} - -/** - * Strip /* … *​/ block comments and // line comments so structural checks - * don't false-positive on prose inside doc comments that legitimately - * mentions lg:/md: tokens. - */ -function stripComments(content) { - return content - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); -} - -test('persistent sidebar wrapper is gated at md (not lg)', async () => { - assert.ok(await exists(SHELL), `GolfDashboardShell must exist: ${SHELL}`); - const content = stripComments(await readFile(SHELL, 'utf8')); - - // The persistent rail wrapper must use the md breakpoint. - assert.match( - content, - /className="hidden md:block"/, - 'The persistent sidebar wrapper must be gated `hidden md:block` (iPad portrait gets the rail).', - ); - - // It must NOT have regressed back to the lg breakpoint. - assert.doesNotMatch( - content, - /className="hidden lg:block"/, - 'The persistent sidebar wrapper regressed to `hidden lg:block`; it must be `hidden md:block`.', - ); -}); - -test('no lg:-gated layout token survives in the shell', async () => { - const content = stripComments(await readFile(SHELL, 'utf8')); - - // After W4C the entire sidebar/main/drawer coordination moved to md:. Any - // residual lg: utility in the shell means the migration is incomplete and - // the breakpoints are incoherent (e.g. sidebar at md but padding at lg). - const lgMatches = content.match(/\blg:[A-Za-z0-9[\]#(),./_-]+/g) ?? []; - assert.deepEqual( - lgMatches, - [], - `GolfDashboardShell still contains lg:-gated layout tokens — these must move to md: ` + - `for a coherent breakpoint: ${JSON.stringify(lgMatches)}`, - ); -}); - -test('mobile drawer is preserved and gated at md:hidden', async () => { - const content = stripComments(await readFile(SHELL, 'utf8')); - - // The sliding drawer panel must still exist (role="dialog", aria-modal), - // and be hidden from md+ so it never double-renders with the persistent rail. - assert.match(content, /id="mobile-sidebar"/, 'Mobile drawer panel (#mobile-sidebar) must remain.'); - assert.match(content, /role="dialog"/, 'Mobile drawer must keep role="dialog".'); - assert.match(content, /aria-modal="true"/, 'Mobile drawer must keep aria-modal="true".'); - - // Both the drawer panel and its scrim/overlay must hide at md+ (md:hidden), - // never the old lg:hidden. - const mdHiddenCount = (content.match(/\bmd:hidden\b/g) ?? []).length; - assert.ok( - mdHiddenCount >= 2, - `Expected the mobile overlay AND drawer panel to be gated md:hidden (>=2 occurrences); ` + - `found ${mdHiddenCount}. The 320–767 drawer must be preserved and hidden from md+.`, - ); - assert.doesNotMatch( - content, - /\blg:hidden\b/, - 'The mobile drawer/overlay still uses lg:hidden — it must move to md:hidden so the rail ' + - 'takes over from md+.', - ); - - // The mobile-open trigger plumbing (drawer open/close) and focus management - // must remain wired. - assert.match(content, /setMobileOpen/, 'Drawer open/close plumbing (setMobileOpen) must remain.'); - assert.match(content, /mobileSidebarRef/, 'Drawer focus-trap ref must remain.'); - assert.match(content, /Escape/, 'Drawer Escape-to-close handler must remain.'); -}); - -test('main content compensation padding flips to md (rail offset)', async () => { - const content = stripComments(await readFile(SHELL, 'utf8')); - - // Collapsed vs expanded rail widths must be reserved from md+. - assert.match( - content, - /md:pl-\[72px\]/, - 'Collapsed-rail left padding must be md:pl-[72px] so content clears the rail at md+.', - ); - assert.match( - content, - /md:pl-64/, - 'Expanded-rail left padding must be md:pl-64 so content clears the rail at md+.', - ); -}); - -test('ContainerGrid wide keeps the 2xl:max-w-[1536px] affordance', async () => { - assert.ok(await exists(CONTAINERS), `containers.tsx must exist: ${CONTAINERS}`); - const content = stripComments(await readFile(CONTAINERS, 'utf8')); - - assert.match( - content, - /wide\s*&&\s*['"]2xl:max-w-\[1536px\]['"]/, - 'ContainerGrid `wide` must yield `2xl:max-w-[1536px]` for data-dense dashboards.', - ); -}); - -test('GolfSidebar still ships full primary + secondary nav and sign-out', async () => { - // Behaviour preservation: the breakpoint shift must not have stripped any - // nav surface or interaction from the rail itself. - assert.ok(await exists(SIDEBAR), `GolfSidebar must exist: ${SIDEBAR}`); - const content = stripComments(await readFile(SIDEBAR, 'utf8')); - - for (const navList of [ - 'coachNavItems', - 'coachSecondaryNav', - 'playerNavItems', - 'playerSecondaryNav', - ]) { - assert.match(content, new RegExp(navList), `GolfSidebar must keep the ${navList} nav list.`); - } - assert.match(content, /handleSignOut/, 'GolfSidebar must keep the sign-out handler.'); - assert.match(content, /setCollapsed/, 'GolfSidebar must keep the collapse toggle.'); - assert.match(content, /setMobileOpen/, 'GolfSidebar must keep mobile nav-click close plumbing.'); -}); diff --git a/scripts/build-baseball-qa-pack.mjs b/scripts/build-baseball-qa-pack.mjs index eb8504ccd..9d146559b 100644 --- a/scripts/build-baseball-qa-pack.mjs +++ b/scripts/build-baseball-qa-pack.mjs @@ -4,8 +4,7 @@ * Usage: node scripts/build-baseball-qa-pack.mjs */ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'fs'; -import { join, basename } from 'path'; -import { globSync } from 'glob'; +import { join } from 'path'; const ROOT = new URL('..', import.meta.url).pathname; const SRC = process.env.QA_SRC || '/tmp/baseball-qa-full'; @@ -102,8 +101,10 @@ const COACH_REDIRECT_NOTES = { '/baseball/dashboard/dev-plan': 'Player-only → coach-onboarding if no player record.', '/baseball/dashboard/settings/privacy': 'Player-only privacy form.', '/baseball/dashboard/settings/notifications': 'Alias → settings/program#notifications.', - '/baseball/dashboard/settings/ai': 'Alias → settings/program#ai.', - '/baseball/dashboard/settings/appearance': 'Alias → settings/program#appearance.', + // '/baseball/dashboard/settings/ai' and '/baseball/dashboard/settings/appearance' + // were removed in Wave W5 (Lift Lab / Performance reskin cleanup) — they are not + // live redirect aliases, the routes no longer exist. settings/program still + // covers AI + appearance preferences as sections on one page. '/baseball/dashboard/page.tsx': 'INVALID AUDIT PATH — use /baseball/dashboard (redirects to command-center).', }; @@ -145,7 +146,7 @@ function inferActions(route) { function parseNavRegistry() { const text = readFileSync(join(ROOT, 'src/lib/baseball/nav-registry.ts'), 'utf8'); const entries = []; - const blocks = text.split(/\n \{/).slice(1); + const blocks = text.split(/\n {2}\{/).slice(1); for (const block of blocks) { const id = block.match(/id: '([^']+)'/)?.[1]; const href = block.match(/href: '([^']+)'/)?.[1]; diff --git a/scripts/seed-rini-baseball-demo.ts b/scripts/seed-rini-baseball-demo.ts index 98ab50868..8190053e1 100644 --- a/scripts/seed-rini-baseball-demo.ts +++ b/scripts/seed-rini-baseball-demo.ts @@ -748,9 +748,9 @@ async function main() { console.log('\nSkipped (schema mismatch):'); for (const s of skipped) console.log(` ⚠ ${s}`); } - console.log(`\nDemo logins:`); - console.log(` COACH : ${COACH_EMAIL} / ${COACH_PASSWORD} (head coach + lifting coach)`); - console.log(` PLAYER: ${PLAYER_EMAIL} / ${PLAYER_PASSWORD} (Marcus Rodriguez, SS #7)`); + console.log(`\nDemo logins (passwords masked — set via RINI_DEMO_COACH_PASSWORD/RINI_DEMO_PLAYER_PASSWORD env vars):`); + console.log(` COACH : ${COACH_EMAIL} / *** (head coach + lifting coach)`); + console.log(` PLAYER: ${PLAYER_EMAIL} / *** (Marcus Rodriguez, SS #7)`); console.log(` Team : Rini University Baseball (${TEAM_ID})`); } diff --git a/scripts/ui-intelligence/generate-atlas.ts b/scripts/ui-intelligence/generate-atlas.ts index 9ce5e4e1e..45b883f16 100644 --- a/scripts/ui-intelligence/generate-atlas.ts +++ b/scripts/ui-intelligence/generate-atlas.ts @@ -47,7 +47,13 @@ const CATALOG_PATH = join(UI_DIR, "catalog.json"); const CROSSCUT_PATH = join(PARTS_V2_DIR, "_crosscut.json"); const DESIGNSYSTEM_PATH = join(PARTS_REMEDIATION_DIR, "_designsystem.json"); const DESIGN_SYSTEM_DOC = "ui-intelligence/DESIGN-SYSTEM.md"; -const SIDEBAR_PATH = join(REPO_ROOT, "src", "components", "golf", "layout", "GolfSidebar.tsx"); +// Wave W1 (2026-07-09): the legacy GolfSidebar.tsx fork was deleted — Fairway +// is the only golf dashboard tree now. Wave W2 (2026-07-09) then extracted the +// 8-hub coach/player nav definitions OUT of FairwayDashboardShell.tsx into a +// dedicated registry module (src/lib/golf/nav-registry.ts — +// buildCoachRailSections()/buildPlayerRailSections()), so THAT is now the +// single source of truth this atlas parser must read. +const SIDEBAR_PATH = join(REPO_ROOT, "src", "lib", "golf", "nav-registry.ts"); const OUT_MD = join(UI_DIR, "ROUTE-ATLAS.md"); const OUT_HTML = join(UI_DIR, "atlas.html"); @@ -645,6 +651,91 @@ interface NavItem { label: string; href: string; } +/** Extract every literal `{ ... label: '...' ... href: '...' ... }` object + * from an arbitrary source slice (flat objects only — no nested `{}` between + * the label/href keys, which is exactly the GolfSubTab / NavItem-arg shape + * nav-registry.ts declares). */ +function extractNavItemObjects(body: string): { label: string; href: string }[] { + const items: { label: string; href: string }[] = []; + const objRe = /\{[^{}]*href:\s*['"`]([^'"`]+)['"`][^{}]*\}/g; + let m: RegExpExecArray | null; + while ((m = objRe.exec(body)) !== null) { + const block = m[0]; + const href = m[1]; + const lm = block.match(/label:\s*['"`]([^'"`]+)['"`]/); + items.push({ label: lm ? lm[1] : href, href }); + } + return items; +} + +/** Slice a named function's body: declaration → next top-level `export function`. */ +function extractFnBody(src: string, fnName: string): string { + const fnStart = src.indexOf(`function ${fnName}`); + if (fnStart === -1) return ""; + const nextFnStart = src.indexOf("\nexport function ", fnStart + 1); + return nextFnStart !== -1 ? src.slice(fnStart, nextFnStart) : src.slice(fnStart); +} + +/** Bracket-depth-matched slice of a top-level `const NAME: ... = [ … ];` array + * literal (handles the NESTED braces inside each GolfSubTab entry, unlike + * extractNavItemObjects's flat-object regex — this only finds the outer + * array's span; extractNavItemObjects then walks the flat entries inside). */ +function extractConstArrayBody(src: string, constName: string): string { + const declIdx = src.indexOf(`const ${constName}`); + if (declIdx === -1) return ""; + const bracketStart = src.indexOf("[", declIdx); + if (bracketStart === -1) return ""; + let depth = 0; + for (let i = bracketStart; i < src.length; i++) { + if (src[i] === "[") depth++; + else if (src[i] === "]") { + depth--; + if (depth === 0) return src.slice(bracketStart, i + 1); + } + } + return src.slice(bracketStart); +} + +/** + * WAVE W2's nav-registry.ts declares the 8-item coach/player rail in TWO + * SEPARATE functions (buildCoachRailSections / buildPlayerRailSections) — a + * real architectural improvement over the old single-function-with-a-role- + * switch shape this parser used to split heuristically (second + * "/golf/dashboard" occurrence). BUT the composite hub rail items (Team, + * Calendar, Rounds & Stats, Messages, Operations) pass their `href` as a + * DERIVED expression (`team.tabs[0]!.href`), not a string literal — the flat- + * object regex can't resolve that, so those rail items are unioned in from + * their own hub's tab-array constant instead (COACH_TEAM_TABS, etc.), which + * always uses literal hrefs. Dedup by href keeps a hub's rail entry from + * appearing twice when its landing tab is also its first array member. + */ +function extractRoleNav( + src: string, + railFnName: string, + tabConstNames: readonly string[], +): NavItem[] { + const seen = new Set(); + const items: NavItem[] = []; + const sources = [extractFnBody(src, railFnName), ...tabConstNames.map((n) => extractConstArrayBody(src, n))]; + for (const source of sources) { + for (const item of extractNavItemObjects(source)) { + if (seen.has(item.href)) continue; + seen.add(item.href); + items.push(item); + } + } + return items; +} + +const COACH_TAB_CONSTS = [ + "COACH_TEAM_TABS", + "COACH_CALENDAR_TABS", + "COACH_ROUNDS_STATS_TABS", + "COACH_MESSAGES_TABS", + "COACH_OPERATIONS_TABS", +] as const; +const PLAYER_TAB_CONSTS = ["PLAYER_TEAM_TABS"] as const; + function parseSidebarNav(): { coach: NavItem[]; player: NavItem[] } { const empty = { coach: [] as NavItem[], player: [] as NavItem[] }; let src = ""; @@ -653,31 +744,9 @@ function parseSidebarNav(): { coach: NavItem[]; player: NavItem[] } { } catch { return empty; } - const items: { label: string; href: string; pos: number }[] = []; - const objRe = /\{[^{}]*href:\s*['"`]([^'"`]+)['"`][^{}]*\}/g; - let m: RegExpExecArray | null; - while ((m = objRe.exec(src)) !== null) { - const block = m[0]; - const href = m[1]; - const lm = block.match(/(?:label|name|title):\s*['"`]([^'"`]+)['"`]/); - items.push({ label: lm ? lm[1] : href, href, pos: m.index }); - } - if (items.length === 0) return empty; - // The file lists the coach group first, then the player group. Both groups - // begin with a Dashboard item, so split on the SECOND occurrence of /golf/dashboard. - let splitIdx = items.length; - let dashSeen = 0; - for (let i = 0; i < items.length; i++) { - if (items[i].href === "/golf/dashboard") { - dashSeen++; - if (dashSeen === 2) { - splitIdx = i; - break; - } - } - } - const coach = items.slice(0, splitIdx).map((i) => ({ label: i.label, href: i.href })); - const player = items.slice(splitIdx).map((i) => ({ label: i.label, href: i.href })); + const coach = extractRoleNav(src, "buildCoachRailSections", COACH_TAB_CONSTS); + const player = extractRoleNav(src, "buildPlayerRailSections", PLAYER_TAB_CONSTS); + if (coach.length === 0 && player.length === 0) return empty; return { coach, player }; } const sidebarNav = parseSidebarNav(); @@ -1626,7 +1695,7 @@ function buildMarkdown(): string { // Cross-page navigation map out.push("## Cross-page navigation map"); out.push(""); - out.push("Primary navigation comes from `GolfSidebar.tsx`. Two role-scoped nav groups:"); + out.push("Primary navigation comes from `src/lib/golf/nav-registry.ts`. Two role-scoped nav groups:"); out.push(""); out.push("**Coach sidebar**"); if (sidebarNav.coach.length) { diff --git a/scripts/ui-intelligence/normalize-devin-routes.ts b/scripts/ui-intelligence/normalize-devin-routes.ts index 1ac9c871c..6ed266fa4 100644 --- a/scripts/ui-intelligence/normalize-devin-routes.ts +++ b/scripts/ui-intelligence/normalize-devin-routes.ts @@ -215,7 +215,14 @@ const TABLE: TableRow[] = [ }, // --- PLAYER-ONLY (role player, auth true, enabled true) --- - { route: '/golf/dashboard/hub', role: 'player', category: 'player-hub', criticality: 'high' }, + { + route: '/golf/dashboard/hub', + role: 'player', + category: 'core', + criticality: 'low', + needs_confirmation: true, + notes: 'redirects to /golf/dashboard (Hub merged into Dashboard Action Center, 2026-07-09)', + }, { route: '/golf/dashboard/coachhelm', role: 'player', category: 'coachhelm', criticality: 'high' }, { route: '/golf/dashboard/coachhelm/chat', role: 'player', category: 'coachhelm', criticality: 'medium' }, { route: '/golf/dashboard/my-development', role: 'player', category: 'development', criticality: 'high' }, diff --git a/scripts/ui-intelligence/refresh-landed.mjs b/scripts/ui-intelligence/refresh-landed.mjs index 8fe3450f9..174b86ae4 100644 --- a/scripts/ui-intelligence/refresh-landed.mjs +++ b/scripts/ui-intelligence/refresh-landed.mjs @@ -3,9 +3,9 @@ * ============================================================================ * refresh-landed — "once a page lands, replace its screenshot with the new one" * ---------------------------------------------------------------------------- - * As each route's redesign LANDS, re-capture it against the REDESIGN-ON app and - * swap its screenshot in the inventory the atlas reads from — so the visual - * inventory tracks the rollout instead of going stale on the legacy shots. + * As each route's redesign LANDS, re-capture it against the app and swap its + * screenshot in the inventory the atlas reads from — so the visual inventory + * tracks the rollout instead of going stale on the legacy shots. * * node scripts/ui-intelligence/refresh-landed.mjs [...more] * # examples: @@ -14,7 +14,7 @@ * # node scripts/ui-intelligence/refresh-landed.mjs --redesigned # re-shoot every already-tagged route * * What it does, per matched route: - * 1. ensures a NEXT_PUBLIC_REDESIGN=1 dev server is up (boots one if needed), + * 1. ensures a dev server is up (boots one if needed), * 2. refreshes the player/coach auth states (npm run ui:auth), * 3. re-captures ONLY the matched routes (UI_ROUTE_FILTER → ui:screenshots:capture), * overwriting ui-intelligence/screenshots/desktop//full-page.png, @@ -78,13 +78,13 @@ function httpOk(url) { async function ensureServer() { if (await httpOk(READY_URL)) { - console.log('• dev server already up (assuming NEXT_PUBLIC_REDESIGN=1)'); + console.log('• dev server already up'); return null; } - console.log('• no server on :3000 — booting NEXT_PUBLIC_REDESIGN=1 dev …'); + console.log('• no server on :3000 — booting dev …'); const child = spawn('npm', ['run', 'dev'], { cwd: ROOT, - env: { ...process.env, NEXT_PUBLIC_REDESIGN: '1' }, + env: { ...process.env }, detached: true, stdio: 'ignore', }); diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx new file mode 100644 index 000000000..071b2adf9 --- /dev/null +++ b/src/app/admin/error.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { RouteErrorBoundary } from '@/components/errors'; + +/** + * Helm Bridge root error boundary. + * + * Every panel inside the Bridge already degrades gracefully via + * `PanelBoundary` (src/app/admin/_components/PanelBoundary.tsx) — one + * upstream hiccup scopes to a single amber card, never the whole console. + * But nothing upstream of a panel was protected: a throw at the very top of + * a page component (e.g. a bare `await requireSuperAdmin()` in + * `AdminOverviewPage` / `TracerPage` before any `` is + * reached), or anywhere else in an /admin page.tsx or nested layout outside + * a panel, had zero local boundary and fell all the way to the generic + * cross-product `src/app/error.tsx` (non-Fairway chrome, "Baseball + * Dashboard" style CTAs — wrong console for a super-admin). + * + * Reuses the same Fairway-tokened `RouteErrorBoundary` the golf Fairway + * shell uses (see src/app/golf/(dashboard)/error.tsx) so the degraded state + * still looks like Helm Bridge, not a foreign product. + * + * NOTE (Next.js boundary scope): error.tsx does NOT catch throws in + * `layout.tsx` of this SAME folder (src/app/admin/layout.tsx) — only in + * nested layouts/pages below it. A throw from `checkSuperAdminAccess()` in + * that layout still bubbles to the root `src/app/error.tsx`. That layout only + * ever *redirects* on auth failure (a Next.js control-flow throw, not a real + * error) — a genuine failure there (e.g. Supabase down) is the one remaining + * gap, sized in the W9 report as a follow-up, not fixed here. + */ +export default function AdminError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/src/app/admin/golf/tracer/page.tsx b/src/app/admin/golf/tracer/page.tsx index 3778fc2f1..1f1f3b3f7 100644 --- a/src/app/admin/golf/tracer/page.tsx +++ b/src/app/admin/golf/tracer/page.tsx @@ -1,6 +1,6 @@ import { requireSuperAdmin } from '@/lib/admin/require-super-admin'; import { bridgeGetTracerData } from '@/app/admin/actions/golf-tracer'; -import { Surface, StatusPill, type FwStatusTone } from '@/components/fairway'; +import { Surface, StatusPill, InlineNotice, type FwStatusTone } from '@/components/fairway'; import { PanelBoundary } from '../../_components/PanelBoundary'; import { PanelAllClear, PanelNoData } from '../../_components/PanelStates'; import { KpiTile } from '../../_components/KpiTile'; @@ -133,6 +133,18 @@ export default async function TracerPage() { return (
+ + This is a minimum-viable read-only port. The per-round diagnostic + drill-down and data-quality "fix" actions from the legacy + console are not wired here yet (the gated delegations already exist + in golf-tracer.ts — only the UI + is missing). Need to inspect or fix a specific round right now? Use + the legacy tracer tab at{' '} + + /golf/admin?tab=tracer + {' '} + in the meantime. + diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx new file mode 100644 index 000000000..7bbdb454f --- /dev/null +++ b/src/app/admin/loading.tsx @@ -0,0 +1,17 @@ +import { FairwayGenericPageSkeleton } from '@/components/fairway'; + +/** + * Helm Bridge root loading UI. + * + * `src/app/admin/layout.tsx` does `await checkSuperAdminAccess()` (a DB + * round-trip) before it renders `AdminShell` at all — that's real async work + * in the layout itself, so the whole `/admin` segment suspends on first + * navigation and Next.js needs a loading boundary at this level (the shell + * hasn't mounted yet, so no panel-level skeleton is on screen to cover it). + * Reuses the same Fairway-tokened generic skeleton other routes fall back to + * when they don't have a page-shape-matched one — swap for something more + * specific if the Bridge overview ever gets its own. + */ +export default function AdminLoading() { + return ; +} diff --git a/src/app/admin/not-found.tsx b/src/app/admin/not-found.tsx new file mode 100644 index 000000000..8c3ed45f0 --- /dev/null +++ b/src/app/admin/not-found.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import { Compass } from 'lucide-react'; +import { EmptyState, Button } from '@/components/fairway'; + +/** + * Helm Bridge root 404. + * + * Catches `notFound()` from dynamic Bridge routes (an `/admin/errors/[fingerprint]` + * or `/admin/teams/[id]` / `/admin/users/[id]` lookup that comes up empty) and + * any mistyped `/admin/*` path. Renders INSIDE `AdminShell` — `src/app/admin/layout.tsx` + * already wraps `children` in the Bridge chrome, so this keeps the rail/top + * bar instead of falling through to the cross-product root `not-found.tsx` + * (which offers Golf/Baseball dashboard buttons — the wrong console for a + * super-admin). Mirrors the same pattern already shipped for the golf + * Fairway shell (src/app/golf/(dashboard)/dashboard/not-found.tsx). + */ +export default function AdminNotFound() { + return ( +
+ } + title="We couldn't find that" + description="This page may have moved, or the record you're looking for no longer exists." + action={ + + } + /> +
+ ); +} diff --git a/src/app/api/account/delete/__tests__/route.test.ts b/src/app/api/account/delete/__tests__/route.test.ts new file mode 100644 index 000000000..467c5e6dd --- /dev/null +++ b/src/app/api/account/delete/__tests__/route.test.ts @@ -0,0 +1,173 @@ +// ============================================================================= +// src/app/api/account/delete/__tests__/route.test.ts +// +// P1 (Production-Readiness Mission W0a) — /api/account/delete RESTRICT FK +// failure. A live-schema check found several ON DELETE NO ACTION +// "who-did-this" attribution columns referencing baseball_coaches.id / +// golf_coaches.id / users.id that blocked the final `users` delete for ANY +// coach/admin who had ever created a game, practice block, academic +// exclusion, announcement, assigned goal, travel itinerary, or touched the +// CRM tables. This locks in: +// 1. Those attribution columns are nulled out (reassigned) BEFORE the +// final users delete, scoped to the correct coach id — not the raw +// auth user id. +// 2. The engagement-events cleanup now matches on baseball_coaches.id +// (previously compared against the auth user id and silently deleted +// nothing). +// 3. A residual FK violation (23503) on the final delete returns an +// honest, actionable 409 — never a generic 500, never silently +// swallowed. +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const BASEBALL_COACH_ID = 'bcoach-1'; +const USER_ID = 'user-1'; + +const getUser = vi.fn(); +const deleteUserMock = vi.fn(async () => ({ error: null })); +const logServerError = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/server-error-logger', () => ({ logServerError })); + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(async () => ({ auth: { getUser } })), +})); + +// Records every update/delete call so tests can assert on exactly which +// table + column + value each cleanup step touched. +type Call = { table: string; op: 'update' | 'delete'; column?: string; value?: unknown; payload?: unknown }; +let calls: Call[] = []; +let usersDeleteError: { code: string; message: string } | null = null; + +function makeTable(table: string) { + return { + select: () => ({ + eq: (col: string, value: unknown) => ({ + maybeSingle: async () => { + if (table === 'baseball_coaches' && col === 'user_id' && value === USER_ID) { + return { data: { id: BASEBALL_COACH_ID }, error: null }; + } + if (table === 'golf_coaches') { + return { data: null, error: null }; + } + return { data: null, error: null }; + }, + }), + }), + update: (payload: unknown) => ({ + eq: async (column: string, value: unknown) => { + calls.push({ table, op: 'update', column, value, payload }); + return { error: null }; + }, + }), + delete: () => ({ + eq: async (column: string, value: unknown) => { + calls.push({ table, op: 'delete', column, value }); + if (table === 'users' && usersDeleteError) { + return { error: usersDeleteError }; + } + return { error: null }; + }, + }), + }; +} + +const adminFrom = vi.fn((table: string) => makeTable(table)); + +vi.mock('@/lib/supabase/admin', () => ({ + createAdminClient: vi.fn(() => ({ + from: adminFrom, + auth: { admin: { deleteUser: deleteUserMock } }, + })), +})); + +import { DELETE } from '@/app/api/account/delete/route'; + +describe('/api/account/delete', () => { + beforeEach(() => { + vi.clearAllMocks(); + calls = []; + usersDeleteError = null; + getUser.mockResolvedValue({ data: { user: { id: USER_ID } }, error: null }); + deleteUserMock.mockResolvedValue({ error: null }); + }); + + it('rejects unauthenticated callers', async () => { + getUser.mockResolvedValue({ data: { user: null }, error: null }); + + const res = await DELETE(); + + expect(res.status).toBe(401); + }); + + it('reassigns NO ACTION coach-attribution columns using the resolved baseball_coaches.id (not the raw auth user id) before deleting the user', async () => { + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.success).toBe(true); + + const gamesUpdate = calls.find((c) => c.table === 'baseball_games' && c.op === 'update'); + expect(gamesUpdate?.column).toBe('created_by'); + expect(gamesUpdate?.value).toBe(BASEBALL_COACH_ID); + expect(gamesUpdate?.payload).toEqual({ created_by: null }); + + const practiceBlocksUpdate = calls.find((c) => c.table === 'baseball_practice_blocks' && c.op === 'update'); + expect(practiceBlocksUpdate?.value).toBe(BASEBALL_COACH_ID); + + const invitationsDelete = calls.find((c) => c.table === 'baseball_team_invitations' && c.op === 'delete'); + expect(invitationsDelete?.column).toBe('created_by_coach_id'); + expect(invitationsDelete?.value).toBe(BASEBALL_COACH_ID); + + // REGRESSION: engagement events must be matched on the resolved coach id, + // never the raw auth user id (the pre-fix bug matched coach_id against + // user.id, which is a different id space and silently deleted 0 rows). + const engagementDelete = calls.find((c) => c.table === 'baseball_player_engagement_events' && c.op === 'delete'); + expect(engagementDelete?.column).toBe('coach_id'); + expect(engagementDelete?.value).toBe(BASEBALL_COACH_ID); + expect(engagementDelete?.value).not.toBe(USER_ID); + }); + + it('reassigns NO ACTION user-attribution (CRM/admin) columns using the raw auth user id', async () => { + await DELETE(); + + const crmCreated = calls.find( + (c) => c.table === 'crm_coaches' && c.op === 'update' && c.column === 'created_by', + ); + expect(crmCreated?.value).toBe(USER_ID); + + const crmArchived = calls.find( + (c) => c.table === 'crm_coaches' && c.op === 'update' && c.column === 'archived_by', + ); + expect(crmArchived?.value).toBe(USER_ID); + + const taskTemplates = calls.find((c) => c.table === 'golf_task_templates' && c.op === 'update'); + expect(taskTemplates?.value).toBe(USER_ID); + }); + + it('returns an honest 409 (not a generic 500, not a silent swallow) when the final delete still hits a residual FK violation', async () => { + usersDeleteError = { code: '23503', message: 'update or delete on table "users" violates foreign key constraint' }; + + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.error).toMatch(/reassigned by an admin/i); + expect(logServerError).toHaveBeenCalledWith( + expect.stringContaining('Account deletion blocked by FK constraint'), + expect.objectContaining({ userId: USER_ID, errorCode: '23503' }), + ); + expect(deleteUserMock).not.toHaveBeenCalled(); + }); + + it('returns a generic 500 for a non-FK users-delete failure', async () => { + usersDeleteError = { code: '42501', message: 'permission denied' }; + + const res = await DELETE(); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.error).toBe('Failed to delete account data'); + }); +}); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 22921dc11..9ad91bc57 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -3,6 +3,92 @@ import { createClient } from '@/lib/supabase/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { logServerError } from '@/lib/server-error-logger'; +// ============================================================================= +// SECURITY / CORRECTNESS (Production-Readiness Mission W0a): +// +// This route used to attempt `DELETE FROM users` directly after a handful of +// unrelated cleanup deletes, then rely on `baseball_coaches.user_id` / +// `golf_coaches.user_id` ON DELETE CASCADE to remove the coach profile. That +// CASCADE only succeeds if every row referencing the coach's OWN id (or the +// user's id directly) has an ON DELETE behavior of CASCADE or SET NULL. A +// live-schema check (information_schema.referential_constraints) found a set +// of "who did this" attribution columns with ON DELETE NO ACTION instead — +// the DEFAULT Postgres behavior most other equivalent columns in this schema +// override to SET NULL, but these did not: +// +// baseball_games.created_by -> baseball_coaches.id +// baseball_practice_blocks.coach_owner_id -> baseball_coaches.id +// golf_academic_exclusions.excluded_by -> golf_coaches.id +// golf_announcements.created_by -> golf_coaches.id +// golf_goals.coach_id_if_assigned -> golf_coaches.id +// golf_travel_itineraries.created_by -> golf_coaches.id +// crm_coaches.archived_by / created_by -> users.id +// crm_contact_log.created_by -> users.id +// crm_email_templates.created_by -> users.id +// crm_events.created_by -> users.id +// golf_task_templates.created_by -> users.id +// golf_team_coachhelm_settings.disabled_by -> users.id +// +// Any coach who has ever created a game, a practice block, an academic +// exclusion, an announcement, an assigned goal, a travel itinerary, or (for +// admin/CRM users) touched the CRM tables above would hit a hard Postgres FK +// violation on the `users` delete, rolling the whole statement back — this +// was the "RESTRICT FK failure for most active coach/admin accounts." +// +// Fix: reassign (NULL out) every one of those attribution columns for this +// user's coach/user id BEFORE the final `users` delete, so the CASCADE it +// triggers never hits a blocking reference. This is non-destructive — it +// only detaches the "who did this" stamp, never removes the underlying +// content (the game, the announcement, the exclusion, etc. all stay intact +// for the team). `baseball_team_invitations` rows this coach created are +// DELETED outright rather than reassigned — an invitation/join-code row is +// operational metadata (not team-shared content; redeemed invitations don't +// affect the resulting `baseball_team_members` rows, which have no FK to +// `baseball_team_invitations`), so removing the coach's own invite codes on +// deletion is safe and non-destructive to anyone else's data. +// +// RESIDUAL GAP (documented, not silently swallowed): a SMALL number of +// similar attribution columns are NOT NULL, so they cannot be reassigned to +// NULL, and the rows they're on hold substantive shared content (a player's +// entered stat line, a logged travel expense, an assigned goal, a qualifier +// selection) that this route must not delete just because the entering +// coach is leaving. (One of these is the deprecated legacy per-player stat +// table named in src/lib/baseball/stat-layer-manifest.ts's +// DEPRECATED_STAT_TABLES — its exact identifier is deliberately not spelled +// out literally here, since this route never reads/writes it and doing so +// would trip the stat-layer-contract scan, #381.): +// +// coach_id (NOT NULL) on the deprecated legacy per-player stat table above -> baseball_coaches.id +// baseball_box_score_uploads.coach_id -> baseball_coaches.id (NOT NULL) +// golf_goals.created_by_user_id -> users.id (NOT NULL) +// golf_qualifier_selections.selected_by_user_id -> users.id (NOT NULL) +// golf_travel_expenses.created_by -> users.id (NOT NULL) +// +// If an account is blocked by one of these, the final `users` delete below +// still fails — but now with an HONEST, actionable 409 (never a silent +// swallow, never a destructive shortcut) instead of an opaque 500. The real +// fix for this residual set is a DB migration (owner sign-off; out of scope +// for this code-only wave) to either loosen these columns to nullable + +// ON DELETE SET NULL (matching every sibling audit column in this schema) or +// introduce a system-reassignment sentinel. +// ============================================================================= + +async function nullOutColumn( + admin: ReturnType, + table: string, + column: string, + matchValue: string, +): Promise<{ label: string; error: string | null }> { + // Spans many tables/columns with no single shared row shape — the + // service-role admin client's overloaded `.from()` typing can't express + // this generically, so this helper is intentionally untyped internally. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (admin.from(table as any) as any) + .update({ [column]: null }) + .eq(column, matchValue); + return { label: `${table}.${column}`, error: error ? error.message : null }; +} + export async function DELETE() { try { const supabase = await createClient(); @@ -23,6 +109,61 @@ export async function DELETE() { } const cleanupErrors: string[] = []; + const recordCleanupError = (results: Array<{ label: string; error: string | null }>) => { + for (const r of results) { + if (r.error) cleanupErrors.push(`${r.label}: ${r.error}`); + } + }; + + // Resolve this user's coach identity in each sport (if any) up front — + // needed to reassign the NO ACTION-constrained coach-attribution columns + // before the final `users` delete cascades into baseball_coaches / + // golf_coaches. + const [{ data: baseballCoach }, { data: golfCoach }] = await Promise.all([ + admin.from('baseball_coaches').select('id').eq('user_id', user.id).maybeSingle(), + admin.from('golf_coaches').select('id').eq('user_id', user.id).maybeSingle(), + ]); + + if (baseballCoach?.id) { + const bId = baseballCoach.id; + const results = await Promise.all([ + nullOutColumn(admin, 'baseball_games', 'created_by', bId), + nullOutColumn(admin, 'baseball_practice_blocks', 'coach_owner_id', bId), + ]); + recordCleanupError(results); + + // Invitation/join-code rows this coach created — operational metadata, + // not team-shared content (see header comment). Safe to remove outright. + const { error: invitationsError } = await admin + .from('baseball_team_invitations') + .delete() + .eq('created_by_coach_id', bId); + if (invitationsError) cleanupErrors.push(`baseball_team_invitations: ${invitationsError.message}`); + } + + if (golfCoach?.id) { + const gId = golfCoach.id; + const results = await Promise.all([ + nullOutColumn(admin, 'golf_academic_exclusions', 'excluded_by', gId), + nullOutColumn(admin, 'golf_announcements', 'created_by', gId), + nullOutColumn(admin, 'golf_goals', 'coach_id_if_assigned', gId), + nullOutColumn(admin, 'golf_travel_itineraries', 'created_by', gId), + ]); + recordCleanupError(results); + } + + // NO ACTION-constrained columns referencing users.id directly (mostly + // admin/CRM attribution — covers the "admin accounts" half of this fix). + const userLevelResults = await Promise.all([ + nullOutColumn(admin, 'crm_coaches', 'archived_by', user.id), + nullOutColumn(admin, 'crm_coaches', 'created_by', user.id), + nullOutColumn(admin, 'crm_contact_log', 'created_by', user.id), + nullOutColumn(admin, 'crm_email_templates', 'created_by', user.id), + nullOutColumn(admin, 'crm_events', 'created_by', user.id), + nullOutColumn(admin, 'golf_task_templates', 'created_by', user.id), + nullOutColumn(admin, 'golf_team_coachhelm_settings', 'disabled_by', user.id), + ]); + recordCleanupError(userLevelResults); // Clean up baseball messages const { error: baseballMessagesError } = await admin @@ -38,12 +179,18 @@ export async function DELETE() { .eq('sender_id', user.id); if (golfMessagesError) cleanupErrors.push(`golf_messages: ${golfMessagesError.message}`); - // Clean up engagement events - const { error: engagementError } = await admin - .from('baseball_player_engagement_events') - .delete() - .eq('coach_id', user.id); - if (engagementError) cleanupErrors.push(`engagement_events: ${engagementError.message}`); + // Clean up engagement events this user recorded AS A COACH. `coach_id` + // on this table references baseball_coaches.id, not the auth user id — + // resolve it first (previously this matched `coach_id` against the raw + // auth user id, which almost never equals a baseball_coaches.id, so this + // cleanup silently deleted 0 rows for every coach). + if (baseballCoach?.id) { + const { error: engagementError } = await admin + .from('baseball_player_engagement_events') + .delete() + .eq('coach_id', baseballCoach.id); + if (engagementError) cleanupErrors.push(`engagement_events: ${engagementError.message}`); + } const { error: userDeleteError } = await admin .from('users') @@ -51,6 +198,26 @@ export async function DELETE() { .eq('id', user.id); if (userDeleteError) { + // 23503 = foreign_key_violation. See the RESIDUAL GAP note in the + // header comment — a small, documented set of NOT NULL attribution + // columns (player stats, box score uploads, travel expenses, goals, + // qualifier selections) can still block deletion because they hold + // substantive shared content this route must not destroy. Surface + // that honestly instead of a generic 500. + if (userDeleteError.code === '23503') { + await logServerError( + `Account deletion blocked by FK constraint: ${userDeleteError.message}`, + { action: 'route.DELETE', userId: user.id, errorCode: userDeleteError.code }, + ); + return NextResponse.json( + { + error: + 'Your account has recorded data (e.g. player stats, uploaded box scores, travel expenses, or goals) that must be reassigned by an admin before deletion can complete. Please contact support.', + }, + { status: 409 } + ); + } + return NextResponse.json( { error: 'Failed to delete account data' }, { status: 500 } diff --git a/src/app/api/crm/unsubscribe/route.ts b/src/app/api/crm/unsubscribe/route.ts index 34195def9..7cbbbf1c2 100644 --- a/src/app/api/crm/unsubscribe/route.ts +++ b/src/app/api/crm/unsubscribe/route.ts @@ -14,38 +14,74 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { verifyUnsubToken } from '@/lib/crm/unsubscribe-token'; +import { logServerError } from '@/lib/server-error-logger'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +// Returns true only once the suppression row exists (or already existed) — +// that row is the source of truth senders consult before sending. A failed +// crm_coaches status update is logged but doesn't flip the result to false, +// since the suppression itself already landed. async function suppress(coachId: string): Promise { const supa = createAdminClient(); - const { data: coach } = await supa + const { data: coach, error: coachError } = await supa .from('crm_coaches') .select('email') .eq('id', coachId) .maybeSingle(); + if (coachError) { + await logServerError(`unsubscribe.suppress coach lookup failed: ${coachError.message}`, { + action: 'crm.unsubscribe.suppress', + errorCode: coachError.code, + extra: { coachId }, + }); + return false; + } const email = coach?.email?.toLowerCase().trim(); if (!email) return false; // check-then-insert (no reliance on a unique constraint existing) - const { data: existing } = await supa + const { data: existing, error: existingError } = await supa .from('crm_email_suppressions') .select('id') .eq('email', email) .maybeSingle(); + if (existingError) { + await logServerError(`unsubscribe.suppress suppression lookup failed: ${existingError.message}`, { + action: 'crm.unsubscribe.suppress', + errorCode: existingError.code, + extra: { coachId }, + }); + return false; + } if (!existing) { - await supa.from('crm_email_suppressions').insert({ + const { error: insertError } = await supa.from('crm_email_suppressions').insert({ email, reason: 'unsubscribed', source: 'system', metadata: { via: 'list-unsubscribe', coach_id: coachId }, }); + if (insertError) { + await logServerError(`unsubscribe.suppress insert failed: ${insertError.message}`, { + action: 'crm.unsubscribe.suppress', + errorCode: insertError.code, + extra: { coachId }, + }); + return false; + } } - await supa + const { error: updateError } = await supa .from('crm_coaches') .update({ email_status: 'unsubscribed', updated_at: new Date().toISOString() }) .eq('id', coachId); + if (updateError) { + await logServerError(`unsubscribe.suppress crm_coaches update failed: ${updateError.message}`, { + action: 'crm.unsubscribe.suppress', + errorCode: updateError.code, + extra: { coachId }, + }); + } return true; } @@ -59,7 +95,10 @@ export async function POST(request: Request) { const c = searchParams.get('c'); const t = searchParams.get('t'); if (!verifyUnsubToken(c, t)) return NextResponse.json({ error: 'invalid' }, { status: 400 }); - await suppress(c); + const ok = await suppress(c); + // Non-2xx tells RFC 8058 senders to retry the one-click POST — silently + // 200-ing a failed suppress() would leave the coach un-suppressed forever. + if (!ok) return NextResponse.json({ error: 'failed' }, { status: 500 }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/cron/coach-morning-digest/route.ts b/src/app/api/cron/coach-morning-digest/route.ts index 3d17986f2..b746017b1 100644 --- a/src/app/api/cron/coach-morning-digest/route.ts +++ b/src/app/api/cron/coach-morning-digest/route.ts @@ -4,6 +4,12 @@ * Every day at 06:30 UTC, pulls the top 3 actionable insights + 1 celebration * + 1 watch per coach, renders a transactional email, and sends via Resend. * + * Idempotency: a retry/manual re-trigger on the same UTC day must not + * re-email every coach. Each send carries an + * `idempotencyKey: coach-morning-digest:${coach_id}:${date}` (Resend's + * `Idempotency-Key` header) so a duplicate tick for the same coach/day + * returns Resend's cached response instead of dispatching a second digest. + * * Auth: `Authorization: Bearer ${CRON_SECRET}` (Vercel Cron standard). * Schedule: `30 6 * * *` — see vercel.json. * @@ -161,13 +167,18 @@ async function handleDigest(): Promise { // ─── 2. Process coaches in parallel batches of 5 ──────────────────────── + // Stable per-invocation dedupe key (UTC date) — computed once so every + // coach in this run shares the same period key even if the run straddles + // a millisecond boundary near midnight. + const dateKey = new Date().toISOString().slice(0, 10); + const coaches = Array.from(eligible.values()); for (let i = 0; i < coaches.length; i += COACH_BATCH_SIZE) { const batch = coaches.slice(i, i + COACH_BATCH_SIZE); await Promise.all( batch.map(async (coach) => { try { - const result = await processCoach(supabase, coach); + const result = await processCoach(supabase, coach, dateKey); if (result.sent) summary.sent += 1; else if (result.skipped) { summary.skipped += 1; @@ -218,6 +229,7 @@ interface CoachResult { async function processCoach( supabase: ReturnType, coach: EligibleCoach, + dateKey: string, ): Promise { // Collect every player id on any team this coach staffs, so the digest // cron isn't bound to a single team (coaches commonly staff JV + varsity). @@ -308,6 +320,7 @@ async function processCoach( html: rendered.html, text: rendered.text, coachId: coach.coach_id, + idempotencyKey: `coach-morning-digest:${coach.coach_id}:${dateKey}`, }); return result; diff --git a/src/app/api/cron/coachhelm-roster-sweep/route.ts b/src/app/api/cron/coachhelm-roster-sweep/route.ts index c9977276b..96e441b04 100644 --- a/src/app/api/cron/coachhelm-roster-sweep/route.ts +++ b/src/app/api/cron/coachhelm-roster-sweep/route.ts @@ -14,10 +14,11 @@ */ import { NextResponse, type NextRequest } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; -import { triggerPlayerInsightsAfterRound } from '@/app/golf/actions/insights'; +import { triggerPlayerInsightsAfterRound } from '@/lib/coachhelm/v2/trigger-insights-bridge'; import { logServerError } from '@/lib/server-error-logger'; import { requireCronAuth } from '@/lib/cron/auth'; import { recordJobRun } from '@/lib/admin/job-log'; +import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; export const runtime = 'nodejs'; export const maxDuration = 300; @@ -42,10 +43,20 @@ export async function GET(req: NextRequest) { async function handleRosterSweep(): Promise { const supabase = createAdminClient(); - const { data: memberships, error } = await supabase - .from('golf_team_members') - .select('player_id, team_id') - .eq('status', 'active'); + // Paginated: platform-wide, unfiltered fetch across every active roster — + // easily exceeds the PostgREST 1000-row cap once enough teams have active + // members (mirrors event-reminders' use of fetchAllRowsResult). + const { data: memberships, error } = await fetchAllRowsResult<{ player_id: string; team_id: string }>( + (from, to) => + supabase + .from('golf_team_members') + .select('player_id, team_id') + .eq('status', 'active') + .order('id', { ascending: true }) + .range(from, to), + undefined, + { table: 'golf_team_members', action: 'cron.coachhelm.rosterSweep.fetchMembers', sport: 'golf' }, + ); if (error) { await logServerError( @@ -71,12 +82,23 @@ async function handleRosterSweep(): Promise { // SKIP_IF_ANALYZED_WITHIN_MS (audit P1-3 — was re-running engine 3× per // round between this sweep + safety-net + post-round trigger). const recencyCutoff = new Date(Date.now() - SKIP_IF_ANALYZED_WITHIN_MS).toISOString(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data: recentRounds } = await (supabase as any) - .from('golf_rounds') - .select('player_id') - .in('player_id', uniquePlayerIds) - .gte('coachhelm_analyzed_at', recencyCutoff); + // Paginated: one row per analyzed round (not per player), so a large + // roster with many rounds analyzed inside the window can exceed the + // PostgREST 1000-row cap just as easily as the unfiltered members query + // above. + const { data: recentRounds } = await fetchAllRowsResult<{ player_id: string }>( + (from, to) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (supabase as any) + .from('golf_rounds') + .select('player_id') + .in('player_id', uniquePlayerIds) + .gte('coachhelm_analyzed_at', recencyCutoff) + .order('id', { ascending: true }) + .range(from, to), + undefined, + { table: 'golf_rounds', action: 'cron.coachhelm.rosterSweep.recentRounds', sport: 'golf' }, + ); const recentlyAnalyzedPlayerIds = new Set( ((recentRounds ?? []) as Array<{ player_id: string }>).map((r) => r.player_id), ); diff --git a/src/app/api/cron/refresh-engagement/route.ts b/src/app/api/cron/refresh-engagement/route.ts index b5a4d8e98..c569df8f0 100644 --- a/src/app/api/cron/refresh-engagement/route.ts +++ b/src/app/api/cron/refresh-engagement/route.ts @@ -14,6 +14,7 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { logServerError } from '@/lib/server-error-logger'; import { requireCronAuth } from '@/lib/cron/auth'; +import { recordJobRun } from '@/lib/admin/job-log'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -22,34 +23,40 @@ export async function GET(request: Request) { const unauthorized = requireCronAuth(request); if (unauthorized) return unauthorized; - try { - const supabase = createAdminClient(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { error } = await (supabase as any).rpc('refresh_crm_coach_engagement'); + // Now registered in vercel.json + CRON_REGISTRY (previously an orphaned + // route the matview refresh never actually ran on in prod) — wrapped in + // recordJobRun so the admin cron board can see it, matching every other + // registered cron (enforced by cron-job-log-coverage.test.ts). + return recordJobRun('refresh-engagement', async () => { + try { + const supabase = createAdminClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (supabase as any).rpc('refresh_crm_coach_engagement'); - if (error) { + if (error) { + await logServerError( + `[cron.refresh-engagement] RPC failed: ${error.message}`, + { + action: 'cron.refresh_crm_coach_engagement', + extra: { code: error.code }, + }, + 'error', + ); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ + ok: true, + refreshed_at: new Date().toISOString(), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); await logServerError( - `[cron.refresh-engagement] RPC failed: ${error.message}`, - { - action: 'cron.refresh_crm_coach_engagement', - extra: { code: error.code }, - }, + `[cron.refresh-engagement] unexpected error: ${message}`, + { action: 'cron.refresh_crm_coach_engagement' }, 'error', ); - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ error: message }, { status: 500 }); } - - return NextResponse.json({ - ok: true, - refreshed_at: new Date().toISOString(), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - await logServerError( - `[cron.refresh-engagement] unexpected error: ${message}`, - { action: 'cron.refresh_crm_coach_engagement' }, - 'error', - ); - return NextResponse.json({ error: message }, { status: 500 }); - } + }); } diff --git a/src/app/api/cron/v3/genome-nightly/route.ts b/src/app/api/cron/v3/genome-nightly/route.ts index 484b2663b..1fc7b52bf 100644 --- a/src/app/api/cron/v3/genome-nightly/route.ts +++ b/src/app/api/cron/v3/genome-nightly/route.ts @@ -18,6 +18,7 @@ import { logServerError } from '@/lib/server-error-logger'; import { requireCronAuth } from '@/lib/cron/auth'; import { computeGenomeForPlayer } from '@/lib/coachhelm/v3/genome/orchestrator'; import { recordJobRun } from '@/lib/admin/job-log'; +import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; export const runtime = 'nodejs'; export const maxDuration = 300; @@ -55,19 +56,38 @@ async function handle(): Promise { // Active team players, oldest-genome-first. NULL computed_at sorts // first so never-computed players catch up before everyone else. - const { data: existingGenomes } = await supabase - .from('golf_player_genome') - .select('player_id, computed_at') - .order('computed_at', { ascending: true }); + // Paginated: platform-wide, unfiltered fetch (both queries) can exceed the + // PostgREST 1000-row cap once the roster grows (mirrors event-reminders' + // use of fetchAllRowsResult). Ordering here only needs to be a STABLE + // unique key for correct page boundaries — the actual "stalest first" + // ordering is re-derived client-side below via computedAtByPlayer, so + // ordering by the table's own primary key (player_id) is safe. + const { data: existingGenomes } = await fetchAllRowsResult<{ player_id: string; computed_at: string }>( + (from, to) => + supabase + .from('golf_player_genome') + .select('player_id, computed_at') + .order('player_id', { ascending: true }) + .range(from, to), + undefined, + { table: 'golf_player_genome', action: 'cron.v3.genome-nightly', sport: 'golf' }, + ); const computedAtByPlayer = new Map(); for (const g of existingGenomes ?? []) { computedAtByPlayer.set(g.player_id, g.computed_at); } - const { data: members } = await supabase - .from('golf_team_members') - .select('player_id') - .eq('status', 'active'); + const { data: members } = await fetchAllRowsResult<{ player_id: string }>( + (from, to) => + supabase + .from('golf_team_members') + .select('player_id') + .eq('status', 'active') + .order('id', { ascending: true }) + .range(from, to), + undefined, + { table: 'golf_team_members', action: 'cron.v3.genome-nightly', sport: 'golf' }, + ); const allPlayerIds = Array.from(new Set((members ?? []).map((m) => m.player_id))); // Sort: never-computed (no entry in map) come first, then by ascending diff --git a/src/app/api/cron/v3/standing-refresh/route.ts b/src/app/api/cron/v3/standing-refresh/route.ts index 0b9d7e92b..9dd7a2a30 100644 --- a/src/app/api/cron/v3/standing-refresh/route.ts +++ b/src/app/api/cron/v3/standing-refresh/route.ts @@ -23,8 +23,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { logServerError } from '@/lib/server-error-logger'; import { requireCronAuth } from '@/lib/cron/auth'; -import { runGoalProgressForPlayers } from '@/app/golf/actions/v3/goal-progress'; -import { runFocusAreaProgressForPlayers } from '@/app/golf/actions/v3/focus-area-progress'; +import { runGoalProgressForPlayers, runFocusAreaProgressForPlayers } from '@/lib/golf/progress-drivers'; import { STANDING_REFRESH_METRIC_IDS, STANDING_REFRESH_DEFERRED_METRIC_IDS, diff --git a/src/app/api/cron/v3/weekly-coach-email/route.ts b/src/app/api/cron/v3/weekly-coach-email/route.ts index 220c5f771..d65fccd0e 100644 --- a/src/app/api/cron/v3/weekly-coach-email/route.ts +++ b/src/app/api/cron/v3/weekly-coach-email/route.ts @@ -2,9 +2,13 @@ * v3 weekly coach email cron — GET/POST /api/cron/v3/weekly-coach-email * * Designed to fire Sundays. Picks every active team's primary coach, - * builds a WeeklyRecap for the past 7 days, and sends via Resend. The - * route is idempotent within the same week — we tag each send so a - * future "sent already" lookup table can dedupe (deferred). + * builds a WeeklyRecap for the past 7 days, and sends via Resend. + * + * Idempotency: a retry/manual re-trigger within the same ISO week must not + * re-email every coach. Each send carries an + * `idempotencyKey: weekly-coach-email:${coach_id}:${isoWeekStart}` (Resend's + * `Idempotency-Key` header) so a duplicate tick for the same coach/week + * returns Resend's cached response instead of dispatching a second email. * * Auth: Vercel Cron sends Authorization: Bearer ${CRON_SECRET}. * @@ -21,6 +25,19 @@ import { buildWeeklyRecap } from '@/lib/coachhelm/v3/recap/builder'; import { buildWeeklyRecapHtml } from '@/lib/coachhelm/v3/recap/template'; import { sendEmail } from '@/lib/coachhelm/v3/foundation/email'; import { recordJobRun } from '@/lib/admin/job-log'; +import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; + +/** + * ISO week start (Monday), UTC, as `YYYY-MM-DD` — the dedupe period key. + * Mirrors the sibling helper in src/app/lifting/actions/performance-profile.ts. + */ +function isoWeekStartKey(d: Date): string { + const dayOfWeek = d.getUTCDay(); // 0=Sun..6=Sat + const offset = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const monday = new Date(d); + monday.setUTCDate(monday.getUTCDate() - offset); + return monday.toISOString().slice(0, 10); +} export const runtime = 'nodejs'; export const maxDuration = 300; @@ -31,6 +48,8 @@ interface SendSummary { sent: number; skipped_no_email: number; skipped_provider_unset: number; + /** Coach has explicitly opted out of the CoachHelm email digest. */ + skipped_opted_out: number; errors: number; duration_ms: number; } @@ -54,13 +73,22 @@ async function handle(): Promise { sent: 0, skipped_no_email: 0, skipped_provider_unset: 0, + skipped_opted_out: 0, errors: 0, duration_ms: 0, }; - const weekEndIso = new Date().toISOString(); + const now = new Date(); + const weekEndIso = now.toISOString(); + const weekKey = isoWeekStartKey(now); - // All teams that have at least one active member. - const { data: teams } = await sb.from('golf_teams').select('id'); + // All teams that have at least one active member. Paginated — platform-wide + // fetch with no filter can exceed the PostgREST 1000-row cap once the team + // count grows (mirrors event-reminders' use of fetchAllRowsResult). + const { data: teams } = await fetchAllRowsResult<{ id: string }>( + (from, to) => sb.from('golf_teams').select('id').order('id', { ascending: true }).range(from, to), + undefined, + { table: 'golf_teams', action: 'cron.v3.weekly-coach-email', sport: 'golf' }, + ); const teamIds = (teams ?? []).map((t) => t.id); summary.teams_considered = teamIds.length; @@ -75,6 +103,23 @@ async function handle(): Promise { .maybeSingle(); if (!staff?.coach_id) continue; + // Opt-out gate: there is no dedicated `email_weekly_recap` preference + // column, so this recap (a CoachHelm digest email, same as the daily + // coach-morning-digest cron) is gated on the CLOSEST existing coach + // preference — `golf_coach_philosophy.email_digest_enabled` — the same + // column /api/cron/coach-morning-digest already honors. Missing row → + // opted-in by default (mirrors that cron's convention: the flag only + // records explicit opt-outs). + const { data: philosophyRow } = await sb + .from('golf_coach_philosophy') + .select('email_digest_enabled') + .eq('coach_id', staff.coach_id) + .maybeSingle(); + if (philosophyRow && philosophyRow.email_digest_enabled === false) { + summary.skipped_opted_out += 1; + continue; + } + const recap = await buildWeeklyRecap(sb, { coach_id: staff.coach_id, team_id, @@ -112,6 +157,7 @@ async function handle(): Promise { { name: 'task', value: 'weekly_coach_recap' }, { name: 'team_id', value: team_id }, ], + idempotencyKey: `weekly-coach-email:${staff.coach_id}:${weekKey}`, }); if (!result.delivered) { // The wrapper returns a friendly error string when the API key diff --git a/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx b/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx index 5731d9761..4f3872906 100644 --- a/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx +++ b/src/app/baseball/(auth)/complete-signup/CompleteSignupClient.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { AlertCircle, GraduationCap, Users } from 'lucide-react'; +import { GraduationCap, Users } from 'lucide-react'; import { completeBaseballSignup } from '@/app/baseball/actions/onboarding'; import type { CoachType, PlayerType } from '@/lib/types'; import { @@ -14,6 +14,7 @@ import { BaseballAuthShell, humanizeAuthError, } from '@/components/auth/baseball-auth-shell'; +import { InkNotice } from '@/components/baseball/living-annual'; type Role = 'coach' | 'player'; @@ -207,15 +208,7 @@ export default function CompleteSignupClient() { )} - {error && ( -
- - {error} -
- )} + {error && {error}} - } - /> -
- +
+ +

+ Track student-athlete academic progress and eligibility +

+
+ { window.location.href = '/baseball/dashboard/roster'; }}> + Manage Roster + + } + /> +
); } @@ -358,100 +368,57 @@ export default function AcademicsPage() { 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'; - }; + // Tri-state, never coerced to a red "Ineligible" chrome badge — the label + // text carries the meaning; only a real, recorded `true` gets green ink. + const eligibilityTone = (isEligible: boolean | null): 'team' | 'neutral' => + isEligible ? 'team' : 'neutral'; return ( - <> -
-

Academics

-

Track student-athlete academic progress and eligibility

-
- -
- {/* Error alert */} +
+ +

+ Track student-athlete academic progress and eligibility +

+
+ +
+ {/* Error alert — the shared InlineNotice component, not an ad-hoc red box. */} {error && ( - {error} - + {error} + )} - {/* Summary cards */} -
- - -
-
- -
-
-

Total Athletes

-

{students.length}

-
-
-
-
- - - -
-
- -
-
-

Team GPA

-

- {avgGpa !== null ? avgGpa.toFixed(2) : '—'} -

-
-
-
-
- - - -
-
- -
-
-

Eligible

-

{eligibleCount}

-
-
-
-
- - - -
-
- -
-
-

At Risk

-

{atRiskCount}

-
-
-
-
+ {/* Summary — RuledStatLine, not gray icon-tile cards: the number carries + the contrast, not chrome-colored circles (spec §4.2 founder addendum). */} +
+ + + 0 && eligibleCount === students.length} + /> +
{/* Student table */} @@ -466,8 +433,8 @@ export default function AcademicsPage() { +
) : ( <> - - {standingLabel(student.academic_standing)} - - - {eligibilityLabel(student.is_eligible)} - + + )}
@@ -586,6 +549,7 @@ export default function AcademicsPage() { Edit )} +
))}
@@ -630,7 +594,7 @@ export default function AcademicsPage() { - {student.primary_position || 'N/A'} + @@ -685,15 +649,10 @@ export default function AcademicsPage() { ]} /> ) : ( - - {standingLabel(student.academic_standing)} - + )} @@ -708,9 +667,10 @@ export default function AcademicsPage() { ]} /> ) : ( - - {eligibilityLabel(student.is_eligible)} - + )} @@ -738,6 +698,6 @@ export default function AcademicsPage() {
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/academics/loading.tsx b/src/app/baseball/(dashboard)/dashboard/academics/loading.tsx index 7f4accd0c..08b8c64f5 100644 --- a/src/app/baseball/(dashboard)/dashboard/academics/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/academics/loading.tsx @@ -1,17 +1,39 @@ import { Skeleton } from '@/components/ui/skeleton'; +/** + * Route-level loading skeleton for Academics (Lane 3 · THE PRESSBOX, team + * ink). Mirrors AcademicsClient's masthead + stat row + student table so + * there is no legacy chrome flash on navigation. + */ export default function AcademicsLoading() { return ( -
-
- - -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
- - - +
+
+ + + +
+ +
+ {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ +
+ +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+
))}
diff --git a/src/app/baseball/(dashboard)/dashboard/activate/loading.tsx b/src/app/baseball/(dashboard)/dashboard/activate/loading.tsx index d7004fe52..1774d66b8 100644 --- a/src/app/baseball/(dashboard)/dashboard/activate/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/activate/loading.tsx @@ -1,4 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; /** * Activate Recruiting loading skeleton — mirrors the hero + feature-grid + CTA layout. @@ -14,18 +15,18 @@ export default function ActivateLoading() {
{/* Hero card */} -
+ -
+ {/* Feature grid */}
{Array.from({ length: 4 }).map((_, i) => ( -
+ @@ -34,12 +35,12 @@ export default function ActivateLoading() { ))}
-
+ ))}
{/* Privacy section */} -
+
@@ -53,13 +54,13 @@ export default function ActivateLoading() { ))}
-
+ {/* CTA card */} -
+ -
+
); diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx new file mode 100644 index 000000000..f8feb9ef9 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/announcements/AnnouncementsClient.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { PageLoading } from '@/components/ui/loading'; +import { useAuth } from '@/hooks/use-auth'; +import { useTeamStore } from '@/stores/team-store'; +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'; + +interface RosterPlayer { + id: string; + first_name: string | null; + last_name: string | null; +} + +export default function AnnouncementsClient() { + const { user, player, loading: authLoading } = useAuth(); + const { selectedTeamId } = useTeamStore(); + + const [announcements, setAnnouncements] = useState([]); + const [players, setPlayers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const isCoach = user?.role === 'coach'; + + // Stable refetch used both by the initial load effect and by the create/delete + // success callbacks below — router.refresh() alone cannot re-run this because + // the announcements list lives in useState, not in server-rendered data. + const fetchAnnouncements = useCallback(async () => { + if (!selectedTeamId || !user) return; + + setLoading(true); + setLoadError(null); + + const playerId = player?.id || null; + + const result = await getAnnouncementsWithMeta( + selectedTeamId, + user.id, + isCoach, + playerId + ); + + if (result.success && result.data) { + setAnnouncements(result.data); + } else { + setAnnouncements([]); + setLoadError(result.error ?? 'Announcements could not be loaded.'); + } + + // For coaches: also fetch roster for the create flow. selectedTeamId is + // already guaranteed truthy here (guard clause above returns otherwise, + // and this closure's value can't change mid-call), so only isCoach gates. + if (isCoach) { + const supabase = createClient(); + const { data: members } = await supabase + .from('baseball_team_members') + .select('player_id, player:baseball_players(id, first_name, last_name)') + .eq('team_id', selectedTeamId) + .eq('status', 'active'); + + const rosterPlayers = (members || []) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => m.player) + .filter(Boolean) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((p: any) => ({ + id: p.id, + first_name: p.first_name, + last_name: p.last_name, + })); + + setPlayers(rosterPlayers); + } + + setLoading(false); + }, [selectedTeamId, user, isCoach, player?.id]); + + useEffect(() => { + if (authLoading) return; + if (!selectedTeamId || !user) { + setLoading(false); + return; + } + void fetchAnnouncements(); + }, [authLoading, selectedTeamId, user, fetchAnnouncements]); + + if (authLoading) return ; + + if (loadError) { + return ( +
+ + void fetchAnnouncements()} + /> +
+ ); + } + + const recentCount = announcements.filter(a => { + if (!a.published_at) return false; + return (Date.now() - new Date(a.published_at).getTime()) < 7 * 86400000; + }).length; + + return ( +
+ +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/loading.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/loading.tsx index abbc56115..9b0815204 100644 --- a/src/app/baseball/(dashboard)/dashboard/announcements/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/announcements/loading.tsx @@ -1,4 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; export default function AnnouncementsLoading() { return ( @@ -13,11 +14,11 @@ export default function AnnouncementsLoading() {
{[1, 2, 3].map((i) => ( -
+ -
+ ))}
diff --git a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx index 838b3c94d..bb65b428b 100644 --- a/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/announcements/page.tsx @@ -1,128 +1,14 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { PageLoading } from '@/components/ui/loading'; -import { useAuth } from '@/hooks/use-auth'; -import { useTeamStore } from '@/stores/team-store'; -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'; - -interface RosterPlayer { - id: string; - first_name: string | null; - last_name: string | null; -} - -export default function BaseballAnnouncementsPage() { - const { user, player, loading: authLoading } = useAuth(); - const { selectedTeamId } = useTeamStore(); - - const [announcements, setAnnouncements] = useState([]); - const [players, setPlayers] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - - const isCoach = user?.role === 'coach'; - - // Stable refetch used both by the initial load effect and by the create/delete - // success callbacks below — router.refresh() alone cannot re-run this because - // the announcements list lives in useState, not in server-rendered data. - const fetchAnnouncements = useCallback(async () => { - if (!selectedTeamId || !user) return; - - setLoading(true); - setLoadError(null); - - const playerId = player?.id || null; - - const result = await getAnnouncementsWithMeta( - selectedTeamId, - user.id, - isCoach, - playerId - ); - - if (result.success && result.data) { - setAnnouncements(result.data); - } else { - setAnnouncements([]); - setLoadError(result.error ?? 'Announcements could not be loaded.'); - } - - // For coaches: also fetch roster for the create flow. selectedTeamId is - // already guaranteed truthy here (guard clause above returns otherwise, - // and this closure's value can't change mid-call), so only isCoach gates. - if (isCoach) { - const supabase = createClient(); - const { data: members } = await supabase - .from('baseball_team_members') - .select('player_id, player:baseball_players(id, first_name, last_name)') - .eq('team_id', selectedTeamId) - .eq('status', 'active'); - - const rosterPlayers = (members || []) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((m: any) => m.player) - .filter(Boolean) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((p: any) => ({ - id: p.id, - first_name: p.first_name, - last_name: p.last_name, - })); - - setPlayers(rosterPlayers); - } - - setLoading(false); - }, [selectedTeamId, user, isCoach, player?.id]); - - useEffect(() => { - if (authLoading) return; - if (!selectedTeamId || !user) { - setLoading(false); - return; - } - void fetchAnnouncements(); - }, [authLoading, selectedTeamId, user, fetchAnnouncements]); - - if (authLoading) return ; - - if (loadError) { - return ( -
- - void fetchAnnouncements()} - /> -
- ); - } - - const recentCount = announcements.filter(a => { - if (!a.published_at) return false; - return (Date.now() - new Date(a.published_at).getTime()) < 7 * 86400000; - }).length; - - return ( -
- -
- ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (AnnouncementsClient +// branches internally on role) — thin server wrapper just requires a +// signed-in baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import AnnouncementsClient from './AnnouncementsClient'; + +export default async function AnnouncementsPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/calendar/loading.tsx b/src/app/baseball/(dashboard)/dashboard/calendar/loading.tsx index f86a13f46..13378e8d9 100644 --- a/src/app/baseball/(dashboard)/dashboard/calendar/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/calendar/loading.tsx @@ -1,9 +1,11 @@ +import { PaperCard } from '@/components/baseball/living-annual'; + export default function CalendarLoading() { return (
-
+ {/* Calendar header skeleton */}
@@ -45,7 +47,7 @@ export default function CalendarLoading() {
))}
-
+
); } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx b/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx new file mode 100644 index 000000000..3a468df01 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/camps/CampsClient.tsx @@ -0,0 +1,511 @@ +'use client'; + +// ============================================================================= +// CampsClient — the shared coach + player camps/showcases surface, migrated +// onto "The Living Annual" kit. This is a recruiting-events surface: the +// coach's view (create/manage/roster) reads in Lane 2 · THE WAR ROOM clay ink; +// the player's own registration view reads in Lane 3 · THE PASSPORT green ink +// — the SAME pattern CollegeInterestClient (coach, clay) / AnalyticsClient +// (player, green) already established for role-branched surfaces. +// +// PRESENTATION ONLY. `loadCamps`/`attachActiveCounts`, the register/unregister/ +// delete server-action calls, and the create/edit modal wiring are unchanged +// — only the render moved to the kit (skeleton instead of a spinner, a shared +// `` instead of the bespoke `CampsPageHeader`, `` +// instead of colored status pills). +// ============================================================================= + +import { useState, useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { IconCalendar, IconMapPin, IconUsers, IconPlus, IconHeart, IconHeartFilled, IconEdit, IconTrash, IconEye } from '@/components/icons'; +import { CreateCampModal } from '@/components/coach/CreateCampModal'; +import { createClient } from '@/lib/supabase/client'; +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { registerForCamp, unregisterFromCamp, deleteCamp } from '@/app/baseball/actions/camps'; +import { activeCampCountsByCamp, formatCampDate } from '@/lib/baseball/camp-utils'; +import { cn } from '@/lib/utils'; +import { SectionMasthead, PaperCard, InkBadge, EditorsLetter, Reveal } from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +interface Camp { + id: string; + name: string; + description: string | null; + start_date: string; + end_date: string; + location: string | null; + capacity: number | null; + status: string | null; + price_cents: number | null; + is_free: boolean | null; + registration_deadline: string | null; + coach_id: string; + organization_id: string | null; + created_at: string | null; + updated_at: string | null; + organization: { + id: string; + name: string; + logo_url: string | null; + } | null; + registrations: { count: number }[]; + is_registered?: boolean; +} + +type CampSupabase = ReturnType; + +// Attach an ACTIVE (non-cancelled) registration count to each camp. Cancelled +// rows must not consume capacity, so we can't use the raw embedded count. (#443) +async function attachActiveCounts(supabase: CampSupabase, camps: Camp[]): Promise { + const ids = camps.map((c) => c.id); + if (ids.length === 0) return camps; + const { data } = await supabase + .from('baseball_camp_registrations') + .select('camp_id, status') + .in('camp_id', ids); + const counts = activeCampCountsByCamp((data ?? []) as { camp_id: string; status: string | null }[]); + return camps.map((c) => ({ ...c, registrations: [{ count: counts.get(c.id) ?? 0 }] })); +} + +// Single source of truth for loading camps + their active counts, shared by the +// initial load, the error-retry, and the post-create refresh. +async function loadCamps( + supabase: CampSupabase, + opts: { coachId: string } | { playerActive: true }, +): Promise { + const base = supabase + .from('baseball_camps') + .select('*, organization:organizations(id, name, logo_url)'); + const filtered = + 'coachId' in opts + ? base.eq('coach_id', opts.coachId) + : base.eq('status', 'published').gte('end_date', new Date().toISOString()); + const { data, error } = await filtered.order('start_date', { ascending: true }); + if (error) throw error; + return attachActiveCounts(supabase, (data as Camp[]) ?? []); +} + +function CampCard({ + camp, + ink, + isPlayer, + isCoach, + isRegistered, + onRegister, + onUnregister, + onEdit, + onDelete +}: { + camp: Camp; + ink: 'team' | 'pursuit'; + isPlayer: boolean; + isCoach: boolean; + isRegistered: boolean; + onRegister: (campId: string) => void; + onUnregister: (campId: string) => void; + onEdit: (camp: Camp) => void; + onDelete: (campId: string) => void; +}) { + const registrationCount = camp.registrations?.[0]?.count || 0; + const isFull = camp.capacity ? registrationCount >= camp.capacity : false; + const inkText = ink === 'team' ? 'text-grade-plus' : 'text-pursuit'; + + return ( + +
+
+

{camp.name}

+ {camp.organization && ( +

{camp.organization.name}

+ )} +
+
+ + + {formatCampDate(camp.start_date)} + {camp.end_date && ` - ${formatCampDate(camp.end_date)}`} + +
+ {camp.location && ( +
+ + {camp.location} +
+ )} +
+ + + {registrationCount}{camp.capacity ? ` / ${camp.capacity}` : ''} registered + +
+
+
+
+ + {camp.price_cents && !camp.is_free && ( +

+ ${(camp.price_cents / 100).toFixed(0)} +

+ )} + {camp.is_free && ( +

Free

+ )} +
+
+ + {camp.description && ( +

{camp.description}

+ )} + + {/* Player Actions */} + {isPlayer && ( +
+ {isRegistered ? ( + + ) : isFull ? ( + + ) : ( + + )} +
+ )} + + {/* Coach Actions */} + {isCoach && ( +
+ + + + + +
+ )} +
+ ); +} + +function CampsSkeleton({ ink }: { ink: 'team' | 'pursuit' }) { + return ( +
+ +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + + + + + + ))} +
+
+ ); +} + +export default function CampsClient() { + const { user, coach, player } = useAuth(); + const { showToast } = useToast(); + const [camps, setCamps] = useState([]); + const [registeredCamps, setRegisteredCamps] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingCamp, setEditingCamp] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deleting, setDeleting] = useState(false); + const supabaseRef = useRef(createClient()); + const supabase = supabaseRef.current; + + const isCoach = user?.role === 'coach'; + const isPlayer = user?.role === 'player'; + // Lane ink follows the viewer's role: the coach's own camp management reads + // clay (War Room recruiting-events), the player's registration view reads + // green (Passport) — same dual-ink pattern as CollegeInterestClient/AnalyticsClient. + const ink: 'team' | 'pursuit' = isCoach ? 'pursuit' : 'team'; + + useEffect(() => { + async function fetchCamps() { + setLoading(true); + setLoadError(null); + + try { + if (isCoach && coach) { + setCamps(await loadCamps(supabase, { coachId: coach.id })); + } else if (isPlayer && player) { + // Fetch player's active registrations (exclude cancelled) for the + // "Registered" badge, then load the active camps + their live counts. + const { data: playerRegs } = await supabase + .from('baseball_camp_registrations') + .select('camp_id') + .eq('player_id', player.id) + .neq('status', 'cancelled'); + + if (playerRegs) { + setRegisteredCamps(new Set(playerRegs.map(r => r.camp_id))); + } + + setCamps(await loadCamps(supabase, { playerActive: true })); + } + } catch { + setCamps([]); + setLoadError('Camps could not be loaded.'); + } + + setLoading(false); + } + + fetchCamps(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable across renders (useRef at line 203). Adding it would noise the dep array without changing behavior. + }, [coach, player, isCoach, isPlayer]); + + const handleRegister = async (campId: string) => { + if (!player) return; + + // Go through the server action so capacity is enforced atomically in the DB + // (a raw client insert could overbook a camp). Only update the UI on a + // confirmed registration; surface the reason (e.g. "This camp is full"). + const result = await registerForCamp(campId); + + if (result.success) { + setRegisteredCamps(prev => new Set(Array.from(prev).concat(campId))); + setCamps(prev => prev.map(c => + c.id === campId + ? { ...c, registrations: [{ count: (c.registrations?.[0]?.count || 0) + 1 }] } + : c + )); + } else { + showToast(result.error || 'Failed to register for camp', 'error'); + } + }; + + const handleUnregister = async (campId: string) => { + if (!player) return; + + // Go through the audited server-action layer instead of a raw + // client-side write. + const result = await unregisterFromCamp(campId); + + if (result.success) { + setRegisteredCamps(prev => { + const newSet = new Set(prev); + newSet.delete(campId); + return newSet; + }); + setCamps(prev => prev.map(c => + c.id === campId + ? { ...c, registrations: [{ count: Math.max(0, (c.registrations?.[0]?.count || 0) - 1) }] } + : c + )); + } else { + showToast(result.error || 'Failed to cancel camp registration', 'error'); + } + }; + + const handleEdit = (camp: Camp) => { + setEditingCamp(camp); + }; + + const handleDelete = async () => { + if (!deleteConfirm) return; + + setDeleting(true); + try { + // Go through the audited server-action layer (deletes registrations + + // the camp, with an ownership check) instead of raw client-side deletes. + const result = await deleteCamp(deleteConfirm); + + if (!result.success) { + showToast(result.error || 'Failed to delete camp', 'error'); + return; + } + + // Remove from local state + setCamps(prev => prev.filter(c => c.id !== deleteConfirm)); + showToast('Camp deleted successfully', 'success'); + } catch { + showToast('An error occurred while deleting', 'error'); + } finally { + setDeleting(false); + setDeleteConfirm(null); + } + }; + + if (loading) { + return ; + } + + if (loadError) { + return ( +
+ +
+ { + setLoading(true); + setLoadError(null); + void (async () => { + try { + if (isCoach && coach) { + setCamps(await loadCamps(supabase, { coachId: coach.id })); + } else if (isPlayer && player) { + setCamps(await loadCamps(supabase, { playerActive: true })); + } + } catch { + setLoadError('Camps could not be loaded.'); + } finally { + setLoading(false); + } + })(); + }} + /> +
+
+ ); + } + + return ( +
+ setShowCreateModal(true)}> + + Create Camp + + ) : undefined + } + > +

+ {isCoach ? `${camps.length} camps` : `${camps.length} available camps`} +

+
+ + {camps.length === 0 ? ( + setShowCreateModal(true)}> + + Create Camp + + ) : undefined + } + /> + ) : ( +
+ {camps.map((camp, i) => ( + + setDeleteConfirm(id)} + /> + + ))} +
+ )} + + {/* Create/Edit Camp Modal */} + { + setShowCreateModal(false); + setEditingCamp(null); + // Refresh camps list + if (isCoach && coach) { + void loadCamps(supabase, { coachId: coach.id }) + .then(setCamps) + .catch(() => setLoadError('Camps could not be loaded.')); + } + }} + camp={editingCamp} + /> + + {/* Delete Confirmation Dialog */} + setDeleteConfirm(null)} + /> +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx new file mode 100644 index 000000000..ba1dd40ec --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/CampDetailClient.tsx @@ -0,0 +1,484 @@ +'use client'; + +// ============================================================================= +// CampDetailClient — the coach's camp roster / check-in surface, migrated onto +// "The Living Annual" kit (Lane 2 · THE WAR ROOM, clay ink — a recruiting +// event's roster, sibling to Discover/Compare in this same wave). +// +// PRESENTATION ONLY. `fetchCampData`, `handleCheckIn`/`handleMarkNoShow`, and +// the ownership check are unchanged — only the render moved to the kit. The +// 13 off-palette blue/purple/amber/red hits (info-bar icon chips, quick-stat +// figures, and the status pill map) are replaced with lane-ink ``/ +// `` — status now reads by LABEL, not by a chrome color map. +// ============================================================================= + +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Avatar } from '@/components/ui/avatar'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + IconArrowLeft, + IconCalendar, + IconMapPin, + IconUsers, + IconCheck, + IconX, + IconClock, +} from '@/components/icons'; +import { createClient } from '@/lib/supabase/client'; +import { fromUntyped } from '@/lib/supabase/untyped'; +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { cn, getFullName, formatRelativeTime } from '@/lib/utils'; +import { formatCampDate } from '@/lib/baseball/camp-utils'; +import { + SectionMasthead, + PaperCard, + Eyebrow, + RuledStatLine, + InkBadge, + PositionChip, + EditorsLetter, + Reveal, +} from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +interface CampRegistration { + id: string; + camp_id: string; + player_id: string; + status: 'interested' | 'registered' | 'confirmed' | 'attended' | 'no_show' | 'cancelled'; + registered_at: string | null; + attended_at: string | null; + notes: string | null; + player: { + id: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + primary_position: string | null; + grad_year: number | null; + high_school_name: string | null; + city: string | null; + state: string | null; + } | null; +} + +interface Camp { + id: string; + name: string; + description: string | null; + start_date: string; + end_date: string; + location: string | null; + capacity: number | null; + status: string | null; + price_cents: number | null; + is_free: boolean | null; + coach_id: string; + organization: { + id: string; + name: string; + } | null; +} + +const CAMP_DETAIL_DATE_OPTIONS: Intl.DateTimeFormatOptions = { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}; + +// Status reads by LABEL + tone, never a bespoke color map — no blue/amber/red +// (spec §4.2 rule 2: clay only for stamps/seals/hot signals, never generic +// chrome). `attended` is the one genuinely positive outcome and gets the +// lane-ink solid treatment; everything else stays quiet neutral. +const STATUS_BADGE: Record = { + interested: { tone: 'neutral', variant: 'soft' }, + registered: { tone: 'neutral', variant: 'solid' }, + confirmed: { tone: 'pursuit', variant: 'soft' }, + attended: { tone: 'pursuit', variant: 'solid' }, + no_show: { tone: 'neutral', variant: 'soft' }, + cancelled: { tone: 'neutral', variant: 'soft' }, +}; + +const STATUS_LABEL: Record = { + interested: 'Interested', + registered: 'Registered', + confirmed: 'Confirmed', + attended: 'Checked In', + no_show: 'No Show', + cancelled: 'Cancelled', +}; + +const FILTERS = [ + { value: 'all', label: 'All' }, + { value: 'registered', label: 'Pending' }, + { value: 'attended', label: 'Checked In' }, + { value: 'no_show', label: 'No Show' }, +] as const; + +// Segmented filter control — the same clay-ink ` + ); + })} +
+ ); +} + +function CampDetailSkeleton() { + return ( +
+ + + + +
+ {[0, 1, 2, 3].map((i) => ( + + + + + ))} +
+
+ ); +} + +export default function CampDetailClient() { + const params = useParams(); + const router = useRouter(); + const { coach, user } = useAuth(); + const { showToast } = useToast(); + const supabase = createClient(); + + const campId = params.id as string; + + const [camp, setCamp] = useState(null); + const [registrations, setRegistrations] = useState([]); + const [loading, setLoading] = useState(true); + const [checkingIn, setCheckingIn] = useState(null); + const [filter, setFilter] = useState<'all' | 'registered' | 'attended' | 'no_show'>('all'); + + const isCoach = user?.role === 'coach'; + + const fetchCampData = useCallback(async () => { + setLoading(true); + + // Fetch camp details + const { data: campData, error: campError } = await supabase + .from('baseball_camps') + .select(` + *, + organization:organizations(id, name) + `) + .eq('id', campId) + .single(); + + if (campError || !campData) { + showToast('Camp not found', 'error'); + router.push('/baseball/dashboard/camps'); + return; + } + + // Verify coach owns this camp + if (isCoach && coach && campData.coach_id !== coach.id) { + showToast('You do not have access to this camp', 'error'); + router.push('/baseball/dashboard/camps'); + return; + } + + setCamp(campData as Camp); + + // Fetch registrations with player details + const { data: regsData } = await supabase + .from('baseball_camp_registrations') + .select(` + id, + camp_id, + player_id, + status, + registered_at, + attended_at, + notes, + player:baseball_players( + id, + first_name, + last_name, + avatar_url, + primary_position, + grad_year, + high_school_name, + city, + state + ) + `) + .eq('camp_id', campId) + .neq('status', 'cancelled') + .order('registered_at', { ascending: false }); + + setRegistrations((regsData as unknown as CampRegistration[]) || []); + setLoading(false); + }, [campId, coach, isCoach, router, showToast, supabase]); + + useEffect(() => { + fetchCampData(); + }, [fetchCampData]); + + const handleCheckIn = async (registrationId: string) => { + setCheckingIn(registrationId); + + const { error } = await fromUntyped(supabase, 'baseball_camp_registrations') + .update({ + status: 'attended', + attended_at: new Date().toISOString(), + }) + .eq('id', registrationId); + + if (error) { + showToast('Failed to check in player', 'error'); + } else { + setRegistrations(prev => + prev.map(r => + r.id === registrationId + ? { ...r, status: 'attended' as const, attended_at: new Date().toISOString() } + : r + ) + ); + showToast('Player checked in', 'success'); + } + + setCheckingIn(null); + }; + + const handleMarkNoShow = async (registrationId: string) => { + const { error } = await supabase + .from('baseball_camp_registrations') + .update({ status: 'no_show' }) + .eq('id', registrationId); + + if (error) { + showToast('Failed to update status', 'error'); + } else { + setRegistrations(prev => + prev.map(r => + r.id === registrationId ? { ...r, status: 'no_show' as const } : r + ) + ); + } + }; + + const filteredRegistrations = registrations.filter(r => { + if (filter === 'all') return true; + if (filter === 'registered') return r.status === 'registered' || r.status === 'confirmed'; + return r.status === filter; + }); + + const stats = { + total: registrations.length, + attended: registrations.filter(r => r.status === 'attended').length, + pending: registrations.filter(r => r.status === 'registered' || r.status === 'confirmed').length, + noShow: registrations.filter(r => r.status === 'no_show').length, + }; + + if (loading) { + return ; + } + + if (!camp) { + return ( +
+ +
+ + + + } + /> +
+
+ ); + } + + return ( +
+ + + + } + > + {camp.organization?.name && ( +

{camp.organization.name}

+ )} +
+ + {/* Camp Info */} + +
+
+ Date +

+ {formatCampDate(camp.start_date, CAMP_DETAIL_DATE_OPTIONS)} + {camp.end_date !== camp.start_date && ` - ${formatCampDate(camp.end_date, CAMP_DETAIL_DATE_OPTIONS)}`} +

+
+ + {camp.location && ( +
+ Location +

{camp.location}

+
+ )} + +
+ Capacity +

+ {stats.total}{camp.capacity ? ` / ${camp.capacity}` : ''} registered +

+
+
+
+ + {/* Quick Stats */} +
+ + 0} /> + + +
+ + {/* Roster */} + +
+ Roster ({filteredRegistrations.length}) + +
+ + {filteredRegistrations.length === 0 ? ( +
+ +
+ ) : ( +
+ {filteredRegistrations.map((reg, i) => { + const badge = STATUS_BADGE[reg.status] ?? { tone: 'neutral' as const, variant: 'soft' as const }; + return ( + +
+ + +
+
+

+ {getFullName(reg.player?.first_name, reg.player?.last_name)} +

+ {reg.player?.primary_position && ( + + )} +
+

+ {reg.player?.high_school_name && `${reg.player.high_school_name} • `} + {reg.player?.grad_year && `Class of ${reg.player.grad_year}`} + {reg.player?.city && reg.player?.state && ` • ${reg.player.city}, ${reg.player.state}`} +

+ {reg.attended_at && ( +

+ + Checked in {formatRelativeTime(reg.attended_at)} +

+ )} +
+ +
+ + + {isCoach && (reg.status === 'registered' || reg.status === 'confirmed') && ( +
+ + +
+ )} +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/camps/[id]/loading.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/loading.tsx index 408bb6be4..edc24ec6b 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/[id]/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/loading.tsx @@ -1,55 +1,53 @@ -import { Header } from '@/components/layout/header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +/** + * Route-level loading skeleton for Camp Details (Lane 4 · THE WAR ROOM, clay + * ink — coaches manage the camp roster from here). Mirrors CampDetailClient's + * own `CampDetailSkeleton` (masthead + info card + stat tiles + roster list). + */ export default function CampDetailLoading() { return ( - <> -
-
- {/* Camp Info Skeleton */} -
-
- {[1, 2, 3].map(i => ( -
-
-
-
-
-
-
- ))} -
-
+
+
+ + + +
- {/* Stats Skeleton */} -
- {[1, 2, 3, 4].map(i => ( -
-
-
-
- ))} -
+ + + - {/* Roster Skeleton */} -
-
-
-
-
-
- {[1, 2, 3, 4, 5].map(i => ( -
-
-
-
-
-
-
+
+ {[0, 1, 2, 3].map((i) => ( + + + + + ))} +
+ + +
+ + +
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ +
+ +
- ))} -
+ +
+ ))}
-
- + +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx index 11fe502a8..cd1b0818b 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/[id]/page.tsx @@ -1,442 +1,14 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Avatar } from '@/components/ui/avatar'; -import { PageLoading } from '@/components/ui/loading'; -import { EmptyState } from '@/components/ui/empty-state'; -import { ShineEffect } from '@/components/ui/shine-effect'; -import { - IconArrowLeft, - IconCalendar, - IconMapPin, - IconUsers, - IconCheck, - IconX, - IconClock, - IconAlertCircle, -} from '@/components/icons'; -import { createClient } from '@/lib/supabase/client'; -import { fromUntyped } from '@/lib/supabase/untyped'; -import { useAuth } from '@/hooks/use-auth'; -import { useToast } from '@/components/ui/sonner'; -import { cn, getFullName, formatRelativeTime } from '@/lib/utils'; -import { formatCampDate } from '@/lib/baseball/camp-utils'; - -interface CampRegistration { - id: string; - camp_id: string; - player_id: string; - status: 'interested' | 'registered' | 'confirmed' | 'attended' | 'no_show' | 'cancelled'; - registered_at: string | null; - attended_at: string | null; - notes: string | null; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - high_school_name: string | null; - city: string | null; - state: string | null; - } | null; -} - -interface Camp { - id: string; - name: string; - description: string | null; - start_date: string; - end_date: string; - location: string | null; - capacity: number | null; - status: string | null; - price_cents: number | null; - is_free: boolean | null; - coach_id: string; - organization: { - id: string; - name: string; - } | null; -} - -const CAMP_DETAIL_DATE_OPTIONS: Intl.DateTimeFormatOptions = { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', -}; - -const statusConfig: Record = { - interested: { label: 'Interested', color: 'text-warm-600', bg: 'bg-warm-100' }, - registered: { label: 'Registered', color: 'text-blue-600', bg: 'bg-blue-100' }, - confirmed: { label: 'Confirmed', color: 'text-primary-600', bg: 'bg-primary-100' }, - attended: { label: 'Checked In', color: 'text-primary-600', bg: 'bg-primary-100' }, - no_show: { label: 'No Show', color: 'text-amber-600', bg: 'bg-amber-100' }, - cancelled: { label: 'Cancelled', color: 'text-red-600', bg: 'bg-red-100' }, -}; - -export default function CampDetailPage() { - const params = useParams(); - const router = useRouter(); - const { coach, user } = useAuth(); - const { showToast } = useToast(); - const supabase = createClient(); - - const campId = params.id as string; - - const [camp, setCamp] = useState(null); - const [registrations, setRegistrations] = useState([]); - const [loading, setLoading] = useState(true); - const [checkingIn, setCheckingIn] = useState(null); - const [filter, setFilter] = useState<'all' | 'registered' | 'attended' | 'no_show'>('all'); - - const isCoach = user?.role === 'coach'; - - const fetchCampData = useCallback(async () => { - setLoading(true); - - // Fetch camp details - const { data: campData, error: campError } = await supabase - .from('baseball_camps') - .select(` - *, - organization:organizations(id, name) - `) - .eq('id', campId) - .single(); - - if (campError || !campData) { - showToast('Camp not found', 'error'); - router.push('/baseball/dashboard/camps'); - return; - } - - // Verify coach owns this camp - if (isCoach && coach && campData.coach_id !== coach.id) { - showToast('You do not have access to this camp', 'error'); - router.push('/baseball/dashboard/camps'); - return; - } - - setCamp(campData as Camp); - - // Fetch registrations with player details - const { data: regsData } = await supabase - .from('baseball_camp_registrations') - .select(` - id, - camp_id, - player_id, - status, - registered_at, - attended_at, - notes, - player:baseball_players( - id, - first_name, - last_name, - avatar_url, - primary_position, - grad_year, - high_school_name, - city, - state - ) - `) - .eq('camp_id', campId) - .neq('status', 'cancelled') - .order('registered_at', { ascending: false }); - - setRegistrations((regsData as unknown as CampRegistration[]) || []); - setLoading(false); - }, [campId, coach, isCoach, router, showToast, supabase]); - - useEffect(() => { - fetchCampData(); - }, [fetchCampData]); - - const handleCheckIn = async (registrationId: string) => { - setCheckingIn(registrationId); - - const { error } = await fromUntyped(supabase, 'baseball_camp_registrations') - .update({ - status: 'attended', - attended_at: new Date().toISOString(), - }) - .eq('id', registrationId); - - if (error) { - showToast('Failed to check in player', 'error'); - } else { - setRegistrations(prev => - prev.map(r => - r.id === registrationId - ? { ...r, status: 'attended' as const, attended_at: new Date().toISOString() } - : r - ) - ); - showToast('Player checked in', 'success'); - } - - setCheckingIn(null); - }; - - const handleMarkNoShow = async (registrationId: string) => { - const { error } = await supabase - .from('baseball_camp_registrations') - .update({ status: 'no_show' }) - .eq('id', registrationId); - - if (error) { - showToast('Failed to update status', 'error'); - } else { - setRegistrations(prev => - prev.map(r => - r.id === registrationId ? { ...r, status: 'no_show' as const } : r - ) - ); - } - }; - - const filteredRegistrations = registrations.filter(r => { - if (filter === 'all') return true; - if (filter === 'registered') return r.status === 'registered' || r.status === 'confirmed'; - return r.status === filter; - }); - - const stats = { - total: registrations.length, - attended: registrations.filter(r => r.status === 'attended').length, - pending: registrations.filter(r => r.status === 'registered' || r.status === 'confirmed').length, - noShow: registrations.filter(r => r.status === 'no_show').length, - }; - - if (loading) { - return ( - <> -
-

Camp Details

-
- - - ); - } - - if (!camp) { - return ( - <> -
-

Camp Not Found

-
-
- } - title="Camp not found" - description="This camp may have been deleted or you don't have access." - action={ - - - - } - /> -
- - ); - } - - return ( - <> -
-
-

{camp.name}

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

{camp.organization.name}

- )} -
- - - -
- -
- {/* Camp Info */} -
- -
-
-
- -
-
-

Date

-

- {formatCampDate(camp.start_date, CAMP_DETAIL_DATE_OPTIONS)} - {camp.end_date !== camp.start_date && ` - ${formatCampDate(camp.end_date, CAMP_DETAIL_DATE_OPTIONS)}`} -

-
-
- - {camp.location && ( -
-
- -
-
-

Location

-

{camp.location}

-
-
- )} - -
-
- -
-
-

Capacity

-

- {stats.total}{camp.capacity ? ` / ${camp.capacity}` : ''} registered -

-
-
-
-
- - {/* Quick Stats */} -
- -

Total

-

{stats.total}

-
- -

Checked In

-

{stats.attended}

-
- -

Pending

-

{stats.pending}

-
- -

No Show

-

{stats.noShow}

-
-
- - {/* Roster */} -
- -
-

Roster ({filteredRegistrations.length})

- - {/* Filter Tabs */} -
- {(['all', 'registered', 'attended', 'no_show'] as const).map(f => ( - - ))} -
-
- - {filteredRegistrations.length === 0 ? ( -
- -

No registrations yet

-
- ) : ( -
- {filteredRegistrations.map(reg => ( -
- - -
-
-

- {getFullName(reg.player?.first_name, reg.player?.last_name)} -

- {reg.player?.primary_position && ( - - {reg.player.primary_position} - - )} -
-

- {reg.player?.high_school_name && `${reg.player.high_school_name} • `} - {reg.player?.grad_year && `Class of ${reg.player.grad_year}`} - {reg.player?.city && reg.player?.state && ` • ${reg.player.city}, ${reg.player.state}`} -

- {reg.attended_at && ( -

- - Checked in {formatRelativeTime(reg.attended_at)} -

- )} -
- -
- - {statusConfig[reg.status]?.label ?? reg.status} - - - {isCoach && (reg.status === 'registered' || reg.status === 'confirmed') && ( -
- - -
- )} -
-
- ))} -
- )} -
-
- - ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (CampDetailClient reads +// role via useAuth internally) — thin server wrapper just requires a +// signed-in baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import CampDetailClient from './CampDetailClient'; + +export default async function CampDetailPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/loading.tsx b/src/app/baseball/(dashboard)/dashboard/camps/loading.tsx index 44ba05581..16adca343 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/loading.tsx @@ -1,60 +1,34 @@ -import { Header } from '@/components/layout/header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; -function CampCardSkeleton() { - return ( -
-
-
- {/* Title */} -
- {/* Organization */} -
- {/* Details */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Description */} -
-
-
-
- {/* Actions */} -
-
-
-
-
- ); -} +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; +/** + * Route-level loading skeleton for Camps & Showcases. Shared coach/player + * surface — CampsClient picks the lane ink (team/pursuit) from `useAuth()` + * client-side, which isn't resolved yet at this Suspense boundary, so this + * skeleton renders the neutral hairline rule rather than guessing an ink. + * Mirrors CampsClient's own `CampsSkeleton` card grid. + */ export default function CampsLoading() { return ( - <> -
-
-
- {[1, 2, 3, 4, 5, 6].map((i) => ( - - ))} -
+
+
+ + +
- + +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + + + + + + ))} +
+
); } diff --git a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx index 644f26823..a8c241e9c 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx @@ -1,501 +1,14 @@ -'use client'; - -import { useState, useEffect, useRef, type ReactNode } from 'react'; -import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { PageLoading } from '@/components/ui/loading'; -import { EmptyState } from '@/components/ui/empty-state'; -import { ReadModelStateNotice } from '@/components/baseball/ReadModelStateNotice'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconCalendar, IconMapPin, IconUsers, IconPlus, IconHeart, IconHeartFilled, IconEdit, IconTrash, IconEye } from '@/components/icons'; -import { CreateCampModal } from '@/components/coach/CreateCampModal'; -import { createClient } from '@/lib/supabase/client'; -import { useAuth } from '@/hooks/use-auth'; -import { useToast } from '@/components/ui/sonner'; -import { registerForCamp, unregisterFromCamp, deleteCamp } from '@/app/baseball/actions/camps'; -import { activeCampCountsByCamp, formatCampDate } from '@/lib/baseball/camp-utils'; - -interface Camp { - id: string; - name: string; - description: string | null; - start_date: string; - end_date: string; - location: string | null; - capacity: number | null; - status: string | null; - price_cents: number | null; - is_free: boolean | null; - registration_deadline: string | null; - coach_id: string; - organization_id: string | null; - created_at: string | null; - updated_at: string | null; - organization: { - id: string; - name: string; - logo_url: string | null; - } | null; - registrations: { count: number }[]; - is_registered?: boolean; -} - -type CampSupabase = ReturnType; - -// Attach an ACTIVE (non-cancelled) registration count to each camp. Cancelled -// rows must not consume capacity, so we can't use the raw embedded count. (#443) -async function attachActiveCounts(supabase: CampSupabase, camps: Camp[]): Promise { - const ids = camps.map((c) => c.id); - if (ids.length === 0) return camps; - const { data } = await supabase - .from('baseball_camp_registrations') - .select('camp_id, status') - .in('camp_id', ids); - const counts = activeCampCountsByCamp((data ?? []) as { camp_id: string; status: string | null }[]); - return camps.map((c) => ({ ...c, registrations: [{ count: counts.get(c.id) ?? 0 }] })); -} - -// Single source of truth for loading camps + their active counts, shared by the -// initial load, the error-retry, and the post-create refresh. -async function loadCamps( - supabase: CampSupabase, - opts: { coachId: string } | { playerActive: true }, -): Promise { - const base = supabase - .from('baseball_camps') - .select('*, organization:organizations(id, name, logo_url)'); - const filtered = - 'coachId' in opts - ? base.eq('coach_id', opts.coachId) - : base.eq('status', 'published').gte('end_date', new Date().toISOString()); - const { data, error } = await filtered.order('start_date', { ascending: true }); - if (error) throw error; - return attachActiveCounts(supabase, (data as Camp[]) ?? []); -} - -function CampCard({ - camp, - isPlayer, - isCoach, - isRegistered, - onRegister, - onUnregister, - onEdit, - onDelete -}: { - camp: Camp; - isPlayer: boolean; - isCoach: boolean; - isRegistered: boolean; - onRegister: (campId: string) => void; - onUnregister: (campId: string) => void; - onEdit: (camp: Camp) => void; - onDelete: (campId: string) => void; -}) { - const registrationCount = camp.registrations?.[0]?.count || 0; - const isFull = camp.capacity ? registrationCount >= camp.capacity : false; - - return ( - - -
-
-

{camp.name}

- {camp.organization && ( -

{camp.organization.name}

- )} -
-
- - - {formatCampDate(camp.start_date)} - {camp.end_date && ` - ${formatCampDate(camp.end_date)}`} - -
- {camp.location && ( -
- - {camp.location} -
- )} -
- - - {registrationCount}{camp.capacity ? ` / ${camp.capacity}` : ''} registered - -
-
-
-
- - {camp.status === 'published' ? 'Open' : camp.status || 'Pending'} - - {camp.price_cents && !camp.is_free && ( -

- ${(camp.price_cents / 100).toFixed(0)} -

- )} - {camp.is_free && ( -

- Free -

- )} -
-
- - {camp.description && ( -

{camp.description}

- )} - - {/* Player Actions */} - {isPlayer && ( -
- {isRegistered ? ( - - ) : isFull ? ( - - ) : ( - - )} -
- )} - - {/* Coach Actions */} - {isCoach && ( -
- - - - - -
- )} -
-
- ); -} - -// Lightweight editorial page header. The dashboard shell already renders the -// global top bar (notifications + command palette), so this page must NOT mount -// the legacy layout
— doing so stacked a second search box + avatar -// under the shell bar. This mirrors the premium Scout Packets header pattern: -// eyebrow → title → subtitle, with the primary action inline on the right. -function CampsPageHeader({ - isCoach, - subtitle, - action, -}: { - isCoach: boolean; - subtitle: string; - action?: ReactNode; -}) { - return ( -
-
-

- Recruiting -

-

- {isCoach ? 'My Camps' : 'Camps'} -

-

{subtitle}

-
- {action ?
{action}
: null} -
- ); -} - -export default function CampsPage() { - const { user, coach, player } = useAuth(); - const { showToast } = useToast(); - const [camps, setCamps] = useState([]); - const [registeredCamps, setRegisteredCamps] = useState>(new Set()); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [editingCamp, setEditingCamp] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(null); - const [deleting, setDeleting] = useState(false); - const supabaseRef = useRef(createClient()); - const supabase = supabaseRef.current; - - const isCoach = user?.role === 'coach'; - const isPlayer = user?.role === 'player'; - - useEffect(() => { - async function fetchCamps() { - setLoading(true); - setLoadError(null); - - try { - if (isCoach && coach) { - setCamps(await loadCamps(supabase, { coachId: coach.id })); - } else if (isPlayer && player) { - // Fetch player's active registrations (exclude cancelled) for the - // "Registered" badge, then load the active camps + their live counts. - const { data: playerRegs } = await supabase - .from('baseball_camp_registrations') - .select('camp_id') - .eq('player_id', player.id) - .neq('status', 'cancelled'); - - if (playerRegs) { - setRegisteredCamps(new Set(playerRegs.map(r => r.camp_id))); - } - - setCamps(await loadCamps(supabase, { playerActive: true })); - } - } catch { - setCamps([]); - setLoadError('Camps could not be loaded.'); - } - - setLoading(false); - } - - fetchCamps(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `supabase` is stable across renders (useRef at line 203). Adding it would noise the dep array without changing behavior. - }, [coach, player, isCoach, isPlayer]); - - const handleRegister = async (campId: string) => { - if (!player) return; - - // Go through the server action so capacity is enforced atomically in the DB - // (a raw client insert could overbook a camp). Only update the UI on a - // confirmed registration; surface the reason (e.g. "This camp is full"). - const result = await registerForCamp(campId); - - if (result.success) { - setRegisteredCamps(prev => new Set(Array.from(prev).concat(campId))); - setCamps(prev => prev.map(c => - c.id === campId - ? { ...c, registrations: [{ count: (c.registrations?.[0]?.count || 0) + 1 }] } - : c - )); - } else { - showToast(result.error || 'Failed to register for camp', 'error'); - } - }; - - const handleUnregister = async (campId: string) => { - if (!player) return; - - // Go through the audited server-action layer instead of a raw - // client-side write. - const result = await unregisterFromCamp(campId); - - if (result.success) { - setRegisteredCamps(prev => { - const newSet = new Set(prev); - newSet.delete(campId); - return newSet; - }); - setCamps(prev => prev.map(c => - c.id === campId - ? { ...c, registrations: [{ count: Math.max(0, (c.registrations?.[0]?.count || 0) - 1) }] } - : c - )); - } else { - showToast(result.error || 'Failed to cancel camp registration', 'error'); - } - }; - - const handleEdit = (camp: Camp) => { - setEditingCamp(camp); - }; - - const handleDelete = async () => { - if (!deleteConfirm) return; - - setDeleting(true); - try { - // Go through the audited server-action layer (deletes registrations + - // the camp, with an ownership check) instead of raw client-side deletes. - const result = await deleteCamp(deleteConfirm); - - if (!result.success) { - showToast(result.error || 'Failed to delete camp', 'error'); - return; - } - - // Remove from local state - setCamps(prev => prev.filter(c => c.id !== deleteConfirm)); - showToast('Camp deleted successfully', 'success'); - } catch { - showToast('An error occurred while deleting', 'error'); - } finally { - setDeleting(false); - setDeleteConfirm(null); - } - }; - - if (loading) { - return ( -
- - -
- ); - } - - if (loadError) { - return ( -
- -
- { - setLoading(true); - setLoadError(null); - void (async () => { - try { - if (isCoach && coach) { - setCamps(await loadCamps(supabase, { coachId: coach.id })); - } else if (isPlayer && player) { - setCamps(await loadCamps(supabase, { playerActive: true })); - } - } catch { - setLoadError('Camps could not be loaded.'); - } finally { - setLoading(false); - } - })(); - }} - /> -
-
- ); - } - - return ( - <> -
- setShowCreateModal(true)}> - - Create Camp - - ) : undefined - } - /> - {camps.length === 0 ? ( - } - title={isCoach ? 'No camps yet' : 'No camps available'} - description={ - isCoach - ? 'Create your first camp to start recruiting players.' - : 'Check back later for upcoming camps and events.' - } - action={ - isCoach ? ( - - ) : undefined - } - /> - ) : ( -
- {camps.map(camp => ( - setDeleteConfirm(id)} - /> - ))} -
- )} -
- - {/* Create/Edit Camp Modal */} - { - setShowCreateModal(false); - setEditingCamp(null); - // Refresh camps list - if (isCoach && coach) { - void loadCamps(supabase, { coachId: coach.id }) - .then(setCamps) - .catch(() => setLoadError('Camps could not be loaded.')); - } - }} - camp={editingCamp} - /> - - {/* Delete Confirmation Dialog */} - setDeleteConfirm(null)} - /> - - ); +// SECURITY: this route was a whole-file 'use client' page with NO server-side +// auth check at all. Shared coach + player surface (CampsClient branches +// internally on role) — thin server wrapper just requires a signed-in +// baseball user, matching the repo's standard shape. +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; +import CampsClient from './CampsClient'; + +export default async function CampsPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + + return ; } diff --git a/src/app/baseball/(dashboard)/dashboard/college-interest/loading.tsx b/src/app/baseball/(dashboard)/dashboard/college-interest/loading.tsx index b6d4c494b..1fd768a23 100644 --- a/src/app/baseball/(dashboard)/dashboard/college-interest/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/college-interest/loading.tsx @@ -1,4 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; export default function CollegeInterestLoading() { return ( @@ -6,9 +7,10 @@ export default function CollegeInterestLoading() { {/* Stats row */}
{Array.from({ length: 4 }).map((_, i) => ( -
@@ -17,12 +19,12 @@ export default function CollegeInterestLoading() {
-
+ ))}
{/* Interest list card */} -
+
@@ -54,7 +56,7 @@ export default function CollegeInterestLoading() {
))}
-
+
); } diff --git a/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx b/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx index 5062345d1..018477af0 100644 --- a/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/college-interest/page.tsx @@ -1,10 +1,21 @@ +import { redirect } from 'next/navigation'; +import { getSessionProfile } from '@/lib/auth/session'; import CollegeInterestClient from './CollegeInterestClient'; import { fairwayScope } from '@/lib/redesign/flag'; // Force dynamic rendering - requires Supabase auth at runtime export const dynamic = 'force-dynamic'; -export default function CollegeInterestPage() { +// SECURITY: this page had NO server-side auth check at all — an +// unauthenticated request rendered the full client shell (which then made its +// own Supabase calls). Role-specific messaging (college-player lock / +// not-yet-activated lock / "Coaches only") is intentionally left to +// CollegeInterestClient (via useAuth + usePlayerRecruitingGate) — this only +// closes the "must be signed in" gap. +export default async function CollegeInterestPage() { + const session = await getSessionProfile(); + if (!session) redirect('/baseball/login'); + return (
diff --git a/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx b/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx new file mode 100644 index 000000000..e334664a3 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/colleges/CollegesClient.tsx @@ -0,0 +1,147 @@ +'use client'; + +// ============================================================================= +// CollegesClient — the player's "discover colleges" search surface, migrated +// onto "The Living Annual" kit (Lane 3 · THE PASSPORT, green ink — +// ui-migration-map.md `college-interest`/`profile` row: SectionMasthead + +// PaperCard + editorial). +// +// PRESENTATION ONLY. `useColleges`/`useStates`/`useConferences` (the org +// query + interest-toggle state) are unchanged; only the render moved to the +// kit — a skeleton grid replaces the spinner, and the error/empty states +// compose `` instead of a bespoke ``. +// ============================================================================= + +import { useState, useMemo } from 'react'; +import { CollegeCard } from '@/components/features/college-card'; +import { Select } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { IconSearch } from '@/components/icons'; +import { useColleges, useStates, useConferences } from '@/hooks/use-colleges'; +import { cn } from '@/lib/utils'; +import { SectionMasthead, PaperCard, EditorsLetter, Reveal } from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +const divisions = [ + { value: '', label: 'All Divisions' }, + { value: 'D1', label: 'Division I' }, + { value: 'D2', label: 'Division II' }, + { value: 'D3', label: 'Division III' }, + { value: 'NAIA', label: 'NAIA' }, + { value: 'JUCO', label: 'Junior College' }, +]; + +function CollegesGridSkeleton() { + return ( +
+ {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ( + +
+ +
+ + + +
+
+
+ ))} +
+ ); +} + +export default function CollegesClient() { + const [division, setDivision] = useState(''); + const [stateFilter, setStateFilter] = useState(''); + const [conferenceFilter, setConferenceFilter] = useState(''); + const [search, setSearch] = useState(''); + + const { colleges, interests, loading, error, refetch, toggleInterest } = useColleges({ + division: division || undefined, + state: stateFilter || undefined, + conference: conferenceFilter || undefined, + search: search || undefined + }); + + const { states } = useStates(); + const { conferences } = useConferences(); + + const stateOptions = useMemo(() => [ + { value: '', label: 'All States' }, + ...states.map(s => ({ value: s, label: s })) + ], [states]); + + const conferenceOptions = useMemo(() => [ + { value: '', label: 'All Conferences' }, + ...conferences.map(c => ({ value: c, label: c })) + ], [conferences]); + + const interestedCount = interests.size; + + return ( +
+ +

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

+
+ + {/* Filters */} + +
+
+ + setSearch(e.target.value)} + placeholder="Search by name, city, or state..." + className="pl-9" + /> +
+
+ + setSearch(e.target.value)} - placeholder="Search by name, city, or state..." - className="pl-9" - /> -
-
- setStateFilter(value)} - className="sm:w-36" - /> - handleSearch(e.target.value)} - placeholder={canAddMore ? "Search players by name or school..." : "Maximum 4 players reached"} - className="pl-9" - disabled={!canAddMore} - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - /> -
- {!canAddMore && ( -

- Remove a player to add another. Maximum of 4 players can be compared at once. -

- )} - - {/* Search Results */} - {searchResults.length > 0 && ( -
- {searchResults.map((player) => { - const name = getFullName(player.first_name, player.last_name); - return ( - - ); - })} -
- )} - - {searching && ( -

Searching...

- )} -
+
+ +

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

+
- {/* Selected Players */} -
- {players.map((player) => { + {/* Add Players Section */} + +
+
+
+ Add Players to Compare + = MAX_PLAYERS ? 'pursuit' : 'neutral'} + variant={playerIds.length >= MAX_PLAYERS ? 'solid' : 'soft'} + /> +
+
+ + handleSearch(e.target.value)} + placeholder={canAddMore ? "Search players by name or school..." : "Maximum 4 players reached"} + className="pl-9" + disabled={!canAddMore} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> +
+ {!canAddMore && ( +

+ Remove a player to add another. Maximum of 4 players can be compared at once. +

+ )} + + {/* Search Results */} + {searchResults.length > 0 && ( +
+ {searchResults.map((player) => { const name = getFullName(player.first_name, player.last_name); return ( -
addPlayer(player)} + className="flex w-full items-center gap-3 rounded-none p-3 text-left hover:bg-[color:var(--paper-canvas)]" > - - {name} - removePlayer(player.id)} - className="min-w-[44px] min-h-[44px] p-2 flex items-center justify-center rounded-full hover:bg-primary-200 transition-colors" - aria-label={`Remove ${name} from comparison`} - > - - -
+ +
+

{name}

+

+ {player.primary_position} • {player.grad_year} • {player.high_school_name} +

+
+ + ); })}
-
- - - - {/* Comparison Table */} - {players.length < 2 ? ( - - -
- -

- {players.length === 0 ? 'No players selected' : 'Add one more player'} -

-

- {players.length === 0 - ? 'Search and add at least 2 players above to start comparing them side by side.' - : 'Add at least one more player to start comparing.'} -

- {players.length === 0 && ( - - )} - - {/* Visual Preview - Empty Comparison Slots */} -
- {[1, 2, 3, 4].map((slot) => ( -
-
-
-
-
- ))} + )} + + {searching && ( +

Searching...

+ )} +
+ + {/* Selected Players */} +
+ {players.map((player) => { + const name = getFullName(player.first_name, player.last_name); + return ( +
+ + {name} + removePlayer(player.id)} + className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full hover:bg-[color:var(--paper-canvas)]" + aria-label={`Remove ${name} from comparison`} + > + +
-
- - - ) : ( - - )} -
- + ); + })} +
+
+ + + {/* Comparison Table */} + {players.length < 2 ? ( + router.push('/baseball/dashboard/discover')}> + + Browse Players + + ) : undefined + } + /> + ) : ( + + )} +
); } export default function ComparePage() { return ( - -
-
-

Compare Players

-

Side-by-side player comparison

+ + +
+
- - }> + }> ); diff --git a/src/app/baseball/(dashboard)/dashboard/compare/loading.tsx b/src/app/baseball/(dashboard)/dashboard/compare/loading.tsx index d0031e15e..422daaa19 100644 --- a/src/app/baseball/(dashboard)/dashboard/compare/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/compare/loading.tsx @@ -1,13 +1,41 @@ -import { Header } from '@/components/layout/header'; -import { SkeletonCompare } from '@/components/ui/skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; -export default function CompareLoading() { +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +/** + * Route-level loading skeleton for Compare Players (Lane 4 · THE WAR ROOM, + * clay ink). Mirrors CompareClient's own `loading` skeleton branch (search + * bar + player column cards) so there is no legacy chrome flash on navigation. + */ +export default function Loading() { return ( - <> -
-
- +
+
+ + + +
+ + + + + + +
+ {[0, 1, 2, 3].map((i) => ( + + + + +
+ {[0, 1, 2, 3].map((j) => ( + + ))} +
+
+ ))}
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/comparisons/loading.tsx b/src/app/baseball/(dashboard)/dashboard/comparisons/loading.tsx index 1cfa39b75..bcf82d0fe 100644 --- a/src/app/baseball/(dashboard)/dashboard/comparisons/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/comparisons/loading.tsx @@ -1,33 +1,35 @@ import { Skeleton } from '@/components/ui/skeleton'; -import { Header } from '@/components/layout/header'; +import { PaperCard } from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; /** - * Route-level loading skeleton for the Saved Comparisons surface. - * Mirrors the real page layout (header + grid of comparison cards) so there is - * no layout shift when data lands. The Suspense fallback inside the page covers - * list-level streaming; this skeleton covers the entire page shell. + * Route-level loading skeleton for Saved Comparisons (Lane 4 · THE WAR ROOM, + * clay ink). Mirrors the page's Living Annual masthead + PaperCard grid + * (spec: docs/baseball/design-system-living-annual.md §7) so there is no + * legacy chrome flash on navigation. */ export default function ComparisonsLoading() { return ( - <> -
-
-
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
- - -
- - -
+
+
+ + + +
+ +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + + +
+ +
- ))} -
+ + ))}
- +
); } diff --git a/src/app/baseball/(dashboard)/dashboard/comparisons/page.tsx b/src/app/baseball/(dashboard)/dashboard/comparisons/page.tsx index 3893cd565..74a65ef29 100644 --- a/src/app/baseball/(dashboard)/dashboard/comparisons/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/comparisons/page.tsx @@ -1,37 +1,46 @@ +// ============================================================================= +// SavedComparisonsPage — the coach's saved-comparisons list, migrated onto +// "The Living Annual" kit (Lane 2 · THE WAR ROOM, clay ink — a sibling of +// /compare, spec §6 P3 #10). PRESENTATION ONLY: `requireRecruitingCoachRoute` +// (W0 server guard) and `getSavedComparisons` are unchanged; only the page +// chrome (masthead, error state, skeleton) moved to the kit — matching +// /compare's sibling behavior instead of a raw red error string. +// ============================================================================= import { Suspense } from 'react'; +import { requireRecruitingCoachRoute } from '@/lib/baseball/server-route-guards'; import { getSavedComparisons } from '../compare/actions'; import { SavedComparisonsList } from '@/components/features/saved-comparisons-list'; -import { IconBookmark } from '@/components/icons'; +import { SectionMasthead, PaperCard, EditorsLetter } from '@/components/baseball/living-annual'; +import { Skeleton } from '@/components/ui/skeleton'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; export default async function SavedComparisonsPage() { + // SECURITY: previously relied on getSavedComparisons()'s internal + // { error: 'Unauthorized' } string, which just rendered as an inline error + // banner (no redirect) — the page shell still rendered for an unauthorized + // caller. Match the sibling /compare route's guard instead. + await requireRecruitingCoachRoute(); + const { comparisons, error } = await getSavedComparisons(); return ( -
-
- {/* Page Header */} -
-
-
-
- -
-

Saved Comparisons

-
-

- View and manage your saved player comparisons -

-
-
+
+ +

+ View and manage your saved player comparisons. +

+
- {/* Error State */} - {error && ( -
-

{error}

-
- )} + {/* Error State */} + {error && ( +
+ +
+ )} - {/* Comparisons List */} + {/* Comparisons List */} +
}> @@ -42,19 +51,16 @@ export default async function SavedComparisonsPage() { function ComparisonsLoadingSkeleton() { return ( -
+
{[1, 2, 3, 4, 5, 6].map((i) => ( -
-
-
+ + +
-
-
+ +
-
+ ))}
); diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plans/DevPlansClient.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plans/DevPlansClient.tsx new file mode 100644 index 000000000..86cf1160b --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/DevPlansClient.tsx @@ -0,0 +1,406 @@ +'use client'; + +// ============================================================================= +// DevPlansClient — the coach's player-development plans list, migrated onto +// "The Living Annual" kit (Lane 1 · THE PRESSBOX, green ink — +// ui-migration-map.md `dev-plan` row). PRESENTATION ONLY: `fetchPlans` (the +// filtered query) and the create-modal wiring are unchanged — only the render +// moved to the kit (skeleton instead of shimmer divs, `` +// instead of the four stat cards, `` instead of the status Badge map, +// `` instead of the bespoke "No development +// plans yet" empty state). +// ============================================================================= + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Avatar } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { IconPlus, IconTarget, IconCheck, IconClock } from '@/components/icons'; +import { CreateDevPlanModal } from '@/components/coach/CreateDevPlanModal'; +import { useAuth } from '@/hooks/use-auth'; +import { useTeamStore } from '@/stores/team-store'; +import { createClient } from '@/lib/supabase/client'; +import { cn, getFullName } from '@/lib/utils'; +import { + SectionMasthead, + PaperCard, + Eyebrow, + KPIContentsStrip, + InkBadge, + EmptyIssue, + EditorsLetter, + Reveal, +} from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +interface DevPlan { + id: string; + title: string; + description: string | null; + status: string | null; + start_date: string | null; + end_date: string | null; + goals: Record | null; + created_at: string | null; + player: { + id: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + primary_position: string | null; + grad_year: number | null; + } | null; +} + +const STATUS_BADGE: Record = { + draft: { tone: 'neutral', variant: 'soft' }, + sent: { tone: 'team', variant: 'soft' }, + in_progress: { tone: 'team', variant: 'soft' }, + completed: { tone: 'team', variant: 'solid' }, + archived: { tone: 'neutral', variant: 'soft' }, +}; + +const STATUS_LABEL: Record = { + draft: 'Draft', + sent: 'Sent', + in_progress: 'In Progress', + completed: 'Completed', + archived: 'Archived', +}; + +type FilterValue = 'all' | 'active' | 'completed'; + +const FILTERS: Array<{ value: FilterValue; label: string }> = [ + { value: 'all', label: 'All Plans' }, + { value: 'active', label: 'Active' }, + { value: 'completed', label: 'Completed' }, +]; + +// Segmented filter control — the same clay/green ` + ); + })} +
+ ); +} + +function PlansListSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+ ); +} + +export default function DevPlansClient() { + const router = useRouter(); + const { user, coach, loading: authLoading } = useAuth(); + const { selectedTeamId } = useTeamStore(); + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + const [showCreateModal, setShowCreateModal] = useState(false); + + useEffect(() => { + if (selectedTeamId) { + fetchPlans(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchPlans depends on coach.id and filter; selectedTeamId and filter are already in deps + }, [selectedTeamId, filter]); + + async function fetchPlans() { + if (!coach?.id) return; + + setLoading(true); + const supabase = createClient(); + + let query = supabase + .from('baseball_developmental_plans') + .select(` + id, + title, + description, + status, + start_date, + end_date, + goals, + created_at, + player:baseball_players ( + id, + first_name, + last_name, + avatar_url, + primary_position, + grad_year + ) + `) + .eq('coach_id', coach.id) + .order('created_at', { ascending: false }); + + // Apply filter + if (filter === 'active') { + query = query.in('status', ['sent', 'in_progress']); + } else if (filter === 'completed') { + query = query.eq('status', 'completed'); + } + + const { data, error } = await query; + + if (!error && data) { + // Transform data to match DevPlan interface + const transformedPlans: DevPlan[] = data.map(plan => ({ + id: plan.id, + title: plan.title, + description: plan.description, + status: plan.status, + start_date: plan.start_date, + end_date: plan.end_date, + goals: plan.goals as Record | null, + created_at: plan.created_at, + player: plan.player as DevPlan['player'], + })); + setPlans(transformedPlans); + } + + setLoading(false); + } + + if (authLoading) return ; + + if (user?.role !== 'coach') { + return ( +
+ +
+ +
+
+ ); + } + + const stats = { + activePlans: plans.filter(p => p.status && ['sent', 'in_progress'].includes(p.status)).length, + playersEnrolled: new Set(plans.map(p => p.player?.id).filter(Boolean)).size, + totalGoals: plans.reduce((sum, p) => sum + (Array.isArray(p.goals) ? p.goals.length : 0), 0), + completed: plans.filter(p => p.status === 'completed').length, + }; + + return ( +
+ setShowCreateModal(true)}> + + Create Plan + + } + > +

+ Create and track player development. +

+
+ + 0 }, + ]} + /> + + {/* Plans List */} +
+
+
+ Development Plans +

Create personalized plans for your players.

+
+ +
+ + {loading ? ( + + ) : plans.length === 0 ? ( + setShowCreateModal(true)}> + + Create Your First Plan + + } + /> + ) : ( +
+ {plans.map((plan, i) => { + const badge = STATUS_BADGE[plan.status ?? 'draft'] ?? { tone: 'neutral' as const, variant: 'soft' as const }; + return ( + + +
+
+ +
+
+

{plan.title}

+ +
+

+ {getFullName(plan.player?.first_name, plan.player?.last_name)} •{' '} + {plan.player?.primary_position} • {plan.player?.grad_year} +

+ {plan.description && ( +

{plan.description}

+ )} +
+ {Array.isArray(plan.goals) && plan.goals.length > 0 && ( + + + {plan.goals.length} {plan.goals.length === 1 ? 'goal' : 'goals'} + + )} + {plan.start_date && plan.end_date && ( + + + {new Date(plan.start_date).toLocaleDateString()} - {new Date(plan.end_date).toLocaleDateString()} + + )} +
+
+
+ + + +
+
+
+ ); + })} +
+ )} +
+ + {/* Info Cards */} +
+ + What are Development Plans? +

+ Development plans help you create structured improvement programs for your players. Each plan includes: +

+
    +
  • + + Specific, measurable goals with target dates +
  • +
  • + + Customized drills and practice routines +
  • +
  • + + Progress tracking and completion status +
  • +
  • + + Video references and instructional content +
  • +
+
+ + + Best Practices +
    +
  • + + 1 + +
    + Set realistic timelines +

    Give players adequate time to achieve each goal

    +
    +
  • +
  • + + 2 + +
    + Focus on fundamentals +

    Build strong foundations before advanced skills

    +
    +
  • +
  • + + 3 + +
    + Review progress regularly +

    Check in with players and adjust plans as needed

    +
    +
  • +
+
+
+ + { + setShowCreateModal(false); + router.refresh(); + }} + teamId={selectedTeamId} + /> +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/loading.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/loading.tsx index ec438928b..b0b0f7dc0 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/loading.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/loading.tsx @@ -1,66 +1,46 @@ -import { Header } from '@/components/layout/header'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaperCard } from '@/components/baseball/living-annual'; +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +/** + * Route-level loading skeleton for a single Development Plan (Lane 3 · THE + * PRESSBOX, team ink). Mirrors the page's own loading branch (masthead + plan + * header card + goals grid). + */ export default function Loading() { return ( - <> -
-
- {/* Plan header skeleton */} - - -
- -
- - - -
-
- -
- -
-
- - -
- - -
-
- - -
-
-
+
+
+ + + +
+ + + + + - {/* Goals skeleton */} - - - - - - -
- {[1, 2, 3, 4].map((i) => ( -
-
- -
- - -
-
- - + + + +
+ {[0, 1, 2, 3].map((i) => ( +
+
+ +
+ +
- ))} +
+ +
- - -
- + ))} +
+ +
); } 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 3ee45e22a..dd1d0b039 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx @@ -1,11 +1,20 @@ 'use client'; +// ============================================================================= +// DevPlanDetailPage — the coach's single development-plan detail view, +// migrated onto "The Living Annual" kit (Lane 1 · THE PRESSBOX, green ink, +// sibling to DevPlansClient). PRESENTATION ONLY: `fetchPlan`/`completeGoal`/ +// `uncompleteGoal`, the not-found vs. genuine-error disambiguation, and the +// breadcrumb wiring are unchanged — only the render moved to the kit (a +// shared `` instead of the 5 copy-pasted header blocks, and +// `` instead of the bespoke destructive/warm boxes). +// ============================================================================= + import { useCallback, useEffect, useState, useTransition } from 'react'; import { useParams } from 'next/navigation'; 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 { Skeleton } from '@/components/ui/skeleton'; import { PlanDetail } from '@/components/baseball/dev-plans/PlanDetail'; import { BreadcrumbLabel } from '@/app/baseball/(dashboard)/_components/breadcrumb-label'; import { IconChevronLeft } from '@/components/icons'; @@ -17,6 +26,19 @@ import { uncompleteGoal, } from '@/app/baseball/actions/dev-plans'; import type { DevPlanWithPlayer } from '@/lib/baseball/dev-plan-types'; +import { cn } from '@/lib/utils'; +import { SectionMasthead, PaperCard, EditorsLetter } from '@/components/baseball/living-annual'; + +const PAGE_SHELL = 'mx-auto w-full max-w-[1536px] px-4 py-8 sm:px-6'; + +const BACK_ACTION = ( + + + +); export default function DevPlanDetailPage() { const params = useParams<{ id: string }>(); @@ -106,140 +128,71 @@ export default function DevPlanDetailPage() { if (authLoading || loading) { return ( - <> -
- -
-
- -
- +
+ + + + + +
); } if (user?.role !== 'coach') { return ( - <> -
- -