From 211fee29441bbb4d48230df217374a26d99f74ef Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 01:06:03 -0400 Subject: [PATCH 01/19] =?UTF-8?q?docs(baseball):=20coherence=20ruling=20fo?= =?UTF-8?q?r=20overnight=20consolidation=20=E2=80=94=20one=20shell,=20capp?= =?UTF-8?q?ed=20nav=20IA,=20one=20lift=20lab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGqvhig8njW38q9PAwDXgu --- docs/baseball/COHERENCE_RULING_2026-07-08.md | 88 ++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/baseball/COHERENCE_RULING_2026-07-08.md diff --git a/docs/baseball/COHERENCE_RULING_2026-07-08.md b/docs/baseball/COHERENCE_RULING_2026-07-08.md new file mode 100644 index 000000000..2aa0828bb --- /dev/null +++ b/docs/baseball/COHERENCE_RULING_2026-07-08.md @@ -0,0 +1,88 @@ +# BaseballHelm Coherence Ruling — 2026-07-08 (overnight consolidation) + +**Authority:** Commander decision doc for the one-night coherence mission. Supersedes conflicting +guidance in CANONICAL_SPEC §2.1 (stale 10-item nav). Builds on COACH_NAV_8TAB_PROPOSAL.md +(owner-approved 2026-07-01) and the 2026-06-30 shell postmortem's unexecuted recommendations. +Baseline: `origin/main` e63de6044 — tsc/lint/ratchet/unit all green. + +## Ruling 1 — ONE shell: BaseballFairwayShell, unconditional + +`NEXT_PUBLIC_REDESIGN=true` is what prod serves. The legacy fork is pure regression risk +(`.env.example` defaults it off). Therefore: + +- `(dashboard)/layout.tsx` and `(player-dashboard)/player/layout.tsx` render + `BaseballFairwayShell` **unconditionally** — delete the `isRedesignEnabled()` forks + (baseball layouts only; golf untouched). +- Delete `src/app/baseball/(coach-dashboard)/` entirely (zero page.tsx, confirmed dead). +- Remove the **baseball** nav paths from `src/components/layout/sidebar.tsx` + (5 legacy arrays: collegeTeamNav/hsCoachTeamNav/jucoTeamNav/showcaseOrgNav/playerTeamNav + + `buildCondensedBaseballNavigation`); the golf branch stays byte-identical. +- Delete `BaseballShellLayout`/`BaseballDashboardShell` baseball render path once unreferenced. +- Route groups `(dashboard)` + `(player-dashboard)` both stay (URLs are load-bearing: + PWA start_url, bookmarks). Folding them is deferred — not tonight's risk. + +## Ruling 2 — Navigation IA: ≤8 primary, ≤3 subtabs per primary (hard caps) + +Top level keeps the owner-approved hub set (College sees 7, HS 6, JUCO 8 — all ≤8): +**Dashboard · Team · Messages · Stats & Performance · Development · Recruiting · +Academics (JUCO) · Management**. The fix is inside the hubs — every destination stays +reachable ≤2 clicks (subtab landing pages surface deeper routes as cards/CTAs; command +palette stays flat with everything). Deep routes keep their URLs; `resolve-active-hub` +maps them to the owning subtab for highlight + breadcrumbs. + +| Hub | Subtabs (≤3) | Folded in (reachable from landing) | +|---|---|---| +| Dashboard | Overview · Signals | — | +| Team | Roster · Calendar · Operations | Operations = new landing: Documents, Travel, Practice Planner, Practice Effectiveness | +| Messages | Messages · Announcements | announcements moves here from Team (it's comms) | +| Stats & Performance | Stats Center · Games · Postgame | Season = view inside Stats Center; Upload + Import Center = CTAs inside Stats Center | +| Development | Dev Plans · Training · Videos | Training = existing /dashboard/performance landing → Programs, Live Weight Room, Builder, Groups | +| Recruiting | Pipeline · Discover · Scouting | Scouting = new landing: Watchlist, Compare, Saved Comparisons, Scout Packets, Camps | +| Academics | (single page) | JUCO only | +| Management | Decision Room · Settings · Organization | Settings = existing card-grid landing (KEEP grid, DELETE the 9-tab splice from COACH_MANAGEMENT_TABS); Organization = org/teams/events (Showcase types) | + +Player nav (Fairway): Today · Schedule · My Stats · Development · Team · Messages · +My Profile (+ Recruiting when activated) — already ≤8; enforce ≤3 subtabs per hub the same way. +Players hitting `/baseball/dashboard/practice` redirect to `/baseball/player/practice` (canonical +player practice surface). + +Nav-manifest test extended: every coach/both registry entry maps to exactly one hub, and no +hub resolves >3 subtabs for any coach type. That test is the anti-regression lock. + +## Ruling 3 — ONE Lift Lab + +Canonical = `src/components/lifting/*` + `helm_lifting_*`. Repoint the 6 baseball +performance routes at the canonical components; delete `src/components/baseball/performance/*` +(23-file legacy tree, GolfHelm-palette, writes legacy `baseball_lift_*`). Completes the +in-flight unification train. + +## Ruling 4 — Data honesty & correctness cluster + +- "Today" is **team-local** everywhere (`resolveTeamTimezone` + `todayIsoInTz`), never server-UTC: + readiness page, command-center read-model, player-today read-model. +- Calendar: null `end_time` renders start + 1h default (never zero-duration); events query gets + lookback bound + limit; badge labels pluralize. +- Academics eligibility is tri-state: `null` = gray "Not on file"; red "Ineligible" only for real `false`. +- Roster: drop EXIT V column (column doesn't exist in schema, no write path — honest UI); + backfill career_obp/slg/ops for existing rows (prod data op). +- Breadcrumbs: UUID-shaped segments never title-cased; dynamic routes supply real names + (players/[id], stats/games/[id], dev-plans/[id]). +- `createBaseballEvent` game-insert errors checked, not swallowed. +- E2E: spec cleans up its own rows (service-role delete in teardown); prod junk rows + (`E2E Created Opponent%`) deleted as a data op; isolated E2E project documented as follow-up + needing owner (new Supabase project). +- Seed script gets realistic event times (practice 15:30–17:30, games 18:00–21:00 local); + demo team's polluted event rows corrected in place. + +## Ruling 5 — Dead code deleted, not layered over + +`(coach-dashboard)`; legacy sidebar baseball arrays; legacy shell baseball path; legacy Lift Lab +tree; `players/[id]/profile` duplicate page (canonical = `players/[id]` PlayerProfileClient); +knip-confirmed orphans (MatchScoreBadge, match-calculator, dashboard-types); 5 orphaned +`baseballhelm-*.{mjs,workflow.js}` scripts (superseded by Helm Bridge). + +## Out of scope tonight (documented, not forgotten) + +Route-group merge of `(player-dashboard)` into `(dashboard)`; dedicated E2E Supabase project; +PlayerPassportCard→Fairway preview swap if polish wave runs out of clock; full 3-lane +Living-Annual masthead vision (ui-migration-map L56) — the 8-hub IA is the stepping stone. From 4ed9617f69ef66853583e92e05c7d2259293d88c Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 01:25:35 -0400 Subject: [PATCH 02/19] refactor(baseball): render BaseballFairwayShell unconditionally, delete dead (coach-dashboard) route group Coherence Ruling 1 (docs/baseball/COHERENCE_RULING_2026-07-08.md): NEXT_PUBLIC_REDESIGN=true is what prod serves, so the legacy shell fork in (dashboard)/layout.tsx and (player-dashboard)/player/layout.tsx was pure regression risk. Both now mount BaseballFairwayShell unconditionally; the team-context DashboardSessionGuard is unchanged. Deletes src/app/baseball/(coach-dashboard)/ entirely (confirmed dead: only layout.tsx/template.tsx/error.tsx, zero page.tsx). Updates stale BaseballShellLayout references in comments/test descriptions to BaseballFairwayShell. --- .../(coach-dashboard)/coach/layout.tsx | 7 -- .../(coach-dashboard)/coach/template.tsx | 89 ------------------- src/app/baseball/(coach-dashboard)/error.tsx | 35 -------- .../(dashboard)/BaseballFairwayShell.tsx | 56 +++++------- src/app/baseball/(dashboard)/layout.tsx | 36 +++----- .../(player-dashboard)/player/layout.tsx | 10 +-- src/components/baseball/NotificationBell.tsx | 5 +- .../__tests__/use-baseball-auth.test.tsx | 6 +- src/hooks/use-baseball-auth.ts | 2 +- 9 files changed, 41 insertions(+), 205 deletions(-) delete mode 100644 src/app/baseball/(coach-dashboard)/coach/layout.tsx delete mode 100644 src/app/baseball/(coach-dashboard)/coach/template.tsx delete mode 100644 src/app/baseball/(coach-dashboard)/error.tsx diff --git a/src/app/baseball/(coach-dashboard)/coach/layout.tsx b/src/app/baseball/(coach-dashboard)/coach/layout.tsx deleted file mode 100644 index eae5bc223..000000000 --- a/src/app/baseball/(coach-dashboard)/coach/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; - -export default function CoachDashboardLayout({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/src/app/baseball/(coach-dashboard)/coach/template.tsx b/src/app/baseball/(coach-dashboard)/coach/template.tsx deleted file mode 100644 index 1b932dfd8..000000000 --- a/src/app/baseball/(coach-dashboard)/coach/template.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client'; - -/** - * Coach route template — retained for the coach route group shell boundary. - * - * The legacy per-coach-type pages under /baseball/coach/* were removed once the - * canonical Fairway dashboard routes were wired. This template stays with the - * group so any future coach-owned subroutes inherit the same reduced-motion - * route reveal as the dashboard and player groups. - * - * Ported VERBATIM in spirit from the sibling templates - * ((dashboard)/dashboard/template.tsx, (player-dashboard)/player/template.tsx), - * which themselves track the GolfHelm route-reveal — same curve, same duration, - * same opacity-only recipe — so all four BaseballHelm dashboard groups transition - * identically. Do NOT diverge the recipe here; uniform motion across the groups is - * the whole point of the fix. - * - * Self-contained motion provider: the BaseballDashboardShell does not mount a motion - * provider, so this template wraps its own LazyMotion (tree-shaken `domAnimation`) + - * MotionConfig. No shell edit required — the fix stays inside this group's file - * ownership. - * - * Recipe — matches the canonical Fairway RouteTransition primitive EXACTLY: - * - Opacity-ONLY crossfade 0 → 1 over --fw-dur-base (280ms) - * - --fw-ease-glide = cubic-bezier(0.16, 1, 0.3, 1) (the iOS out-quint) - * - * Why opacity-only (no slide / no `will-change: transform`): a transform value — or - * a persistent `will-change: transform` — establishes a CSS containing block, which - * re-anchors every `position: fixed` descendant (peek panels, the mobile sidebar - * overlay, any in-tree action bar) to THIS wrapper instead of the viewport. A pure - * crossfade sidesteps that hazard entirely while staying premium on the glide curve. - * - * Keyed on pathname (NOT search params): switching a coach vertical's in-page mode - * via query string (e.g. JUCO recruit↔team toggle, Showcase team selection) keeps - * the same route segment, so it does NOT re-fire the reveal — only true cross-home - * navigation crossfades, which is the intended behavior. - * - * Reduced-motion: honored TWO ways — MotionConfig reducedMotion="user" reads the OS - * preference platform-wide, and useReducedMotion() collapses this reveal to a faster - * linear fade. No baseball-specific in-app animation toggle exists yet, so the OS - * preference is the source of truth. - */ - -import { LazyMotion, domAnimation, MotionConfig, m, useReducedMotion } from 'framer-motion'; -import { usePathname } from 'next/navigation'; - -// --fw-ease-glide = cubic-bezier(0.16, 1, 0.3, 1); --fw-dur-base = 280ms. -const GLIDE = [0.16, 1, 0.3, 1] as const; -const DURATION = 0.28; - -export default function BaseballCoachDashboardTemplate({ - children, -}: { - children: React.ReactNode; -}) { - // No `strict` on LazyMotion: descendant pages may render the full `motion.*` - // component, which LazyMotion-strict forbids. Non-strict lets them load their own - // features while this template stays on the tree-shaken `m`. - return ( - - - {children} - - - ); -} - -function RouteReveal({ children }: { children: React.ReactNode }) { - const prefersReducedMotion = useReducedMotion(); - const pathname = usePathname(); - return ( - - {children} - - ); -} diff --git a/src/app/baseball/(coach-dashboard)/error.tsx b/src/app/baseball/(coach-dashboard)/error.tsx deleted file mode 100644 index fe552ad02..000000000 --- a/src/app/baseball/(coach-dashboard)/error.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -/** - * Group-level error boundary for the coach dashboard route group. - * - * Catches render-time throws in ANY coach segment (college / high-school / - * juco / showcase). These type-specific routes now redirect into the main - * dashboard shell, but the group still needs a local boundary. - * - * The route group previously had no error.tsx at its root, so a render-time - * throw bubbled unhandled past the group to the app root. This is the safety - * net for stale bookmarks and transitional route failures. - */ - -import { RouteErrorBoundary } from '@/components/errors'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - - ); -} diff --git a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx index 5a36f8abf..7c816815c 100644 --- a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx +++ b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx @@ -2,39 +2,32 @@ /** * ============================================================================ - * BaseballFairwayShell (ADDITIVE · FLAG-GATED) — Fairway migration Phase A + * BaseballFairwayShell — the ONE dashboard frame for BaseballHelm * ---------------------------------------------------------------------------- - * The flag-ON dashboard frame for the generic `/baseball/(dashboard)` route - * group. Mirrors GolfHelm's `FairwayDashboardShell` playbook exactly: a full, - * standalone replacement for the legacy shell composition (not a wrapper - * around it) that renders the shared Fairway `` — the warm-black - * recessive rail on desktop, a slide-in glass drawer on mobile, the one glass - * top bar — in place of the legacy `BaseballDashboardShell`. + * Mounted unconditionally by the generic `/baseball/(dashboard)` and + * `/baseball/player` route-group layouts (Coherence Ruling 1, 2026-07-08 — + * see docs/baseball/COHERENCE_RULING_2026-07-08.md). Mirrors GolfHelm's + * `FairwayDashboardShell` playbook: renders the shared Fairway `` — + * the warm-black recessive rail on desktop, a slide-in glass drawer on + * mobile, the one glass top bar. * * PRESENTATION ONLY. No server actions, no RLS, no new reads beyond what the * existing baseball auth/nav hooks already resolve: - * - useBaseballAuth(requiredRole) — the SAME session/onboarding gate - * BaseballShellLayout uses for each mounted route group. + * - useBaseballAuth(requiredRole) — the SAME session/onboarding gate every + * mounted route group uses. * - useBaseballNavContext() — the SAME server-resolved capability map - * (nav-context.ts), so capability-gated verticals never fail-closed here - * when they wouldn't in the legacy shell. + * (nav-context.ts), so capability-gated verticals never fail-closed here. * - getVisibleBaseballNav() — the #383 capability-gated nav-registry * single source of truth (nav-registry.ts). NavSections are built from * this, never a hardcoded route list, so this shell can't drift from (or - * duplicate) what Sidebar / MobileBottomNav / CommandPalette already read. + * duplicate) what MobileBottomNav / CommandPalette already read. * - * PROVIDER STACK — kept VERBATIM from BaseballShellLayout (the shared - * composition point for all three BaseballHelm shell route groups): the same - * SidebarProvider > SessionActivityProvider > LastSeenUpdater > - * PeekPanelProvider nesting, unchanged. BaseballShellLayout.tsx itself is not - * imported or edited — this file is a parallel, full duplicate of that - * composition (same reason GolfHelm's FairwayDashboardShell duplicates - * GolfDashboardShell's stack rather than wrapping it). - * - * Mounted ONLY behind isRedesignEnabled() in the Baseball dashboard/player - * route-group layouts. Flag OFF renders the legacy `BaseballShellLayout` → - * `BaseballDashboardShell`, byte-for-byte unchanged. + * PROVIDER STACK: SidebarProvider > SessionActivityProvider > LastSeenUpdater + * > PeekPanelProvider — the same nesting the legacy `BaseballShellLayout` / + * `BaseballDashboardShell` composition used before it was deleted (Ruling 1 / + * Ruling 5). This file is the sole surviving shell for BaseballHelm. * + * The AppShell drawer (`mobileOpen`) is BRIDGED to the SAME SidebarContext * every legacy baseball page's own menu button already calls `setMobileOpen` * against, so a not-yet-migrated page opens the SAME drawer. One nav surface, @@ -564,15 +557,15 @@ function BaseballFairwayContent({ - {/* Same global the legacy BaseballDashboardShell mounts unconditionally. */} + {/* The one global CommandPalette mount for the whole shell. */} ); } /** - * Exported shell — full standalone replacement for BaseballShellLayout (auth - * gate + provider stack + shell), rendering the Fairway AppShell frame. + * Exported shell — auth gate + provider stack + shell, rendering the Fairway + * AppShell frame. The only shell BaseballHelm renders. */ export function BaseballFairwayShell({ children, @@ -583,10 +576,9 @@ export function BaseballFairwayShell({ authVerified?: boolean; requiredRole?: Role | null; }) { - // SAME auth gate BaseballShellLayout uses for the mounted route group. + // Session/onboarding gate for the mounted route group. const { loading, authorized, role } = useBaseballAuth(requiredRole); - // SAME server-resolved capability map (nav-context.ts) BaseballShellLayout - // passes into BaseballDashboardShell. + // Server-resolved capability map (nav-context.ts) driving the nav sections. const { navContext } = useBaseballNavContext(); if (!authVerified && (loading || !authorized)) { @@ -600,10 +592,8 @@ export function BaseballFairwayShell({ {/* Render-null: fetches the program's brand + applies it as CSS vars / - data attrs on . Mounted here too — this is a full parallel - duplicate of BaseballShellLayout's provider stack (not a wrapper), - so branding would otherwise silently die whenever the redesign - flag is on for this route group. */} + data attrs on so persisted branding (settings/appearance) + actually takes visible effect. */} diff --git a/src/app/baseball/(dashboard)/layout.tsx b/src/app/baseball/(dashboard)/layout.tsx index d5ff8221e..59b5fb1b2 100644 --- a/src/app/baseball/(dashboard)/layout.tsx +++ b/src/app/baseball/(dashboard)/layout.tsx @@ -6,7 +6,7 @@ // W6 PROTECTION — team-context guard for the generic (dashboard) route group. // // This layout accepts BOTH roles (coach AND player) with requiredRole=null. -// BaseballShellLayout handles: +// BaseballFairwayShell handles: // - Auth enforcement (useBaseballAuth): unauthenticated → /baseball/login, // no profile → /baseball/complete-signup, onboarding incomplete → onboarding. // @@ -36,23 +36,21 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; import { BaseballFairwayShell } from './BaseballFairwayShell'; import { BaseballDashboardBootstrap } from '@/components/baseball/BaseballDashboardBootstrap'; import { useBaseballAuth } from '@/hooks/use-baseball-auth'; import { useBaseballNavContext } from '@/hooks/use-baseball-nav-context'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; // --------------------------------------------------------------------------- -// Inner guard — rendered after BaseballShellLayout's auth gate has passed. +// Inner guard — rendered before BaseballFairwayShell's auth gate re-checks. // Placed as a sibling wrapper so the hooks run in a client component boundary // that is ABOVE the shell (layout route groups wrap all children). // -// Strategy: call the same two hooks that BaseballShellLayout uses internally. +// Strategy: call the same two hooks that BaseballFairwayShell uses internally. // Both are safe to call twice: // - useBaseballAuth: verifies session; fast-path from Zustand persisted store. // - useBaseballNavContext: module-scope cache → no extra network call when -// BaseballShellLayout calls it again synchronously after this component. +// BaseballFairwayShell calls it again synchronously after this component. // --------------------------------------------------------------------------- function DashboardSessionGuard({ children }: { children: React.ReactNode }) { const router = useRouter(); @@ -90,31 +88,19 @@ function DashboardSessionGuard({ children }: { children: React.ReactNode }) { return ; } - const shell = isRedesignEnabled() ? ( - {children} - ) : ( - - {children} - - ); - - return shell; + return {children}; } // --------------------------------------------------------------------------- -// Layout export — thin shell that composes the session guard around the shared -// BaseballShellLayout. Both components are client components; the guard runs +// Layout export — thin shell that composes the session guard around +// BaseballFairwayShell. Both components are client components; the guard runs // its hooks before the shell renders, so the shell always receives a non-null // navContext (or the user has already been redirected away). // -// FAIRWAY MIGRATION — PHASE A (shell only): the team-context guard above is -// shared verbatim by both branches below — it is not part of "the shell" and -// stays byte-for-byte unchanged regardless of the flag. Only what renders -// INSIDE the guard is gated: flag ON mounts BaseballFairwayShell (the Fairway -// AppShell frame, presentation-only); flag OFF keeps the existing -// BaseballShellLayout -> BaseballDashboardShell composition exactly as it -// shipped before this change. Default (flag unset) is OFF — dark and -// reversible. +// SHELL UNIFICATION (Coherence Ruling 1, 2026-07-08): BaseballFairwayShell +// renders unconditionally — the legacy isRedesignEnabled() fork to +// BaseballShellLayout was removed. This is the only shell the (dashboard) +// route group renders. // --------------------------------------------------------------------------- export default function DashboardLayout({ children, diff --git a/src/app/baseball/(player-dashboard)/player/layout.tsx b/src/app/baseball/(player-dashboard)/player/layout.tsx index 9306ef5d9..d8cd1978a 100644 --- a/src/app/baseball/(player-dashboard)/player/layout.tsx +++ b/src/app/baseball/(player-dashboard)/player/layout.tsx @@ -1,16 +1,8 @@ 'use client'; import { BaseballFairwayShell } from '@/app/baseball/(dashboard)/BaseballFairwayShell'; -import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; export default function PlayerDashboardLayout({ children }: { children: React.ReactNode }) { - const shell = isRedesignEnabled() ? ( - {children} - ) : ( - {children} - ); - return ( <> {/* @@ -20,7 +12,7 @@ export default function PlayerDashboardLayout({ children }: { children: React.Re * (start_url: /baseball/player/today) is available for player sessions. */} - {shell} + {children} ); } diff --git a/src/components/baseball/NotificationBell.tsx b/src/components/baseball/NotificationBell.tsx index af6954dbf..9c85b30af 100644 --- a/src/components/baseball/NotificationBell.tsx +++ b/src/components/baseball/NotificationBell.tsx @@ -14,11 +14,10 @@ // clears the badge optimistically. // // Design tokens: cream/green palette — no blue/purple. Matches the shared -// BaseballDashboardShell header aesthetic (warm-matte surfaces, subtle +// BaseballFairwayShell header aesthetic (warm-matte surfaces, subtle // ring focus, green-primary CTAs). // -// Integration: the shell integration agent wires this into -// BaseballShellLayout / BaseballDashboardShell header. +// Integration: mounted in BaseballFairwayShell's top bar actions slot. // ============================================================================= import { useState, useEffect, useTransition, useCallback } from 'react'; diff --git a/src/hooks/__tests__/use-baseball-auth.test.tsx b/src/hooks/__tests__/use-baseball-auth.test.tsx index 631d55bf2..6714db20a 100644 --- a/src/hooks/__tests__/use-baseball-auth.test.tsx +++ b/src/hooks/__tests__/use-baseball-auth.test.tsx @@ -4,7 +4,7 @@ * Covers: * - Dedup: (dashboard)/layout.tsx mounts this hook twice on the same hard * navigation (once in DashboardSessionGuard, once again inside - * BaseballShellLayout, gated so the second only mounts after the first + * BaseballFairwayShell, gated so the second only mounts after the first * settles). Before the fix, each mount independently ran the full * 4-round-trip session verification, doubling the time-to-chrome for * every dashboard page load. The short-lived module cache must collapse @@ -97,7 +97,7 @@ describe('useBaseballAuth', () => { // to land on a fresh cache key instead of resetting module state. }); - it('dedupes two back-to-back mounts (DashboardSessionGuard + BaseballShellLayout) into a single verifyServerSession round trip', async () => { + it('dedupes two back-to-back mounts (DashboardSessionGuard + BaseballFairwayShell) into a single verifyServerSession round trip', async () => { const first = renderHook(() => useBaseballAuth(null)); await waitFor(() => expect(first.result.current.loading).toBe(false)); expect(first.result.current.authorized).toBe(true); @@ -106,7 +106,7 @@ describe('useBaseballAuth', () => { expect(callsAfterFirst).toBe(1); // Second mount, same requiredRole, immediately after the first settles — - // exactly the DashboardSessionGuard -> BaseballShellLayout sequence. + // exactly the DashboardSessionGuard -> BaseballFairwayShell sequence. const second = renderHook(() => useBaseballAuth(null)); await waitFor(() => expect(second.result.current.loading).toBe(false)); expect(second.result.current.authorized).toBe(true); diff --git a/src/hooks/use-baseball-auth.ts b/src/hooks/use-baseball-auth.ts index 129f638f2..121c761d2 100644 --- a/src/hooks/use-baseball-auth.ts +++ b/src/hooks/use-baseball-auth.ts @@ -31,7 +31,7 @@ type VerifyResult = // // (dashboard)/layout.tsx's DashboardSessionGuard calls useBaseballAuth(null) // to gate the team-context recovery redirect, then — only once that settles — -// mounts BaseballShellLayout, which calls useBaseballAuth(requiredRole) AGAIN +// mounts BaseballFairwayShell, which calls useBaseballAuth(requiredRole) AGAIN // to gate the shared shell chrome. Both calls run the full 4-round-trip // verifyServerSession() (auth.getUser() + 3 parallel profile queries) // independently, back-to-back (never concurrently, since the second mount is From cf0d024709f78f475091cf88e570438275e4ccba Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 01:25:51 -0400 Subject: [PATCH 03/19] refactor(baseball): delete legacy BaseballShellLayout/dashboard-shell, strip baseball nav from shared sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files had zero real importers once the (dashboard)/(player-dashboard) layouts render BaseballFairwayShell unconditionally (previous commit) — deleted per Ruling 1/5. src/components/layout/sidebar.tsx is shared with golf: removed the 5 legacy baseball nav arrays (collegeTeamNav/hsCoachTeamNav/jucoTeamNav/showcaseOrgNav/playerTeamNav), buildCondensedBaseballNavigation(), the showcase org/team TeamSwitcher branch, and every other baseball-only branch. The isGolf branch (golfCoachNav, dashboardHref, EXACT_MATCH_HREFS golf entry, coachSecondaryNav/playerSecondaryNav fallback, isHubItemActive) is byte-identical to before — golf renders the exact same values it did prior to this change. Note: BaseballHelm no longer renders this Sidebar component at all (it only ever had one consumer, the now-deleted BaseballDashboardShell) — GolfHelm's real rendered sidebar is the separate GolfSidebar.tsx via GolfDashboardShell, not this file. This component is consequently unreferenced by any route today; flagged for the commander as a follow-up deletion candidate, not removed here since it falls outside this task's named scope and the file is marked shared/untouchable without further authorization. --- .../baseball/BaseballShellLayout.tsx | 87 ---- src/components/baseball/dashboard-shell.tsx | 268 ------------ src/components/layout/sidebar.tsx | 410 +----------------- 3 files changed, 20 insertions(+), 745 deletions(-) delete mode 100644 src/components/baseball/BaseballShellLayout.tsx delete mode 100644 src/components/baseball/dashboard-shell.tsx diff --git a/src/components/baseball/BaseballShellLayout.tsx b/src/components/baseball/BaseballShellLayout.tsx deleted file mode 100644 index 85d937d7c..000000000 --- a/src/components/baseball/BaseballShellLayout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -// ============================================================================= -// BaseballShellLayout — shared layout wrapper for all three BaseballHelm shell -// route groups: (dashboard), (coach-dashboard)/coach, (player-dashboard)/player. -// -// Extracted so the three layout.tsx files stay a thin ≤15-line wrapper around -// this component instead of triplicating the same provider tree. Any change to -// the session / provider / shell composition is made here once; the three layouts -// pick it up automatically. -// -// The only difference between the three layout variants is auth-role enforcement: -// - (dashboard): useBaseballAuth() — accepts both roles, passes dynamic -// - (coach-dashboard): useBaseballAuth('coach') — rejects players -// - (player-dashboard): useBaseballAuth('player') — rejects coaches -// -// The `requiredRole` prop maps to those three cases so callers can share this -// component without adding their own provider trees. -// ============================================================================= - -import type { ReactNode } from 'react'; -import { SidebarProvider } from '@/contexts/sidebar-context'; -import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider'; -import { PageLoading } from '@/components/ui/loading'; -import { LastSeenUpdater } from '@/components/admin/LastSeenUpdater'; -import { PeekPanelProvider } from '@/components/baseball/peek-panel'; -import { BaseballDashboardShell } from '@/components/baseball/dashboard-shell'; -import { BaseballProgramBrand } from '@/components/baseball/settings/BaseballProgramBrand'; -import { useBaseballAuth } from '@/hooks/use-baseball-auth'; -import { useBaseballNavContext } from '@/hooks/use-baseball-nav-context'; -import type { ActiveBaseballRole } from '@/lib/baseball/active-context-shared'; - -interface BaseballShellLayoutProps { - children: ReactNode; - /** - * When set, the auth hook enforces that the signed-in user has the matching - * role and redirects otherwise. `null` accepts both coach and player (the - * generic /baseball/dashboard route group). The `role` passed to the shell is - * derived from the auth response when this is `null`. - */ - requiredRole: ActiveBaseballRole | null; - /** Parent layout already verified auth — skip the second full-page skeleton. */ - authVerified?: boolean; -} - -export function BaseballShellLayout({ - children, - requiredRole, - authVerified = false, -}: BaseballShellLayoutProps) { - // For the generic (dashboard) group, `requiredRole` is null and the hook - // accepts both roles + returns the real role from the session. For the - // role-locked groups it enforces the expected role and redirects on mismatch. - const { loading, authorized, role } = useBaseballAuth(requiredRole); - // Server-resolved capability map so capability-gated coach verticals (Import - // Center, Postgame Review, Practice Effectiveness, Decision Room, Staff/Program - // Settings) actually appear in the nav. Without this the shell falls back to an - // empty capability map and fail-closes every gated entry. - const { navContext } = useBaseballNavContext(); - - if (!authVerified && (loading || !authorized)) { - return ; - } - - // When a role is locked (coach / player layout) pass it directly so the shell - // does not have to re-derive it from the session. When null, use the value the - // auth hook resolved (never undefined at this point because we checked above). - const resolvedRole: 'coach' | 'player' = - requiredRole ?? role ?? 'coach'; - - return ( - - - - {/* Render-null: fetches the program's brand + applies it as CSS vars / - data attrs on . Mounted once per shell so persisted branding - (settings/appearance) actually takes visible effect. */} - - - - {children} - - - - - ); -} diff --git a/src/components/baseball/dashboard-shell.tsx b/src/components/baseball/dashboard-shell.tsx deleted file mode 100644 index 78f6692a8..000000000 --- a/src/components/baseball/dashboard-shell.tsx +++ /dev/null @@ -1,268 +0,0 @@ -'use client'; - -import { useEffect, useRef, useMemo } from 'react'; -import { usePathname } from 'next/navigation'; -import { Sidebar } from '@/components/layout/sidebar'; -import { CommandPalette } from '@/components/CommandPalette'; -import { MobileBottomNav, type MobileNavItem } from '@/components/layout/mobile-bottom-nav'; -import { useSidebar } from '@/contexts/sidebar-context'; -import { useUnreadCount } from '@/hooks/use-unread-count'; -import { cn } from '@/lib/utils'; -import { HubSubNav } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; -import { resolveActiveHub } from '@/app/baseball/(dashboard)/_components/resolve-active-hub'; -import { NotificationBell } from '@/components/baseball/NotificationBell'; -import { - getVisibleBaseballNav, - type BaseballNavContext, -} from '@/lib/baseball/nav-registry'; -import { - IconHome, - IconUsers, - IconUser, - IconCalendar, - IconMenu, -} from '@/components/icons'; - -// --------------------------------------------------------------------------- -// Fallback mobile nav (rendered while navContext is still resolving so the -// bottom bar is never empty on first paint). -// --------------------------------------------------------------------------- -const COACH_NAV_FALLBACK: MobileNavItem[] = [ - { label: 'Home', href: '/baseball/dashboard/command-center', icon: IconHome }, - { label: 'Calendar', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { label: 'Roster', href: '/baseball/dashboard/roster', icon: IconUsers }, -]; - -const PLAYER_NAV_FALLBACK: MobileNavItem[] = [ - { label: 'Home', href: '/baseball/player/today', icon: IconHome }, - { label: 'Schedule', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { label: 'Profile', href: '/baseball/dashboard/profile', icon: IconUser }, -]; - -/** - * Derive the 4 mobile bottom nav items from the resolved nav context. - * - * Strategy: keep the three everyday mobile destinations stable, then make the - * fourth slot open the full drawer. The registry still decides whether the - * preferred ids exist for the current role/program, and the drawer carries the - * rest of the product surface without pretending Settings is "More". - */ -function buildMobileNavFromContext( - ctx: BaseballNavContext, - unreadCount: number, - openMenu: () => void, -): MobileNavItem[] { - const primary = getVisibleBaseballNav(ctx).filter((e) => e.section === 'primary'); - const preferredIds = ctx.role === 'coach' - ? ['command-center', 'calendar', 'roster'] - : ['player-today', 'calendar', 'player-profile']; - const top3 = preferredIds - .map((id) => primary.find((e) => e.id === id)) - .filter((e): e is NonNullable => Boolean(e)); - const fill = primary.filter((e) => !top3.some((selected) => selected.id === e.id)); - const items: MobileNavItem[] = [...top3, ...fill].slice(0, 3).map((e) => ({ - label: e.label, - href: e.href, - icon: e.icon, - ...(e.showUnreadBadge && unreadCount > 0 ? { badge: unreadCount } : {}), - })); - items.push({ - label: 'Menu', - icon: IconMenu, - onClick: openMenu, - ...(unreadCount > 0 ? { badge: unreadCount } : {}), - }); - return items; -} - -type Props = { - children: React.ReactNode; - role: 'coach' | 'player'; - /** - * Server-resolved nav context (role + capabilities + programType). When - * provided, the mobile bottom nav is derived from getVisibleBaseballNav() so - * nav registry changes propagate automatically. Falls back to the hardcoded - * role constants until the context resolves. - */ - navContext?: BaseballNavContext; -}; - -export function BaseballDashboardShell({ children, role, navContext }: Props) { - const { collapsed, mobileOpen, setMobileOpen } = useSidebar(); - const pathname = usePathname(); - const { unreadCount } = useUnreadCount(); - - // Derive the mobile nav from the registry when the context is available; - // fall back to the role-specific constants while it is still resolving. - const mobileNavItems = useMemo(() => { - if (navContext) { - return buildMobileNavFromContext(navContext, unreadCount, () => setMobileOpen(true)); - } - const fallback = role === 'coach' ? COACH_NAV_FALLBACK : PLAYER_NAV_FALLBACK; - return [ - ...fallback, - { - label: 'Menu', - icon: IconMenu, - onClick: () => setMobileOpen(true), - ...(unreadCount > 0 ? { badge: unreadCount } : {}), - }, - ]; - }, [navContext, role, unreadCount, setMobileOpen]); - - // Grouped-hubs sub-tab strip: resolve which hub (Team / Stats / Development / - // Management / Recruiting / Academics) owns the current route and render its - // sub-tabs above the page. Top-level surfaces (Dashboard, Profile, etc.) sit - // in no hub → activeHub is null → no strip. - const activeHub = resolveActiveHub({ - pathname, - role, - programType: navContext?.programType ?? null, - capabilities: navContext?.capabilities, - }); - - const mobileSidebarRef = useRef(null); - const triggerRef = useRef(null); - - // Close mobile sidebar on Escape key - useEffect(() => { - if (!mobileOpen) return; - function onKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') setMobileOpen(false); - } - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [mobileOpen, setMobileOpen]); - - // Prevent body scroll when mobile sidebar is open - useEffect(() => { - if (!mobileOpen) return; - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, [mobileOpen]); - - // Focus trap for mobile sidebar + restore focus on close - useEffect(() => { - if (!mobileOpen || !mobileSidebarRef.current) return; - - // Store the element that had focus before sidebar opened - triggerRef.current = document.activeElement; - - const sidebar = mobileSidebarRef.current; - const focusable = sidebar.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - const first = focusable[0]; - if (first) first.focus(); - - function trapFocus(e: KeyboardEvent) { - if (e.key !== 'Tab' || focusable.length === 0) return; - const firstEl = focusable[0]!; - const lastEl = focusable[focusable.length - 1]!; - if (e.shiftKey && document.activeElement === firstEl) { - e.preventDefault(); - lastEl.focus(); - } else if (!e.shiftKey && document.activeElement === lastEl) { - e.preventDefault(); - firstEl.focus(); - } - } - document.addEventListener('keydown', trapFocus); - return () => { - document.removeEventListener('keydown', trapFocus); - // Restore focus to trigger element when sidebar closes - if (triggerRef.current instanceof HTMLElement) { - triggerRef.current.focus(); - } - }; - }, [mobileOpen]); - - return ( - // `baseball-shell` is a scoping hook ONLY (no styles of its own) so - // globals.css can safely re-point the legacy sidebar's active-nav accent - // to the program's persisted brand color without ever matching a golf - // route. This component + (src/components/layout/sidebar.tsx) - // are baseball-only, but the class gives CSS an explicit, grep-able - // boundary instead of relying on file co-location. See globals.css - // "BaseballHelm legacy shell nav-accent consumer" for the paired rule. -
- - Skip to main content - - - - -
- -
- - {/* Mobile Sidebar Overlay */} -
setMobileOpen(false)} - aria-hidden="true" - /> - - {/* Mobile Sidebar */} -
- -
- -
-
-
- -
- - {/* Grouped-hub sub-tab strip (only on hub-owned routes). */} - {activeHub && ( - - )} - {children} -
-
- - -
- ); -} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 4bbd7f73d..77e0a3ab2 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -8,14 +8,10 @@ import { useAuth } from '@/hooks/use-auth'; import { IconHome, IconUsers, - IconMessage, IconSettings, IconLogOut, - IconUser, - IconBuilding, IconCalendar, IconHelp, - IconGraduationCap, IconChevronLeft, IconChevronRight, IconChartBar, @@ -23,51 +19,29 @@ import { IconMessageSquare, IconMap, IconFileText, - IconTarget, } from '@/components/icons'; -import { - COACH_TEAM_TABS, - COACH_STATS_TABS, - COACH_DEVELOPMENT_TABS, - COACH_MANAGEMENT_TABS, - COACH_ACADEMICS_TABS, - COACH_RECRUITING_TABS, - COACH_HUB_ORDER, - COACH_HUB_DEFS, - PLAYER_STATS_TABS, - PLAYER_DEVELOPMENT_TABS, - PLAYER_TEAM_TABS, - PLAYER_RECRUITING_TABS, -} from '@/app/baseball/(dashboard)/_components/hub-definitions'; -import { TeamSwitcher } from './team-switcher'; -import { useTeams } from '@/hooks/use-teams'; -import { usePlayerTeams } from '@/hooks/use-player-teams'; import { useUnreadCount } from '@/hooks/use-unread-count'; import { useSidebar } from '@/contexts/sidebar-context'; import { Button } from '@/components/ui/button'; -import { - getVisibleBaseballNav, - getBaseballTerminology, - BASEBALL_MESSAGES_NAV, - type BaseballNavContext, -} from '@/lib/baseball/nav-registry'; -import { - filterHubTabsByCapabilities, - filterHubTabsByProgramType, -} from '@/app/baseball/(dashboard)/_components/resolve-active-hub'; // ============================================================================= -// GROUPED-HUBS NAVIGATION (approved 2026-06-24) +// GOLF-ONLY SIDEBAR (post BaseballHelm shell unification, Coherence Ruling +// 2026-07-08 — see docs/baseball/COHERENCE_RULING_2026-07-08.md Ruling 1) // -// The old flat 11–13 sidebar tabs are condensed into a handful of top-level HUBS. -// Each hub is ONE sidebar item that lands on its first child route; the hub's own -// sub-tab strip (rendered by BaseballDashboardShell via HubSubNav) then exposes -// the leaf routes. `hubPrefixes` lists every route inside the hub so the sidebar -// item lights active whenever ANY of the hub's leaves is the current page. The -// hub tab lists themselves live in the single-source-of-truth hub-definitions. +// This component historically rendered BOTH GolfHelm's flat nav AND +// BaseballHelm's "Grouped-Hubs" nav (a condensed set of top-level hubs, each +// landing on its first child route, with a sub-tab strip rendered by the +// legacy `BaseballDashboardShell`). The baseball hub-building logic (5 legacy +// coach-type nav arrays, `buildCondensedBaseballNavigation`, the showcase +// org/team split, and every other baseball-only branch) has been deleted — +// BaseballHelm now renders through `BaseballFairwayShell.tsx`, which owns its +// own parallel nav-section builders (`buildCoachHubSections` / +// `buildPlayerNavSections` / `buildShowcaseOrgSections` / +// `buildShowcaseTeamSections`) and never imports this file. // -// ADDITIVE: every existing leaf route still works; the hubs add a layer above -// them. The sidebar's dark styling is unchanged — only the top-level item set is. +// The `isGolf` branch below is preserved byte-identical. Only the golf nav +// array (`golfCoachNav`) and the generic rendering scaffolding it needs +// remain. // ============================================================================= type SidebarHubItem = { @@ -79,148 +53,6 @@ type SidebarHubItem = { hubPrefixes?: string[]; }; -/** Collect every route a hub owns (each tab's href + matchPrefixes). */ -function hubPrefixesFrom( - tabs: readonly { href: string; matchPrefixes?: readonly string[] }[], -): string[] { - return tabs.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]); -} - -// --- Coach hub item builders (one shared set; landing differs by program mode) --- -const COACH_TEAM_HUB: SidebarHubItem = { - name: 'Team', - href: COACH_TEAM_TABS[0]!.href, - icon: IconUsers, - hubPrefixes: hubPrefixesFrom(COACH_TEAM_TABS), -}; -const COACH_STATS_HUB: SidebarHubItem = { - name: 'Stats', - href: COACH_STATS_TABS[0]!.href, - icon: IconChartBar, - hubPrefixes: hubPrefixesFrom(COACH_STATS_TABS), -}; -const COACH_DEVELOPMENT_HUB: SidebarHubItem = { - name: 'Development', - href: COACH_DEVELOPMENT_TABS[0]!.href, - icon: IconTarget, - hubPrefixes: hubPrefixesFrom(COACH_DEVELOPMENT_TABS), -}; -const COACH_MANAGEMENT_HUB: SidebarHubItem = { - name: 'Management', - href: COACH_MANAGEMENT_TABS[0]!.href, - icon: IconBuilding, - hubPrefixes: hubPrefixesFrom(COACH_MANAGEMENT_TABS), -}; -const COACH_ACADEMICS_HUB: SidebarHubItem = { - name: 'Academics', - href: COACH_ACADEMICS_TABS[0]!.href, - icon: IconGraduationCap, - hubPrefixes: hubPrefixesFrom(COACH_ACADEMICS_TABS), -}; -const COACH_RECRUITING_HUB: SidebarHubItem = { - name: 'Recruiting', - href: COACH_RECRUITING_TABS[0]!.href, - icon: COACH_HUB_DEFS.recruiting.icon, - hubPrefixes: hubPrefixesFrom(COACH_RECRUITING_TABS), -}; -// Messages — persistent cross-cutting slot (golf-style), never a hub sub-tab. -// Deliberately outside BASEBALL_NAV_REGISTRY (see nav-registry.ts), so every -// nav consumer injects it explicitly; this is the shared literal both the -// condensed (8-tab) nav and the legacy showcase org nav inject. -const COACH_MESSAGES_ITEM: SidebarHubItem = { - name: BASEBALL_MESSAGES_NAV.label, - href: BASEBALL_MESSAGES_NAV.href, - icon: IconMessage, - badge: true, -}; - -// College Coach — grouped hubs. This fallback must mirror the navContext-driven -// condensed builder closely enough that the shell never flashes the old IA while -// navContext is resolving. -const collegeTeamNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/dashboard/command-center', icon: IconHome }, - COACH_MESSAGES_ITEM, - COACH_TEAM_HUB, - COACH_STATS_HUB, - COACH_DEVELOPMENT_HUB, - COACH_RECRUITING_HUB, - COACH_MANAGEMENT_HUB, -]; - -// HS Coach — same hubs, HS-specific dashboard landing. -const hsCoachTeamNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/dashboard/command-center', icon: IconHome }, - COACH_MESSAGES_ITEM, - COACH_TEAM_HUB, - COACH_STATS_HUB, - COACH_DEVELOPMENT_HUB, - COACH_MANAGEMENT_HUB, -]; - -// JUCO Coach — includes Recruiting + Academics. -const jucoTeamNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/dashboard/command-center', icon: IconHome }, - COACH_MESSAGES_ITEM, - COACH_TEAM_HUB, - COACH_STATS_HUB, - COACH_DEVELOPMENT_HUB, - COACH_RECRUITING_HUB, - COACH_ACADEMICS_HUB, - COACH_MANAGEMENT_HUB, -]; - -// Showcase Coach - Organization Mode (manages multiple teams). Showcase keeps its -// org-level surfaces at the top level; the per-team hubs appear once a team is -// selected (showcaseTeamNav below). -const showcaseOrgNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/dashboard/organization', icon: IconHome }, - { name: 'Teams', href: '/baseball/dashboard/teams', icon: IconUsers }, - { name: 'Events', href: '/baseball/dashboard/events', icon: IconCalendar }, - COACH_MESSAGES_ITEM, -]; - -// Showcase Coach - Team-specific hubs (shown when a team is selected). -const showcaseTeamNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/dashboard/command-center', icon: IconHome }, - COACH_MESSAGES_ITEM, - COACH_TEAM_HUB, - COACH_STATS_HUB, - COACH_DEVELOPMENT_HUB, -]; - -// Player — grouped hubs: Dashboard, My Profile, My Stats hub, Development hub, -// Calendar, Messages, and a Team hub (Announcements / Tasks / Documents). -const playerTeamNav: SidebarHubItem[] = [ - { name: 'Dashboard', href: '/baseball/player/today', icon: IconHome }, - { name: 'My Profile', href: '/baseball/dashboard/profile', icon: IconUser }, - { - name: 'My Stats', - href: PLAYER_STATS_TABS[0]!.href, - icon: IconChartBar, - hubPrefixes: hubPrefixesFrom(PLAYER_STATS_TABS), - }, - { - name: 'Development', - href: PLAYER_DEVELOPMENT_TABS[0]!.href, - icon: IconTarget, - hubPrefixes: hubPrefixesFrom(PLAYER_DEVELOPMENT_TABS), - }, - { name: 'Calendar', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { name: 'Messages', href: '/baseball/dashboard/messages', icon: IconMessage, badge: true }, - { - name: 'Team', - href: PLAYER_TEAM_TABS[0]!.href, - icon: IconUsers, - hubPrefixes: hubPrefixesFrom(PLAYER_TEAM_TABS), - }, - { - name: 'Recruiting', - href: PLAYER_RECRUITING_TABS[0]!.href, - icon: COACH_HUB_DEFS.recruiting.icon, - hubPrefixes: hubPrefixesFrom(PLAYER_RECRUITING_TABS), - }, -]; - // Golf Coach navigation (flat — golf keeps its own nav model; unchanged). const golfCoachNav: SidebarHubItem[] = [ { name: 'Dashboard', href: '/golf/dashboard', icon: IconHome }, @@ -251,101 +83,13 @@ const playerSecondaryNav = [ * prefix, since every nested route starts with the dashboard root). */ const EXACT_MATCH_HREFS = new Set([ - '/baseball/dashboard', - '/baseball/dashboard/command-center', - '/baseball/player/today', - '/baseball/dashboard/organization', '/golf/dashboard', ]); -const RECRUITING_PROGRAM_TYPES = new Set(['college', 'juco', 'showcase', 'academy', 'club']); - -/** - * The condensed 8-tab coach nav (COACH_NAV_8TAB_PROPOSAL.md, approved - * 2026-07-01): Dashboard, Team, Messages, Stats & Performance, Development, - * Recruiting, Academics, Management. GROUPED BY `entry.hub` (via - * COACH_HUB_ORDER / COACH_HUB_DEFS in hub-definitions.ts, itself derived from - * BASEBALL_NAV_REGISTRY) — this function never hand-lists a hub's member - * routes, so a new registry entry tagged with an existing `hub` is picked up - * automatically, with no risk of re-orphaning a feature from the sidebar. - */ -function buildCondensedBaseballNavigation(ctx: BaseballNavContext): SidebarHubItem[] { - if (ctx.role === 'player') { - return [ - { name: 'Today', href: '/baseball/player/today', icon: IconHome }, - { name: 'Schedule', href: '/baseball/dashboard/calendar', icon: IconCalendar }, - { - name: 'My Stats', - href: PLAYER_STATS_TABS[0]!.href, - icon: IconChartBar, - hubPrefixes: hubPrefixesFrom(PLAYER_STATS_TABS), - }, - { - name: 'Development', - href: PLAYER_DEVELOPMENT_TABS[0]!.href, - icon: IconTarget, - hubPrefixes: hubPrefixesFrom(PLAYER_DEVELOPMENT_TABS), - }, - { - name: 'Team', - href: PLAYER_TEAM_TABS[0]!.href, - icon: IconUsers, - hubPrefixes: hubPrefixesFrom(PLAYER_TEAM_TABS), - }, - { - name: getBaseballTerminology(ctx).exposureNoun, - href: PLAYER_RECRUITING_TABS[0]!.href, - icon: COACH_HUB_DEFS.recruiting.icon, - hubPrefixes: hubPrefixesFrom(PLAYER_RECRUITING_TABS), - }, - { name: 'Messages', href: '/baseball/dashboard/messages', icon: IconMessage, badge: true }, - { name: 'My Profile', href: '/baseball/dashboard/profile', icon: IconUser }, - ]; - } - - const items: SidebarHubItem[] = []; - - for (const hubId of COACH_HUB_ORDER) { - const def = COACH_HUB_DEFS[hubId]; - - // Recruiting/Academics are MODE-gated (RECRUITING_PROGRAM_TYPES / JUCO-only), - // not just capability-gated — checked up front so a recruiting-ineligible - // program type never shows the hub, even if a member id happens to also be - // capability-visible. - if (hubId === 'recruiting' && !(ctx.programType && RECRUITING_PROGRAM_TYPES.has(ctx.programType))) { - continue; - } - - // Every hub hides itself when NO tab is visible for this coach. Use the - // same filters as the shell-level HubSubNav resolver, not just registry - // ids: several important sub-tabs (Games, Season, settings supplements) - // are intentionally not registry entries but still own real routes. - const capFiltered = filterHubTabsByCapabilities(def.tabs, 'coach', ctx.capabilities); - const visibleTabs = filterHubTabsByProgramType(capFiltered, ctx.programType); - if (visibleTabs.length === 0) continue; - - items.push({ - name: hubId === 'recruiting' ? getBaseballTerminology(ctx).exposureNoun : def.label, - href: visibleTabs[0]!.href, - icon: def.icon, - hubPrefixes: visibleTabs.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]), - }); - - // Messages: persistent cross-cutting slot (golf-style), injected right - // after Dashboard — never a hub sub-tab (nav-registry.ts: "messages lives - // outside this set"). - if (hubId === 'dashboard') items.push(COACH_MESSAGES_ITEM); - } - - return items; -} - /** * Whether a top-level (hub or flat) sidebar item is active for the current path. * A hub item lights when the pathname matches its href OR any route inside the - * hub (`hubPrefixes`); home/dashboard items match exactly. Settings is excluded - * from the Management hub's prefixes when it would collide with the secondary - * Settings link (handled by the secondary nav's own active state). + * hub (`hubPrefixes`); home/dashboard items match exactly. */ function isHubItemActive(item: SidebarHubItem, pathname: string): boolean { const prefixes = item.hubPrefixes ?? [item.href]; @@ -358,78 +102,30 @@ function isHubItemActive(item: SidebarHubItem, pathname: string): boolean { interface SidebarProps { isMobile?: boolean; - navContext?: BaseballNavContext; } -export function Sidebar({ isMobile = false, navContext }: SidebarProps) { +export function Sidebar({ isMobile = false }: SidebarProps) { const pathname = usePathname(); const router = useRouter(); const { user, coach, player, signOut } = useAuth(); const { unreadCount } = useUnreadCount(); const { collapsed, setCollapsed, setMobileOpen } = useSidebar(); - // Use appropriate teams hook based on user role - const coachTeams = useTeams(); - const playerTeams = usePlayerTeams(); - const { hasMultipleTeams, selectedTeam } = user?.role === 'coach' ? coachTeams : playerTeams; - // Determine sport-specific dashboard href based on pathname const isGolf = pathname.startsWith('/golf'); const dashboardHref = isGolf ? '/golf/dashboard' : '/baseball/dashboard'; // Determine navigation based on role, coach type, and mode - // ARCHIVED: Recruiting mode branches removed — all users see team mode only const getNavigation = (): SidebarHubItem[] => { if (isGolf) { return golfCoachNav; } - if (navContext) { - return buildCondensedBaseballNavigation(navContext); - } - if (user?.role === 'coach') { - if (coach?.coach_type === 'college') { - return collegeTeamNav; - } else if (coach?.coach_type === 'juco') { - return jucoTeamNav; - } else if (coach?.coach_type === 'showcase') { - return showcaseOrgNav; - } else if (coach?.coach_type === 'high_school') { - return hsCoachTeamNav; - } else { - return hsCoachTeamNav; - } - } else if (user?.role === 'player') { - // ARCHIVED: All players now see team nav only — recruiting nav disabled - return playerTeamNav; - } - return collegeTeamNav; - }; - - const getTeamNavigation = () => { - if (coach?.coach_type === 'showcase' && selectedTeam) { - return showcaseTeamNav; - } return []; }; const navigation = getNavigation(); - const teamNavigation = getTeamNavigation(); - const secondaryNav = !isGolf && navContext - ? navContext.role === 'coach' - ? [{ name: 'Help', href: '/help', icon: IconHelp }] - : [ - ...getVisibleBaseballNav(navContext) - .filter((entry) => entry.section === 'secondary') - .map((entry) => ({ - name: entry.label, - href: entry.href, - icon: entry.icon, - })), - { name: 'Help', href: '/help', icon: IconHelp }, - ] - : user?.role === 'coach' ? coachSecondaryNav : playerSecondaryNav; + const secondaryNav = user?.role === 'coach' ? coachSecondaryNav : playerSecondaryNav; const displayName = coach?.full_name || (player ? `${player.first_name} ${player.last_name}` : 'User'); - const isShowcaseCoach = coach?.coach_type === 'showcase'; const subtitle = coach ? ((coach.organization as { name?: string })?.name || 'Coach') : (player ? `${player.primary_position} • ${player.grad_year}` : ''); const handleNavClick = () => { @@ -560,24 +256,10 @@ export function Sidebar({ isMobile = false, navContext }: SidebarProps) { overscrollBehavior: 'contain', }} > - {/* ARCHIVED: Recruiting mode toggle hidden — re-enable when recruiting is ready */} - {/* {showModeToggle && !isCollapsed && ( -
- -
- )} */} - - {/* Team Switcher */} - {(isShowcaseCoach || (user?.role === 'player' && hasMultipleTeams)) && hasMultipleTeams && ( - - )} - {/* Section Label */} {!isCollapsed && (

- {user?.role === 'coach' - ? (coach?.coach_type === 'showcase' ? 'Organization' : 'Team') - : 'Team'} + Team

)} @@ -636,58 +318,6 @@ export function Sidebar({ isMobile = false, navContext }: SidebarProps) { })} - {/* Team-specific navigation for Showcase Coaches */} - {isShowcaseCoach && selectedTeam && teamNavigation.length > 0 && ( - <> - {/* Divider */} -
- {!isCollapsed && ( -

- {selectedTeam.name} -

- )} -
    - {teamNavigation.map((item) => { - const isActive = isHubItemActive(item, pathname); - return ( -
  • - -
  • - ); - })} -
- - )} - {/* Divider */}
@@ -751,7 +381,7 @@ export function Sidebar({ isMobile = false, navContext }: SidebarProps) { >
Free Plan - +
Free for all teams
From 660f51f4377cbf2cd72244bf0df33db6e4e8e07c Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 01:26:02 -0400 Subject: [PATCH 04/19] refactor(baseball): remove remaining isRedesignEnabled forks in calendar/documents/messages pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar, Documents, and Messages pages had their own local isRedesignEnabled() forks rendering a legacy pre-Fairway UI. Since the shell fork is gone (BaseballFairwayShell is now the only shell), these pages always take the Fairway branch — deleted the legacy JSX and now-unused imports (Link, BaseballCalendarWrapper, EVENT_TYPE_CONFIG, Card/CardContent/Input/Select/Checkbox/DocumentCard/cn, the messages skeleton block). practice/page.tsx already had no fork (comment-only precedent reference, untouched). --- .../(dashboard)/dashboard/calendar/page.tsx | 123 +----- .../dashboard/documents/documents-client.tsx | 358 +++++------------- .../(dashboard)/dashboard/messages/page.tsx | 120 +----- 3 files changed, 115 insertions(+), 486 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx index a985885b7..b60cf6e86 100644 --- a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx @@ -2,10 +2,7 @@ import { createClient } from '@/lib/supabase/server'; import { fromUntyped } from '@/lib/supabase/untyped'; import { getSessionProfile } from '@/lib/auth/session'; import { redirect } from 'next/navigation'; -import Link from 'next/link'; -import { BaseballCalendarWrapper } from '@/components/baseball/calendar/BaseballCalendarWrapper'; import { CalendarFairway } from '@/components/baseball/calendar/CalendarFairway'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; import type { CalendarEvent } from '@/hooks/useCalendarEvents'; import type { Metadata } from 'next'; @@ -16,16 +13,6 @@ export const metadata: Metadata = { export const revalidate = 60; -const EVENT_TYPE_CONFIG: Record = { - game: { label: 'Game', dot: 'bg-blue-500' }, - practice: { label: 'Practice', dot: 'bg-primary-500' }, - camp: { label: 'Camp', dot: 'bg-purple-500' }, - tryout: { label: 'Tryout', dot: 'bg-amber-500' }, - meeting: { label: 'Meeting', dot: 'bg-warm-500' }, - travel: { label: 'Travel', dot: 'bg-sky-500' }, - other: { label: 'Other', dot: 'bg-warm-400' }, -}; - export default async function BaseballCalendarPage() { const supabase = await createClient(); @@ -218,63 +205,9 @@ export default async function BaseballCalendarPage() { // ── College coach with no team: recruiting-focused empty state ───────────── if (isCollegeCoach && !teamId) { - if (isRedesignEnabled()) { - return ( - - ); - } - return ( -
-
-
- {/* Calendar icon */} -
- - - -
- -

- Your recruiting calendar is empty -

-

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

- - {/* CTA */} - - - - - Browse Prospects - -
-
-
- ); - } - - if (isRedesignEnabled()) { return ( - {/* Event summary strip — only shown when there's an upcoming event to - summarize. Gating on `upcomingEvents` (not `events.length`) matches - what the strip actually says: a team with only past events has - nothing "upcoming" to report, so showing "0 upcoming events ·" - with no badges after it would just be a second, quieter version of - the same contradiction this strip exists to avoid. */} - {upcomingEvents > 0 && ( -
-
- - {upcomingEvents} upcoming event{upcomingEvents !== 1 ? 's' : ''} - - - {Object.entries(eventTypeCounts).map(([type, count]) => { - const cfg = EVENT_TYPE_CONFIG[type] ?? { label: type, dot: 'bg-warm-400' }; - return ( - - - {count} {cfg.label} - - ); - })} -
-
- )} - - {/* Calendar — overflow-hidden is required so PremiumCalendarClient's h-full resolves correctly. - Golf's main is overflow-y-auto (which anchors heights); baseball's is not, so we add it here. */} -
- -
-
+ ); } diff --git a/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx b/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx index 4870be4a3..9b61bf878 100644 --- a/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx +++ b/src/app/baseball/(dashboard)/dashboard/documents/documents-client.tsx @@ -1,13 +1,6 @@ 'use client'; import { useMemo, useRef, useState } from 'react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Select } from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; -import { IconFile, IconUpload, IconSearch } from '@/components/icons'; -import { DocumentCard } from '@/components/baseball/documents/DocumentCard'; import { DocumentPreview } from '@/components/baseball/documents/DocumentPreview'; import { UploadNewVersionModal } from '@/components/baseball/documents/UploadNewVersionModal'; import { EditDocumentModal, type EditDocumentSaveData } from '@/components/baseball/documents/EditDocumentModal'; @@ -25,9 +18,8 @@ import { uploadNewVersion, } from '@/app/baseball/actions/documents'; import { useToast } from '@/components/ui/sonner'; -import { cn } from '@/lib/utils'; import { DocumentsFairway } from '@/components/baseball/documents/DocumentsFairway'; -import { isRedesignEnabled, fairwayScope } from '@/lib/redesign/flag'; +import { fairwayScope } from '@/lib/redesign/flag'; const UPLOAD_ACCEPT = 'application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*,text/plain,video/mp4,video/webm,video/quicktime'; @@ -240,273 +232,95 @@ export function DocumentsClient({ documents: initialDocuments, coachId, teamId, setMovingDoc(null); } - if (isRedesignEnabled()) { - return ( -
- setPreviewDoc(d)} - onUploadVersion={isCoach ? (d) => setVersionDoc(d) : undefined} - onDelete={isCoach ? handleDelete : undefined} - onEdit={isCoach ? openEditModal : undefined} - onViewHistory={isCoach ? openVersionHistory : undefined} - onMoveToFolder={isCoach ? openMoveModal : undefined} - fileInputSlot={ - isCoach ? ( - - - ) : null - } - previewSlot={ - { - if (!open) setPreviewDoc(null); - }} - /> - } - versionSlot={ - versionDoc && isCoach ? ( - setVersionDoc(null)} - documentTitle={versionDoc.title} - currentFileType={versionDoc.file_type || null} - onUpload={handleUploadNewVersion} - /> - ) : null - } - editSlot={ - isCoach ? ( - setEditingDoc(null)} - onSave={handleSaveEdit} - /> - ) : null - } - historySlot={ - isCoach ? ( - setHistoryDoc(null)} - onReverted={handleDocumentReverted} - /> - ) : null - } - moveSlot={ - isCoach ? ( - setMovingDoc(null)} - onMove={handleSaveMove} - /> - ) : null - } - /> -
- ); - } - return ( -
- {/* Hidden file input driving both the header and empty-state upload triggers */} - {isCoach && ( - - - )} - - {/* Search & Filter Bar */} -
-
- setSearch(e.target.value)} - placeholder="Search documents..." - leftIcon={} - className="text-sm bg-cream-50 border-warm-200 rounded-lg" - /> -
-
- {CATEGORIES.map(cat => ( - - ))} -
- {isCoach && ( - - )} -
- - {/* Upload-time picker — category + visibility applied to the next file selected */} - {isCoach && ( -
-
- Next upload -
- + ) : null + } + previewSlot={ + { + if (!open) setPreviewDoc(null); + }} /> -
- )} - - {/* Documents Grid */} - {filtered.length === 0 ? ( - - -
- -
-

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

-

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

- {documents.length === 0 && isCoach && ( - - )} -
-
- ) : ( -
- {filtered.map(doc => ( - setPreviewDoc(d)} - onUploadVersion={isCoach ? (d) => setVersionDoc(d) : undefined} - onDelete={isCoach ? handleDelete : undefined} - onEdit={isCoach ? openEditModal : undefined} - onViewHistory={isCoach ? openVersionHistory : undefined} - onMoveToFolder={isCoach ? openMoveModal : undefined} + } + versionSlot={ + versionDoc && isCoach ? ( + setVersionDoc(null)} + documentTitle={versionDoc.title} + currentFileType={versionDoc.file_type || null} + onUpload={handleUploadNewVersion} /> - ))} -
- )} - - {/* Preview Modal */} - { if (!open) setPreviewDoc(null); }} + ) : null + } + editSlot={ + isCoach ? ( + setEditingDoc(null)} + onSave={handleSaveEdit} + /> + ) : null + } + historySlot={ + isCoach ? ( + setHistoryDoc(null)} + onReverted={handleDocumentReverted} + /> + ) : null + } + moveSlot={ + isCoach ? ( + setMovingDoc(null)} + onMove={handleSaveMove} + /> + ) : null + } /> - - {/* Upload New Version Modal */} - {versionDoc && isCoach && ( - setVersionDoc(null)} - documentTitle={versionDoc.title} - currentFileType={versionDoc.file_type || null} - onUpload={handleUploadNewVersion} - /> - )} - - {/* Edit Details Modal */} - {isCoach && ( - setEditingDoc(null)} - onSave={handleSaveEdit} - /> - )} - - {/* Version History Modal */} - {isCoach && ( - setHistoryDoc(null)} - onReverted={handleDocumentReverted} - /> - )} - - {/* Move to Folder Modal */} - {isCoach && ( - setMovingDoc(null)} - onMove={handleSaveMove} - /> - )}
); } diff --git a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx index 70bf34032..98c6b93ca 100644 --- a/src/app/baseball/(dashboard)/dashboard/messages/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/messages/page.tsx @@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useMemo, useRef } from 'react'; import { useSearchParams } from 'next/navigation'; -import { cn } from '@/lib/utils'; import { Loading } from '@/components/ui/loading'; import { LazyConversationList, LazyChatWindow } from '@/lib/lazy-components'; import { EmptyChatState } from '@/components/messages/EmptyChatState'; @@ -14,7 +13,6 @@ import { createConversation, getPlayerUserId } from '@/app/baseball/actions/mess import type { ConversationWithMeta } from '@/lib/types/messages'; import { getParticipantDetails } from '@/lib/types/messages'; import { MessagesFairway } from '@/components/baseball/messages/MessagesFairway'; -import { isRedesignEnabled } from '@/lib/redesign/flag'; function MessagesContent() { const searchParams = useSearchParams(); @@ -169,88 +167,11 @@ function MessagesContent() { return success; }; - if (isRedesignEnabled()) { - return ( - setShowNewMessageModal(true)} - className="h-full" - /> - } - chatSlot={ - selectedConversationId ? ( - - ) : ( - setShowNewMessageModal(true)} - className="h-full" - /> - ) - } - modalSlot={ - setShowNewMessageModal(false)} - onSelect={handleNewConversation} - currentUserRole={currentUserRole} - /> - } - /> - ); - } - - if (conversationsLoading) { - return ( -
- {/* Conversation list skeleton */} -
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
-
- {/* Chat area skeleton */} -
- -
-
- ); - } - return ( -
- {/* Conversation List - Hidden on mobile when viewing chat */} -
+ setShowNewMessageModal(true)} className="h-full" /> -
- - {/* Chat Window - Full width on mobile, split on desktop */} -
- {selectedConversationId ? ( + } + chatSlot={ + selectedConversationId ? ( setShowNewMessageModal(true)} className="h-full" /> - )} -
- - {/* New Message Modal */} - setShowNewMessageModal(false)} - onSelect={handleNewConversation} - currentUserRole={currentUserRole} - /> -
+ ) + } + modalSlot={ + setShowNewMessageModal(false)} + onSelect={handleNewConversation} + currentUserRole={currentUserRole} + /> + } + /> ); } From 44b036d2f2f954260ba05f84fdf99b521e54b09f Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 02:04:47 -0400 Subject: [PATCH 05/19] =?UTF-8?q?refactor(baseball):=20navigation=20IA=20r?= =?UTF-8?q?estructure=20=E2=80=94=20cap=20every=20hub=20at=20=E2=89=A43=20?= =?UTF-8?q?subtabs=20(Ruling=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the coach hub/subtab table from docs/baseball/COHERENCE_RULING_2026-07-08.md Ruling 2. Mechanism: nav-registry.ts gets a `foldedUnder` field (a registry entry marks itself as reachable only from a parent landing page instead of its own hub-tab slot, but keeps its registry row so command palette + deep links still work) and a `hubTabLabel` override (lets a subtab read differently from the entry's canonical label, e.g. "Performance" reads "Training" under Development). hub-definitions.ts's `hubEntries()` excludes folded entries from the rendered strip. Regrouped: Announcements moves from Team to a new real Messages hub (Messages · Announcements, replacing the old bare flat link); Practice Planner/Effectiveness move from Stats & Performance to Team (fold into the new Operations landing); Performance moves from Stats & Performance to Development (folds in as "Training"); Import Center folds into Stats Center; Interest folds into Pipeline; Watchlist/Compare/Saved Comparisons/Scout Packets/Camps fold into a new Scouting landing; Program Info/Staff Settings/Program Settings fold into the existing Settings landing; Teams/ Events fold into Organization. Deleted the 9-item SETTINGS_* hub-tab splice and the 4-item PERFORMANCE_* splice outright (dead code, not layered over) since Performance's own page already links Programs/Live/Builder/Groups. Command Center's nav-visible label renames to "Overview" (breadcrumbs, hub subtab, command palette, mobile nav) so "Dashboard" never has two names in the UI; the route/page masthead keep "Command Center". BaseballFairwayShell.tsx: Messages now flows through the generic COACH_HUB_ORDER loop (with its unread badge threaded in by hub id) instead of a hand-injected flat item; showcase org/team rails route their Messages item through the same hub-aware path so showcase coaches don't lose Announcements reachability. Mobile bottom nav trimmed to the top 3 destinations per role (Ruling 2 item 8) — the top-bar hamburger (already bridged to the same drawer) is the "+ More". /baseball/dashboard/practice now server-redirects players to the canonical /baseball/player/practice surface (item 7); coach/staff rendering unchanged. nav-manifest.ts and route-contract.ts extended to track the new Messages hub tabs for completeness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGqvhig8njW38q9PAwDXgu --- .../(dashboard)/BaseballFairwayShell.tsx | 43 ++- .../_components/hub-definitions.ts | 345 ++++++------------ .../_components/resolve-active-hub.ts | 1 + .../(dashboard)/dashboard/practice/page.tsx | 17 +- src/lib/baseball/nav-manifest.ts | 4 + src/lib/baseball/nav-registry.ts | 183 +++++++++- src/lib/baseball/route-contract.ts | 6 + 7 files changed, 348 insertions(+), 251 deletions(-) diff --git a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx index 7c816815c..b727f146e 100644 --- a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx +++ b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx @@ -97,14 +97,20 @@ type Role = 'coach' | 'player'; /** * P413-equivalent: mobile bottom-tab destinations derived from the SAME hub * sections as the desktop rail so active states agree across breakpoints. - * Golf FairwayDashboardShell uses the same pattern (5 tabs, hub activeMatch). + * Golf FairwayDashboardShell uses the same pattern (hub activeMatch). + * + * Ruling 2 (item 8): exactly the top 3 highest-frequency destinations per + * role — the mobile drawer (opened via the top bar's hamburger, bridged to + * the SAME SidebarContext this shell renders) is the "+ More" the ruling + * describes, not a 4th bottom-tab button, so 3 named + the always-present + * hamburger covers every remaining hub. */ function buildBottomNavFromSections(sections: NavSection[], role: Role): NavItem[] { const items = sections.flatMap((section) => section.items); const preferredLabels = role === 'coach' - ? (['Dashboard', 'Team', 'Stats & Performance', 'Messages'] as const) - : (['Today', 'Stats', 'Development', 'Team', 'Messages'] as const); + ? (['Dashboard', 'Team', 'Stats & Performance'] as const) + : (['Today', 'Schedule', 'Messages'] as const); const picked = preferredLabels .map((label) => items.find((item) => item.label === label)) @@ -251,23 +257,38 @@ function buildCoachHubSections(ctx: BaseballNavContext, unreadCount: number): Na href: visibleTabs[0]!.href, icon: def.icon as unknown as NavItem['icon'], tabs: visibleTabs, + // Messages is now a real hub (Ruling 2: Messages · Announcements) — + // the unread badge is the one piece of state that stays outside the + // registry-derived tab list, so it's threaded in here by hub id. + badge: hubId === 'messages' && unreadCount > 0 ? unreadCount : undefined, }), ); - - // Messages is the persistent cross-cutting slot, outside the hub registry. - if (hubId === 'dashboard') { - items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href])); - } } return [{ heading: 'Baseball', items }]; } +/** + * The Messages hub rail item (Ruling 2: a real hub with an Announcements + * subtab, not a bare flat link) — shared by the showcase org/team rails below + * so a showcase coach's Messages click ALSO opens the sub-nav strip, exactly + * like the main coach rail's (`buildCoachHubSections`) Messages entry. + */ +function messagesNavItem(unreadCount: number): NavItem { + return playerHubToNavItem({ + label: COACH_HUB_DEFS.messages.label, + href: COACH_HUB_DEFS.messages.tabs[0]!.href, + icon: COACH_HUB_DEFS.messages.icon as unknown as NavItem['icon'], + tabs: COACH_HUB_DEFS.messages.tabs, + badge: unreadCount > 0 ? unreadCount : undefined, + }); +} + /** * Showcase ORG-level rail (no team selected yet) — the documented two-level * org→team exception (COACH_NAV_8TAB_PROPOSAL.md): org-wide Dashboard/Teams/ * Events, mirroring src/components/layout/sidebar.tsx's `showcaseOrgNav` - * exactly (same routes/icons/order), plus the persistent Messages slot. + * exactly (same routes/icons/order), plus the persistent Messages hub. */ function buildShowcaseOrgSections(unreadCount: number): NavSection[] { return [ @@ -277,7 +298,7 @@ function buildShowcaseOrgSections(unreadCount: number): NavSection[] { { label: 'Dashboard', href: '/baseball/dashboard/organization', icon: IconHome }, { label: 'Teams', href: '/baseball/dashboard/teams', icon: IconUsers }, { label: 'Events', href: '/baseball/dashboard/events', icon: IconCalendar }, - toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]), + messagesNavItem(unreadCount), ], }, ]; @@ -302,7 +323,7 @@ function buildShowcaseTeamSections(ctx: BaseballNavContext, unreadCount: number) tabs: dashboardTabs, }), ); - items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href])); + items.push(messagesNavItem(unreadCount)); } for (const hubId of ['team', 'stats-performance', 'development'] as const) { const def = COACH_HUB_DEFS[hubId]; diff --git a/src/app/baseball/(dashboard)/_components/hub-definitions.ts b/src/app/baseball/(dashboard)/_components/hub-definitions.ts index 3a6c93d37..f210cbaee 100644 --- a/src/app/baseball/(dashboard)/_components/hub-definitions.ts +++ b/src/app/baseball/(dashboard)/_components/hub-definitions.ts @@ -13,24 +13,31 @@ // COACH_NAV_8TAB_PROPOSAL.md (approved 2026-07-01) — DERIVED, NOT HAND-LISTED: // every COACH_*_TABS array below is built by grouping BASEBALL_NAV_REGISTRY on // its `hub` field (nav-registry.ts), not by hand-maintaining a parallel route -// list. That is what fixed the drift the proposal documented: 5 registered -// features with no hub-tab entry anywhere (camps, postgame-review, -// practice-effectiveness, practice-planner, comparisons), a phantom coach -// `tasks` tab with no coach-visible registry row, and a player-only -// `college-interest` leaking into the coach recruiting hub. Labels, icons, -// requiredCapability/requiredAnyCapabilities, and allowedProgramTypes are all -// read from the registry entry verbatim — never re-declared here, so they can -// never drift from the registry again. The only hand-maintained data left is -// (a) each hub's DISPLAY ORDER (a small id list; entries not listed simply fall -// to the end in registry order — nothing can be silently dropped), and (b) a -// handful of SUPPLEMENTARY leaf tabs (Stats Center's Games/Season/Upload, -// Performance's Live/Programs/Groups/Builder) that are child pages of a -// registry feature, not registry features in their own right. +// list. Labels, icons, requiredCapability/requiredAnyCapabilities, and +// allowedProgramTypes are all read from the registry entry verbatim — never +// re-declared here, so they can never drift from the registry again. The only +// hand-maintained data left is (a) each hub's DISPLAY ORDER (a small id list; +// entries not listed simply fall to the end in registry order), and (b) ONE +// supplementary leaf tab (Stats Center's "Games" — a child page of a registry +// feature, not a registry feature in its own right). +// +// HARD CAP (COHERENCE_RULING_2026-07-08.md Ruling 2): every hub renders AT +// MOST 3 sub-tabs, for every coach type. The mechanism is `hub` + +// `foldedUnder` on nav-registry.ts entries: a registry entry declares +// `foldedUnder: ` to mark itself as reachable ONLY from its parent's +// LANDING PAGE (a card grid — see dashboard/operations and dashboard/scouting) +// rather than as its own rendered sub-tab. `hubEntries()` below excludes any +// entry carrying `foldedUnder` from the rendered array; the folded entry KEEPS +// its own registry row, so command palette and deep links still resolve it, +// and the parent landing entry's `matchPrefixes` (nav-registry.ts) still +// route `resolve-active-hub.ts` to the right hub + subtab when a user is on +// the folded destination directly. nav-manifest.test.ts locks both halves of +// this contract (≤3 rendered tabs; every folded href still resolves a hub). // // PLAYER_*_TABS are intentionally NOT derived the same way: most player-only // registry entries (player-dev-plan, player-lift, player-readiness, etc.) carry // no `hub` field (hub is only required for role coach/both — see nav-registry.ts), -// so they stay hand-maintained here, unchanged by this pass. +// so they stay hand-maintained here, trimmed to the same ≤3 cap by hand. // // PURE DATA + ICONS. No 'use client' / 'use server', no Supabase, no React state — // safe to import from both the client sidebar and the client hub layouts. @@ -46,24 +53,18 @@ import { IconChartBar, IconClipboardList, IconTrendingUp, - IconUpload, IconTarget, - IconVideo, - IconDumbbell, - IconGauge, IconShieldCheck, IconBuilding, IconStar, IconHome, IconUser, IconGraduationCap, - IconSettings, - IconLock, - IconDatabase, } from '@/components/icons'; import type { HubSubNavTab } from './hub-sub-nav'; import { BASEBALL_NAV_REGISTRY, + BASEBALL_MESSAGES_NAV, type BaseballNavEntry, type BaseballNavHub, type BaseballNavIcon, @@ -78,7 +79,10 @@ import { function toHubTab(entry: BaseballNavEntry): HubSubNavTab { return { id: entry.id, - label: entry.label, + // hubTabLabel overrides the canonical label ONLY inside the sub-nav + // strip (Ruling 2 — e.g. "Performance" reads "Training" here, unchanged + // everywhere else); falls back to the canonical label otherwise. + label: entry.hubTabLabel ?? entry.label, href: entry.href, icon: entry.icon, requiredCapability: entry.requiredCapability ?? undefined, @@ -89,15 +93,18 @@ function toHubTab(entry: BaseballNavEntry): HubSubNavTab { } /** - * Every coach/both registry entry tagged for `hub`, converted to tabs. `team` - * is always excluded — it is the legacy `/dashboard/team` alias (hub: - * 'dashboard' for the registry invariant, but its href is an exact duplicate - * of `command-center` for coaches), never a distinct destination worth a tab. + * Every coach/both registry entry tagged for `hub`, converted to tabs. + * Excludes two kinds of entries from the RENDERED strip (both keep their own + * registry row — see the module header): + * - `team`: the legacy `/dashboard/team` alias (hub: 'dashboard' for the + * registry invariant, but its href is an exact duplicate of + * `command-center` for coaches) — never a distinct destination. + * - anything carrying `foldedUnder`: Ruling 2's ≤3-subtabs mechanism. */ function hubEntries(hub: BaseballNavHub): HubSubNavTab[] { - return BASEBALL_NAV_REGISTRY.filter((e) => e.hub === hub && e.role !== 'player' && e.id !== 'team').map( - toHubTab, - ); + return BASEBALL_NAV_REGISTRY.filter( + (e) => e.hub === hub && e.role !== 'player' && e.id !== 'team' && !e.foldedUnder, + ).map(toHubTab); } /** Stable-sort tabs by a curated id order; unlisted ids keep registry order at the end. */ @@ -126,8 +133,12 @@ function withSupplements( // ----------------------------------------------------------------------------- // Supplementary leaf tabs — child pages of a registry feature that are not -// themselves BASEBALL_NAV_REGISTRY entries (nav-registry.ts tracks the 32 -// top-level features; these are deeper sub-pages within two of them). +// themselves BASEBALL_NAV_REGISTRY entries. Games is the ONE surviving +// supplement post-Ruling-2: Season/Upload now resolve via stats-center's own +// matchPrefixes (a view + header CTAs inside Stats Center, not their own +// tabs), and Performance's Live/Programs/Groups/Builder are reached via +// in-page masthead links on /dashboard/performance itself (verified present), +// not a second copy of the same links in the hub sub-nav strip. // ----------------------------------------------------------------------------- const STATS_GAMES_TAB: HubSubNavTab = { @@ -137,187 +148,79 @@ const STATS_GAMES_TAB: HubSubNavTab = { icon: IconClipboardList, matchPrefixes: ['/baseball/dashboard/stats/games'], }; -const STATS_SEASON_TAB: HubSubNavTab = { - id: 'season', - label: 'Season', - href: '/baseball/dashboard/stats/season', - icon: IconTrendingUp, -}; -const STATS_UPLOAD_TAB: HubSubNavTab = { - id: 'upload', - label: 'Upload', - href: '/baseball/dashboard/stats/upload', - icon: IconUpload, -}; -const PERFORMANCE_LIVE_TAB: HubSubNavTab = { - id: 'performance-live', - label: 'Live', - href: '/baseball/dashboard/performance/live', - icon: IconDumbbell, - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_PROGRAMS_TAB: HubSubNavTab = { - id: 'performance-programs', - label: 'Programs', - href: '/baseball/dashboard/performance/programs', - icon: IconClipboardList, - matchPrefixes: ['/baseball/dashboard/performance/programs'], - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_GROUPS_TAB: HubSubNavTab = { - id: 'performance-groups', - label: 'Groups', - href: '/baseball/dashboard/performance/groups', - icon: IconUsers, - requiredCapability: 'can_manage_lifting', -}; -const PERFORMANCE_BUILDER_TAB: HubSubNavTab = { - id: 'performance-builder', - label: 'Builder', - href: '/baseball/dashboard/performance/builder', - icon: IconGauge, - requiredCapability: 'can_manage_lifting', -}; -const SETTINGS_HOME_TAB: HubSubNavTab = { - id: 'settings-home', - label: 'Settings', - href: '/baseball/dashboard/settings', - icon: IconSettings, -}; -const SETTINGS_SEASON_TAB: HubSubNavTab = { - id: 'settings-season', - label: 'Season', - href: '/baseball/dashboard/settings/season', - icon: IconCalendar, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_PHILOSOPHY_TAB: HubSubNavTab = { - id: 'settings-philosophy', - label: 'Philosophy', - href: '/baseball/dashboard/settings/philosophy', - icon: IconTarget, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_ROLES_TAB: HubSubNavTab = { - id: 'settings-roles', - label: 'Roles', - href: '/baseball/dashboard/settings/roles', - icon: IconLock, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_PERMISSIONS_TAB: HubSubNavTab = { - id: 'settings-permissions', - label: 'Permissions', - href: '/baseball/dashboard/settings/permissions', - icon: IconShieldCheck, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_TEAMS_TAB: HubSubNavTab = { - id: 'settings-teams', - label: 'Team Settings', - href: '/baseball/dashboard/settings/teams', - icon: IconUsers, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_IMPORTS_TAB: HubSubNavTab = { - id: 'settings-imports', - label: 'Imports', - href: '/baseball/dashboard/settings/imports', - icon: IconUpload, - requiredCapability: 'can_manage_imports', -}; -const SETTINGS_INTEGRATIONS_TAB: HubSubNavTab = { - id: 'settings-integrations', - label: 'Integrations', - href: '/baseball/dashboard/settings/integrations', - icon: IconBuilding, - requiredCapability: 'can_manage_settings', -}; -const SETTINGS_AUDIT_TAB: HubSubNavTab = { - id: 'settings-audit', - label: 'Audit', - href: '/baseball/dashboard/settings/audit', - icon: IconDatabase, - requiredCapability: 'can_manage_settings', -}; // ----------------------------------------------------------------------------- -// Curated display order per hub (COACH_NAV_8TAB_PROPOSAL.md mapping table). -// Membership is always registry-derived (hubEntries); this only sequences it. +// Curated display order per hub (COHERENCE_RULING_2026-07-08.md Ruling 2 — +// the hub/subtab table is authoritative). Membership is always +// registry-derived (hubEntries); this only sequences it, and every hub below +// resolves to AT MOST 3 rendered tabs. // ----------------------------------------------------------------------------- const DASHBOARD_ORDER = ['command-center', 'signals']; -const TEAM_ORDER = ['roster', 'calendar', 'announcements', 'documents', 'travel']; -const STATS_PERFORMANCE_ORDER = [ - 'stats-center', - 'performance', - 'postgame-review', - 'practice-planner', - 'practice-effectiveness', - 'import-center', -]; -const DEVELOPMENT_ORDER = ['dev-plans', 'videos']; -const RECRUITING_ORDER = [ - 'pipeline', - 'college-interest', - 'discover', - 'watchlist', - 'compare', - 'comparisons', - 'scout-packets', - 'camps', -]; +const TEAM_ORDER = ['roster', 'calendar', 'operations']; +const MESSAGES_ORDER = ['announcements']; +const STATS_PERFORMANCE_ORDER = ['stats-center', 'postgame-review']; +const DEVELOPMENT_ORDER = ['dev-plans', 'performance', 'videos']; +const RECRUITING_ORDER = ['pipeline', 'discover', 'scouting']; const ACADEMICS_ORDER = ['academics']; -const MANAGEMENT_ORDER = [ - 'staff-decision-room', - 'program', - 'staff-settings', - 'program-settings', - 'organization', - 'teams', - 'events', -]; +const MANAGEMENT_ORDER = ['staff-decision-room', 'settings', 'organization']; // ----------------------------------------------------------------------------- // COACH HUBS — every array below is registry-derived (see the module header). // ----------------------------------------------------------------------------- -/** DASHBOARD hub — Command Center + Signals (folded from two flat top-level tabs). */ +/** DASHBOARD hub — Overview (command-center) + Signals. */ export const COACH_DASHBOARD_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('dashboard'), DASHBOARD_ORDER, ); -/** TEAM hub — roster + day-to-day team operations. */ +/** TEAM hub — Roster · Calendar · Operations (Documents/Travel/Practice Planner/ + * Practice Effectiveness fold into the Operations landing, Ruling 2). */ export const COACH_TEAM_TABS: readonly HubSubNavTab[] = orderTabs(hubEntries('team'), TEAM_ORDER); /** - * STATS & PERFORMANCE hub — team-wide stats depth, game logs, season, practice - * intelligence, and lifting/readiness, folded into one hub per the proposal - * (previously Practice Planner/Effectiveness, Postgame Review, and Import - * Center had no hub-tab entry anywhere — an unreachable-feature bug this fixes). + * MESSAGES hub — Messages · Announcements (Ruling 2: Announcements moves off + * Team — comms belong with comms). The Messages tab itself is the persistent + * cross-cutting entry (BASEBALL_MESSAGES_NAV, deliberately kept outside the + * feature registry — see nav-registry.ts) rendered as the FIRST tab; + * Announcements is registry-derived like every other hub. + */ +const MESSAGES_TAB: HubSubNavTab = { + id: BASEBALL_MESSAGES_NAV.id, + label: BASEBALL_MESSAGES_NAV.label, + href: BASEBALL_MESSAGES_NAV.href, + icon: BASEBALL_MESSAGES_NAV.icon, +}; + +export const COACH_MESSAGES_TABS: readonly HubSubNavTab[] = [ + MESSAGES_TAB, + ...orderTabs(hubEntries('messages'), MESSAGES_ORDER), +]; + +/** + * STATS & PERFORMANCE hub — Stats Center · Games · Postgame (Ruling 2: Season + * is a view inside Stats Center, Upload + Import Center are header CTAs + * there; Practice Planner/Effectiveness moved to Team>Operations; Performance + * moved to Development>Training). */ export const COACH_STATS_TABS: readonly HubSubNavTab[] = withSupplements( orderTabs(hubEntries('stats-performance'), STATS_PERFORMANCE_ORDER), - { - 'stats-center': [STATS_GAMES_TAB, STATS_SEASON_TAB, STATS_UPLOAD_TAB], - performance: [PERFORMANCE_LIVE_TAB, PERFORMANCE_PROGRAMS_TAB, PERFORMANCE_GROUPS_TAB, PERFORMANCE_BUILDER_TAB], - }, + { 'stats-center': [STATS_GAMES_TAB] }, ); -/** DEVELOPMENT hub — dev plans + video library. */ +/** DEVELOPMENT hub — Dev Plans · Training (performance) · Videos. */ export const COACH_DEVELOPMENT_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('development'), DEVELOPMENT_ORDER, ); /** - * RECRUITING hub — pipeline, discovery, comparisons, scout packets, camps. - * Gated to RECRUITING_PROGRAM_TYPES by the sidebar/resolve-active-hub, hidden - * entirely for High School. Fixed by this pass: `import` (misplaced here - * previously) moved to Stats & Performance; `college-interest` (a coach-facing - * interest dashboard) stays out of player nav; `comparisons` and `camps` - * (previously unreachable) added. + * RECRUITING hub — Pipeline · Discover · Scouting (Ruling 2: Watchlist, + * Compare, Saved Comparisons, Scout Packets, and Camps fold into the new + * Scouting landing; Interest folds into Pipeline). Gated to + * RECRUITING_PROGRAM_TYPES by the sidebar/resolve-active-hub, hidden entirely + * for High School. */ export const COACH_RECRUITING_TABS: readonly HubSubNavTab[] = orderTabs( hubEntries('recruiting'), @@ -331,43 +234,17 @@ export const COACH_RECRUITING_TABS: readonly HubSubNavTab[] = orderTabs( */ export const COACH_ACADEMICS_TABS: readonly HubSubNavTab[] = orderTabs(hubEntries('academics'), ACADEMICS_ORDER); -const MANAGEMENT_SETTINGS_SUPPLEMENT_ID = 'program-settings'; - -/** Dev-only guard: settings supplement tabs must attach to a real registry row. */ -function assertManagementSettingsSupplement(tabs: readonly HubSubNavTab[]): void { - if (process.env.NODE_ENV === 'production') return; - if (!tabs.some((tab) => tab.id === MANAGEMENT_SETTINGS_SUPPLEMENT_ID)) { - throw new Error( - `COACH_MANAGEMENT_TABS settings supplement requires registry tab "${MANAGEMENT_SETTINGS_SUPPLEMENT_ID}".`, - ); - } -} - /** - * MANAGEMENT hub — staff coordination, program settings, and (Showcase/Academy/ - * Club only, via allowedProgramTypes carried through verbatim from the - * registry) org-level Organization/Teams/Events. Fixed by this pass: the - * "Decision Room" vs "Staff Room" label drift (the registry's label always - * wins now — it is read, not re-declared). + * MANAGEMENT hub — Decision Room · Settings · Organization (Ruling 2: DELETED + * the 9-item settings-route splice — the existing card-grid landing at + * /dashboard/settings is now the single settings nav surface; Program + * Info/Staff Settings/Program Settings fold into it. Organization/Teams/ + * Events fold under Organization for Showcase/Academy/Club program types + * only, via allowedProgramTypes carried through verbatim from the registry). */ -const managementHubTabs = orderTabs(hubEntries('management'), MANAGEMENT_ORDER); -assertManagementSettingsSupplement(managementHubTabs); - -export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = withSupplements( - managementHubTabs, - { - [MANAGEMENT_SETTINGS_SUPPLEMENT_ID]: [ - SETTINGS_HOME_TAB, - SETTINGS_SEASON_TAB, - SETTINGS_PHILOSOPHY_TAB, - SETTINGS_ROLES_TAB, - SETTINGS_PERMISSIONS_TAB, - SETTINGS_TEAMS_TAB, - SETTINGS_IMPORTS_TAB, - SETTINGS_INTEGRATIONS_TAB, - SETTINGS_AUDIT_TAB, - ], - }, +export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = orderTabs( + hubEntries('management'), + MANAGEMENT_ORDER, ); // ----------------------------------------------------------------------------- @@ -383,10 +260,11 @@ export interface CoachHubDef { tabs: readonly HubSubNavTab[]; } -/** Display order of the 7 registry-backed coach hubs (Messages is the 8th tab, - * a persistent cross-cutting slot outside this grouping — see nav-registry.ts). */ +/** Display order of the 8 registry-backed coach hubs (Ruling 2 promotes + * Messages from a bare flat link to a real hub with its own sub-nav). */ export const COACH_HUB_ORDER: readonly BaseballNavHub[] = [ 'dashboard', + 'messages', 'team', 'stats-performance', 'development', @@ -397,6 +275,7 @@ export const COACH_HUB_ORDER: readonly BaseballNavHub[] = [ export const COACH_HUB_DEFS: Readonly> = { dashboard: { id: 'dashboard', label: 'Dashboard', icon: IconHome, tabs: COACH_DASHBOARD_TABS }, + messages: { id: 'messages', label: 'Messages', icon: IconMessage, tabs: COACH_MESSAGES_TABS }, team: { id: 'team', label: 'Team', icon: IconUsers, tabs: COACH_TEAM_TABS }, 'stats-performance': { id: 'stats-performance', @@ -411,8 +290,12 @@ export const COACH_HUB_DEFS: Readonly> = { }; // ----------------------------------------------------------------------------- -// PLAYER HUBS — unchanged by this pass (see the module header: most player-only -// registry entries carry no `hub`, so these stay hand-maintained). +// PLAYER HUBS — unchanged mechanism (see the module header: most player-only +// registry entries carry no `hub`, so these stay hand-maintained), trimmed to +// the same ≤3 cap (Ruling 2 item 7). Every dropped tab below KEEPS its own +// registry row (still command-palette + deep-link reachable) and, for +// Lifts/Readiness specifically, is also surfaced inline on Player Today — +// so nothing is orphaned, only de-duplicated off the sub-nav strip. // ----------------------------------------------------------------------------- /** Player STATS hub — own stats depth + game/season views. */ @@ -420,14 +303,17 @@ export const PLAYER_STATS_TABS: readonly HubSubNavTab[] = [ { id: 'overview', label: 'Overview', href: '/baseball/dashboard/my-stats', icon: IconChartBar }, ]; -/** Player DEVELOPMENT hub — own dev plan, training, proof packet, and video library. */ +/** + * Player DEVELOPMENT hub — Dev Plan · Practice · Passport. Lifts and + * Readiness are already surfaced inline on Player Today's daily card + * (PlayerLiftToday) and remain reachable via their own registry entries + * (player-lift, player-readiness); Videos remains reachable via its own + * registry entry (role: 'both'). + */ export const PLAYER_DEVELOPMENT_TABS: readonly HubSubNavTab[] = [ { id: 'dev-plan', label: 'Dev Plan', href: '/baseball/dashboard/dev-plan', icon: IconTarget }, { id: 'practice', label: 'Practice', href: '/baseball/player/practice', icon: IconClipboardList }, - { id: 'lifts', label: 'Lifts', href: '/baseball/dashboard/lift', icon: IconDumbbell, matchPrefixes: ['/baseball/dashboard/lift'] }, - { id: 'readiness', label: 'Readiness', href: '/baseball/dashboard/readiness', icon: IconGauge }, { id: 'passport', label: 'Passport', href: '/baseball/player/passport', icon: IconShieldCheck }, - { id: 'videos', label: 'Videos', href: '/baseball/dashboard/videos', icon: IconVideo, matchPrefixes: ['/baseball/dashboard/videos'] }, ]; /** Player TEAM hub — shared team surfaces a player reads. */ @@ -437,12 +323,16 @@ export const PLAYER_TEAM_TABS: readonly HubSubNavTab[] = [ { id: 'documents', label: 'Documents', href: '/baseball/dashboard/documents', icon: IconFileText }, ]; -/** Player RECRUITING hub — player-owned exposure and college discovery surfaces. */ +/** + * Player RECRUITING hub — Journey · Colleges · Analytics. Activate Recruiting + * remains reachable via its own registry entry (player-activate, section: + * 'secondary') — command palette + direct URL — it is a one-time opt-in + * action, not a recurring destination worth a permanent tab slot. + */ export const PLAYER_RECRUITING_TABS: readonly HubSubNavTab[] = [ { id: 'journey', label: 'Journey', href: '/baseball/dashboard/journey', icon: IconStar }, { id: 'colleges', label: 'Colleges', href: '/baseball/dashboard/colleges', icon: IconGraduationCap }, { id: 'analytics', label: 'Analytics', href: '/baseball/dashboard/analytics', icon: IconTrendingUp }, - { id: 'activate', label: 'Activate', href: '/baseball/dashboard/activate', icon: IconShieldCheck }, ]; // ----------------------------------------------------------------------------- @@ -452,6 +342,7 @@ export const PLAYER_RECRUITING_TABS: readonly HubSubNavTab[] = [ export const HUB_LANDING = { coachDashboard: COACH_DASHBOARD_TABS[0]!.href, + coachMessages: COACH_MESSAGES_TABS[0]!.href, coachTeam: COACH_TEAM_TABS[0]!.href, coachStats: COACH_STATS_TABS[0]!.href, coachDevelopment: COACH_DEVELOPMENT_TABS[0]!.href, diff --git a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts index b606cd224..205fa6be6 100644 --- a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts +++ b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts @@ -51,6 +51,7 @@ export { RECRUITING_PROGRAM_TYPES }; */ const RESOLVE_HUB_ID: Readonly> = { dashboard: 'dashboard', + messages: 'messages', team: 'team', 'stats-performance': 'stats', development: 'development', diff --git a/src/app/baseball/(dashboard)/dashboard/practice/page.tsx b/src/app/baseball/(dashboard)/dashboard/practice/page.tsx index f0f931e8a..54e153862 100644 --- a/src/app/baseball/(dashboard)/dashboard/practice/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/practice/page.tsx @@ -1,3 +1,6 @@ +import { redirect } from 'next/navigation'; + +import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import { PracticePlannerClient } from '@/components/baseball/practice-planner/PracticePlannerClient'; import { fairwayScope } from '@/lib/redesign/flag'; @@ -17,8 +20,20 @@ export const metadata = { * `isRedesignEnabled` fork (the shell flag already gates the whole subtree, * per the command-center precedent), always the kit version, wrapped in the * `.fairway-ds` scope exactly like `command-center/page.tsx`. + * + * COHERENCE_RULING_2026-07-08 Ruling 2 (item 7): this route is the COACH + * planning surface; the nav already sends players to their canonical + * `/baseball/player/practice` view (nav-registry.ts's `practice-planner` + * playerHref). A player landing here directly (bookmark, typed URL) is + * server-redirected to that canonical surface instead — staff/coach + * rendering below is unchanged. */ -export default function BaseballPracticePage() { +export default async function BaseballPracticePage() { + const context = await getActiveBaseballContext(); + if (context?.activeRole === 'player') { + redirect('/baseball/player/practice'); + } + return (
diff --git a/src/lib/baseball/nav-manifest.ts b/src/lib/baseball/nav-manifest.ts index b46f2f942..de45c893c 100644 --- a/src/lib/baseball/nav-manifest.ts +++ b/src/lib/baseball/nav-manifest.ts @@ -52,6 +52,8 @@ import { BASEBALL_NAV_REGISTRY, BASEBALL_MESSAGES_NAV } from './nav-registry'; import type { BaseballCapability } from './capabilities'; import { BASEBALL_SETTINGS_ALIASES } from './settings-route-aliases'; import { + COACH_DASHBOARD_TABS, + COACH_MESSAGES_TABS, COACH_TEAM_TABS, COACH_STATS_TABS, COACH_DEVELOPMENT_TABS, @@ -140,6 +142,8 @@ registryEntries.push({ // ----------------------------------------------------------------------------- const ALL_HUB_TAB_ARRAYS: readonly (readonly HubSubNavTab[])[] = [ + COACH_DASHBOARD_TABS, + COACH_MESSAGES_TABS, COACH_TEAM_TABS, COACH_STATS_TABS, COACH_DEVELOPMENT_TABS, diff --git a/src/lib/baseball/nav-registry.ts b/src/lib/baseball/nav-registry.ts index 75159711f..1923ffa43 100644 --- a/src/lib/baseball/nav-registry.ts +++ b/src/lib/baseball/nav-registry.ts @@ -68,6 +68,7 @@ import { IconBolt, IconFlag, IconMapPin, + IconEye, } from '@/components/icons'; // ----------------------------------------------------------------------------- @@ -82,16 +83,22 @@ export type BaseballNavRole = ActiveBaseballRole | 'both'; /** * The 8 top-level GROUPED HUBS the coach sidebar condenses ~32 destinations - * into (COACH_NAV_8TAB_PROPOSAL.md, approved 2026-07-01). 'messages' is - * deliberately absent — Messages lives outside the registry as a persistent - * cross-cutting slot (see BASEBALL_MESSAGES_NAV below), so it is never a - * grouping target here. Every coach/both registry entry below declares - * exactly one of these (nav-manifest.test.ts asserts it), so hub-definitions.ts - * can GROUP BY this field instead of hand-listing routes per hub. + * into (COACH_NAV_8TAB_PROPOSAL.md, approved 2026-07-01; hub/subtab caps + * finalized in COHERENCE_RULING_2026-07-08.md Ruling 2). 'messages' is a real + * grouping target — the persistent Messages surface itself is STILL declared + * outside the registry (see BASEBALL_MESSAGES_NAV below, the cross-cutting + * "Messages" tab every consumer prepends), but Announcements folds into this + * hub (moved off Team — comms belong with comms) so hub-definitions.ts can + * derive a real "Messages · Announcements" sub-nav instead of rendering + * Messages as a bare flat link with no sub-tab strip. Every coach/both + * registry entry below declares exactly one of these (nav-manifest.test.ts + * asserts it), so hub-definitions.ts can GROUP BY this field instead of + * hand-listing routes per hub. */ export type BaseballNavHub = | 'dashboard' | 'team' + | 'messages' | 'stats-performance' | 'development' | 'recruiting' @@ -144,7 +151,11 @@ export type BaseballNavId = | 'player-readiness' | 'teams' | 'player-activate' - | 'team'; + | 'team' + // --- New hub-landing pages (COHERENCE_RULING_2026-07-08 Ruling 2) --- + | 'operations' + | 'scouting' + | 'settings'; /** * Icon contract shared by sidebar (``) and @@ -236,6 +247,29 @@ export interface BaseballNavEntry { * detail routes (e.g. /players/[id]) highlight the parent registry tab. */ matchPrefixes?: readonly string[]; + /** + * COHERENCE_RULING_2026-07-08 Ruling 2 — the "≤3 subtabs per hub" mechanism. + * When set, this entry is a FOLDED destination: hub-definitions.ts's + * `hubEntries()` excludes it from the hub's RENDERED sub-tab strip (so the + * hub never exceeds 3 tabs), but the entry keeps its own registry row — + * command palette, breadcrumbs, and deep links still resolve it normally. + * The value is the id of the sibling registry entry that acts as this + * entry's LANDING/parent tab (e.g. a Documents/Travel/Practice Planner row + * folds under the 'operations' landing). The parent entry's own + * `matchPrefixes` must list this entry's `href` so resolve-active-hub still + * highlights the right hub + subtab when a user is on the folded route + * (nav-manifest.test.ts asserts this pairing). + */ + foldedUnder?: BaseballNavId; + /** + * Optional override for how this entry's label reads INSIDE a hub sub-nav + * strip specifically, when the canonical `label` (used everywhere else — + * breadcrumbs, command palette, mobile nav) reads better unchanged. Used by + * Ruling 2's Development hub, where the "Performance" feature is folded in + * as the hub's "Training" subtab without renaming the feature itself + * everywhere it's referenced. + */ + hubTabLabel?: string; } /** @@ -288,7 +322,13 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ // --- Daily loops (no capability gate beyond role) ---------------------------- { id: 'command-center', - label: 'Command Center', + // Ruling 2 (item 9): the top-level hub is already labeled "Dashboard" — + // a subtab/breadcrumb ALSO reading "Command Center" is drift the founder + // called out by name. The route/id/page masthead keep "Command Center" + // (the page's own distinctive editorial title); only the NAV-visible + // label (breadcrumbs, hub subtab strip, command palette, mobile nav) + // changes to "Overview" so "Dashboard" never has two names in the UI. + label: 'Overview', href: '/baseball/dashboard/command-center', icon: IconLayers, role: 'coach', @@ -434,6 +474,14 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'stats-performance', + // Ruling 2: Season is a VIEW inside Stats Center (no longer its own + // subtab), and Upload + Import Center are CTAs on the Stats Center page + // header — so all three routes must still resolve to this hub + subtab. + matchPrefixes: [ + '/baseball/dashboard/stats/season', + '/baseball/dashboard/stats/upload', + '/baseball/dashboard/import', + ], }, // --- Coach feature verticals (capability-gated) ------------------------------ @@ -446,6 +494,8 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_manage_imports', section: 'primary', hub: 'stats-performance', + // Ruling 2: a CTA inside Stats Center, not its own subtab. + foldedUnder: 'stats-center', }, { id: 'practice-planner', @@ -458,7 +508,12 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ // Coaches need can_manage_practice to plan; players see the published plan. requiredCapability: null, section: 'primary', - hub: 'stats-performance', + // Ruling 2: Practice Planner + Practice Effectiveness moved OFF + // Stats & Performance (which caps at Stats Center/Games/Postgame) and + // fold into the Team hub's new Operations landing alongside + // Documents/Travel — all four are team-logistics surfaces, not stats. + hub: 'team', + foldedUnder: 'operations', }, { id: 'practice-effectiveness', @@ -471,7 +526,8 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ // so this is gated on can_manage_practice (the read model enforces it too). requiredCapability: 'can_manage_practice', section: 'primary', - hub: 'stats-performance', + hub: 'team', + foldedUnder: 'operations', }, { id: 'postgame-review', @@ -489,6 +545,12 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ { id: 'performance', label: 'Performance', + // Ruling 2: Development hub reads "Dev Plans · Training · Videos" — this + // feature IS "Training" in that strip. The canonical label stays + // "Performance" everywhere else (breadcrumbs, command palette, the + // page's own masthead already titled "Performance") — only the hub + // sub-nav tab renders the reframed name. + hubTabLabel: 'Training', href: '/baseball/dashboard/performance', icon: IconDumbbell, role: 'coach', @@ -496,7 +558,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, requiredAnyCapabilities: ['can_manage_lifting', 'can_view_readiness'], section: 'primary', - hub: 'stats-performance', + hub: 'development', matchPrefixes: ['/baseball/dashboard/performance'], }, { @@ -523,6 +585,10 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_invite_staff', section: 'secondary', hub: 'management', + // Ruling 2: folds into the Settings landing (card grid at + // /dashboard/settings already links here) — Management caps at + // Decision Room/Settings/Organization. + foldedUnder: 'settings', }, { id: 'program-settings', @@ -533,6 +599,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_manage_settings', section: 'secondary', hub: 'management', + foldedUnder: 'settings', }, // ----------------------------------------------------------------------- @@ -559,6 +626,11 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'recruiting', + // Ruling 2: Interest is a coach-facing recruiting-funnel view — closest + // conceptual home is Pipeline (not explicitly named in the ruling's + // Recruiting row; smallest-safe-choice fold, still command-palette + + // deep-link reachable). + matchPrefixes: ['/baseball/dashboard/college-interest'], }, { id: 'college-interest', @@ -572,6 +644,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'recruiting', + foldedUnder: 'pipeline', }, { id: 'discover', @@ -595,6 +668,9 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'recruiting', + // Ruling 2: folds into the new Scouting landing (Recruiting caps at + // Pipeline/Discover/Scouting). + foldedUnder: 'scouting', }, { id: 'compare', @@ -607,6 +683,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_manage_roster', section: 'primary', hub: 'recruiting', + foldedUnder: 'scouting', }, { id: 'comparisons', @@ -618,6 +695,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_manage_roster', section: 'primary', hub: 'recruiting', + foldedUnder: 'scouting', }, { id: 'scout-packets', @@ -631,6 +709,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_export_reports', section: 'primary', hub: 'recruiting', + foldedUnder: 'scouting', }, { id: 'dev-plans', @@ -671,6 +750,29 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ section: 'primary', hub: 'recruiting', matchPrefixes: ['/baseball/dashboard/camps'], + foldedUnder: 'scouting', + }, + { + // Ruling 2 (Recruiting hub): the new landing page that keeps Watchlist, + // Compare Players, Saved Comparisons, Scout Packets, and Camps reachable + // once Recruiting caps at 3 rendered subtabs (Pipeline · Discover · + // Scouting). Card-grid landing (dashboard/scouting) — each card inherits + // its target's own registry gating, never re-declares it. + id: 'scouting', + label: 'Scouting', + href: '/baseball/dashboard/scouting', + icon: IconEye, + role: 'coach', + requiredCapability: null, + section: 'primary', + hub: 'recruiting', + matchPrefixes: [ + '/baseball/dashboard/watchlist', + '/baseball/dashboard/compare', + '/baseball/dashboard/comparisons', + '/baseball/dashboard/scout-packets', + '/baseball/dashboard/camps', + ], }, { id: 'organization', @@ -685,6 +787,11 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ allowedProgramTypes: SHOWCASE_ORG_PROGRAM_TYPES, section: 'secondary', hub: 'management', + // Ruling 2: Organization is the rendered Management subtab; Teams + + // Events fold under it for Showcase-types (both already share this + // entry's allowedProgramTypes gate), so both routes still highlight the + // Organization subtab when visited directly. + matchPrefixes: ['/baseball/dashboard/teams', '/baseball/dashboard/events'], }, { id: 'teams', @@ -698,6 +805,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ allowedProgramTypes: SHOWCASE_ORG_PROGRAM_TYPES, section: 'secondary', hub: 'management', + foldedUnder: 'organization', }, { id: 'program', @@ -711,6 +819,29 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: 'can_manage_settings', section: 'secondary', hub: 'management', + foldedUnder: 'settings', + }, + { + // Ruling 2 (Management hub): the existing card-grid landing at + // /dashboard/settings (KEPT, per the ruling — its own 13-tab strip is + // gone by design now that Management caps at 3 subtabs: Decision Room · + // Settings · Organization). Program Info, Staff Settings, and Program + // Settings fold into this one Settings destination; the card grid + // already links every one of them (verified, nothing orphaned). + id: 'settings', + label: 'Settings', + href: '/baseball/dashboard/settings', + icon: IconSettings, + role: 'coach', + requiredCapability: null, + section: 'secondary', + hub: 'management', + // '/baseball/dashboard/settings/*' sub-routes (staff, program, season, + // philosophy, roles, permissions, imports, integrations, audit) resolve + // via this entry's own href as a natural route PREFIX — only the + // 'program' fold target needs an explicit prefix since its href is NOT + // nested under /settings/. + matchPrefixes: ['/baseball/dashboard/program'], }, // --- Shared team surfaces (coach + player) -------------------------------- @@ -725,7 +856,9 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ role: 'both', requiredCapability: null, section: 'primary', - hub: 'team', + // Ruling 2: moved OFF Team — announcements are comms, so they fold into + // the Messages hub (Messages · Announcements) instead. + hub: 'messages', }, { id: 'documents', @@ -738,6 +871,9 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'team', + // Ruling 2: Team hub caps at Roster/Calendar/Operations — Documents folds + // into the new Operations landing. + foldedUnder: 'operations', }, { id: 'travel', @@ -750,6 +886,28 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'team', + foldedUnder: 'operations', + }, + { + // Ruling 2 (Team hub): the new landing page that keeps Documents, Travel, + // Practice Planner, and Practice Effectiveness reachable in ≤2 clicks + // once Team caps at 3 rendered subtabs (Roster · Calendar · Operations). + // Card-grid landing (src/app/baseball/(dashboard)/dashboard/operations) — + // each card inherits its target's own registry gating, never re-declares it. + id: 'operations', + label: 'Operations', + href: '/baseball/dashboard/operations', + icon: IconClipboardList, + role: 'coach', + requiredCapability: null, + section: 'primary', + hub: 'team', + matchPrefixes: [ + '/baseball/dashboard/documents', + '/baseball/dashboard/travel', + '/baseball/dashboard/practice', + '/baseball/dashboard/practice-effectiveness', + ], }, { id: 'videos', @@ -776,6 +934,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ allowedProgramTypes: SHOWCASE_ORG_PROGRAM_TYPES, section: 'primary', hub: 'management', + foldedUnder: 'organization', }, { id: 'team', diff --git a/src/lib/baseball/route-contract.ts b/src/lib/baseball/route-contract.ts index d5f8de066..69d84e4f6 100644 --- a/src/lib/baseball/route-contract.ts +++ b/src/lib/baseball/route-contract.ts @@ -32,6 +32,8 @@ import { } from './nav-registry'; import type { BaseballCapability } from './capabilities'; import { + COACH_DASHBOARD_TABS, + COACH_MESSAGES_TABS, COACH_TEAM_TABS, COACH_STATS_TABS, COACH_DEVELOPMENT_TABS, @@ -51,6 +53,8 @@ import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-su /** Which hub a tab-origin declared href came from. */ export type HubName = + | 'coach-dashboard' + | 'coach-messages' | 'coach-team' | 'coach-stats' | 'coach-development' @@ -89,6 +93,8 @@ const HUB_TAB_SOURCES: ReadonlyArray<{ role: 'coach' | 'player'; tabs: readonly HubSubNavTab[]; }> = [ + { hub: 'coach-dashboard', role: 'coach', tabs: COACH_DASHBOARD_TABS }, + { hub: 'coach-messages', role: 'coach', tabs: COACH_MESSAGES_TABS }, { hub: 'coach-team', role: 'coach', tabs: COACH_TEAM_TABS }, { hub: 'coach-stats', role: 'coach', tabs: COACH_STATS_TABS }, { hub: 'coach-development', role: 'coach', tabs: COACH_DEVELOPMENT_TABS }, From 01adf356ee32667de0b0b56e4268e2347b8e7d3d Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 8 Jul 2026 02:05:09 -0400 Subject: [PATCH 06/19] feat(baseball): add Operations + Scouting hub-landing pages (Ruling 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New card-grid landing pages that keep the folded destinations reachable in ≤2 clicks now that Team and Recruiting cap at 3 rendered subtabs: - /baseball/dashboard/operations (Team hub): Documents, Travel, Practice Planner, Practice Effectiveness. - /baseball/dashboard/scouting (Recruiting hub): Watchlist, Compare Players, Saved Comparisons, Scout Packets, Camps. Both are server components (no hooks) built from the Living Annual kit (SectionMasthead/PaperCard/Eyebrow). Neither re-declares capability or program-type gating — each card is resolved via getBaseballNavEntry + isBaseballNavEntryVisible against the SAME registry entry the sidebar and command palette use, so a coach without a capability simply doesn't see that card, exactly like they wouldn't see it in the sidebar. Operations uses the green "team" ink lane; Scouting uses the clay "pursuit" ink lane (recruiting). Also adds the Stats Center masthead's persistent header-level actions (Upload, Import Center) per Ruling 2 item 2 — Upload + Import Center are now CTAs on the Stats Center page instead of their own hub-tab slots; the existing empty-state "Import a box score" CTA is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGqvhig8njW38q9PAwDXgu --- .../(dashboard)/dashboard/operations/page.tsx | 117 +++++++++++++++++ .../(dashboard)/dashboard/scouting/page.tsx | 122 ++++++++++++++++++ .../stats-center/StatsCenterClient.tsx | 23 +++- 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/app/baseball/(dashboard)/dashboard/operations/page.tsx create mode 100644 src/app/baseball/(dashboard)/dashboard/scouting/page.tsx diff --git a/src/app/baseball/(dashboard)/dashboard/operations/page.tsx b/src/app/baseball/(dashboard)/dashboard/operations/page.tsx new file mode 100644 index 000000000..ac588c847 --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/operations/page.tsx @@ -0,0 +1,117 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/operations/page.tsx +// +// COHERENCE_RULING_2026-07-08 Ruling 2 — Team hub's new "Operations" landing. +// +// The Team hub caps at 3 rendered subtabs (Roster · Calendar · Operations), +// so Documents, Travel, Practice Planner, and Practice Effectiveness — all +// team-logistics surfaces, not stats — fold in here as a card grid instead of +// each keeping its own subtab slot. Every route the grid links to keeps its +// existing URL and its own registry gating (nav-registry.ts); this page never +// re-declares that gating — it reads the SAME registry entries and reuses +// isBaseballNavEntryVisible so a coach without can_manage_practice, for +// example, simply doesn't see the Practice Effectiveness card, exactly like +// they wouldn't see it in the sidebar. +// +// Server component, no hooks — the Living Annual kit's SectionMasthead / +// PaperCard / Eyebrow are all server-safe. +// ============================================================================= + +import Link from 'next/link'; + +import { requireBaseballCoachRoute } from '@/lib/baseball/server-route-guards'; +import { getBaseballNavContext } from '@/lib/baseball/nav-context'; +import { + getBaseballNavEntry, + isBaseballNavEntryVisible, + type BaseballNavContext, + type BaseballNavEntry, + type BaseballNavId, +} from '@/lib/baseball/nav-registry'; +import { SectionMasthead, PaperCard, Eyebrow } from '@/components/baseball/living-annual'; +import { IconChevronRight } from '@/components/icons'; + +export const metadata = { + title: 'Operations · BaseballHelm', +}; + +const OPERATIONS_CARD_IDS: readonly { + id: BaseballNavId; + description: string; +}[] = [ + { + id: 'documents', + description: 'The team file library — playbooks, forms, and anything the roster needs on hand.', + }, + { + id: 'travel', + description: 'Trip itineraries for every away game or showcase — lodging, transport, and timing.', + }, + { + id: 'practice-planner', + description: 'Build and publish the practice schedule the roster sees on their own Practice tab.', + }, + { + id: 'practice-effectiveness', + description: 'Did practice transfer to performance? The staff-only read on what actually worked.', + }, +]; + +export default async function OperationsPage() { + await requireBaseballCoachRoute(); + + const navContext = (await getBaseballNavContext()) ?? ({ role: 'coach', capabilities: {} } as BaseballNavContext); + + const cards = OPERATIONS_CARD_IDS.map(({ id, description }) => { + const entry = getBaseballNavEntry(id); + return entry && isBaseballNavEntryVisible(entry, navContext) ? { entry, description } : null; + }).filter((card): card is { entry: BaseballNavEntry; description: string } => Boolean(card)); + + return ( +
+ +

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

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

{entry.label}

+

{description}

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

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

+
+ )} +
+ ); +} diff --git a/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx b/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx new file mode 100644 index 000000000..128f544dd --- /dev/null +++ b/src/app/baseball/(dashboard)/dashboard/scouting/page.tsx @@ -0,0 +1,122 @@ +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/scouting/page.tsx +// +// COHERENCE_RULING_2026-07-08 Ruling 2 — Recruiting hub's new "Scouting" +// landing. +// +// The Recruiting hub caps at 3 rendered subtabs (Pipeline · Discover · +// Scouting), so Watchlist, Compare Players, Saved Comparisons, Scout +// Packets, and Camps — the evaluation/export tools, not the funnel itself — +// fold in here as a card grid. Every route keeps its existing URL and its own +// registry gating (nav-registry.ts); this page never re-declares that +// gating — it reads the SAME registry entries and reuses +// isBaseballNavEntryVisible so a coach without can_manage_roster, for +// example, simply doesn't see the Compare/Saved Comparisons cards. +// +// Server component, no hooks — the Living Annual kit's SectionMasthead / +// PaperCard / Eyebrow are all server-safe. Ink is `pursuit` (clay) — this is +// THE WAR ROOM lane (recruiting), never green. +// ============================================================================= + +import Link from 'next/link'; + +import { requireRecruitingCoachRoute } from '@/lib/baseball/server-route-guards'; +import { getBaseballNavContext } from '@/lib/baseball/nav-context'; +import { + getBaseballNavEntry, + isBaseballNavEntryVisible, + type BaseballNavContext, + type BaseballNavEntry, + type BaseballNavId, +} from '@/lib/baseball/nav-registry'; +import { SectionMasthead, PaperCard, Eyebrow } from '@/components/baseball/living-annual'; +import { IconChevronRight } from '@/components/icons'; + +export const metadata = { + title: 'Scouting · BaseballHelm', +}; + +const SCOUTING_CARD_IDS: readonly { + id: BaseballNavId; + description: string; +}[] = [ + { + id: 'watchlist', + description: 'The saved shortlist — every prospect you’re actively tracking, in one place.', + }, + { + id: 'compare', + description: 'Side-by-side player comparison across the metrics that matter to your program.', + }, + { + id: 'comparisons', + description: 'Saved comparison sets you can return to without rebuilding the matchup.', + }, + { + id: 'scout-packets', + description: 'Export a showcase event roster into a shareable scout packet.', + }, + { + id: 'camps', + description: 'Create and manage your program’s camp listings.', + }, +]; + +export default async function ScoutingPage() { + await requireRecruitingCoachRoute(); + + const navContext = (await getBaseballNavContext()) ?? ({ role: 'coach', capabilities: {} } as BaseballNavContext); + + const cards = SCOUTING_CARD_IDS.map(({ id, description }) => { + const entry = getBaseballNavEntry(id); + return entry && isBaseballNavEntryVisible(entry, navContext) ? { entry, description } : null; + }).filter((card): card is { entry: BaseballNavEntry; description: string } => Boolean(card)); + + return ( +
+ +

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

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

{entry.label}

+

{description}

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

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

+
+ )} +
+ ); +} diff --git a/src/components/baseball/stats-center/StatsCenterClient.tsx b/src/components/baseball/stats-center/StatsCenterClient.tsx index 61f6e408e..135f3ab23 100644 --- a/src/components/baseball/stats-center/StatsCenterClient.tsx +++ b/src/components/baseball/stats-center/StatsCenterClient.tsx @@ -28,7 +28,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'; import { Button } from '@/components/ui/button'; -import { IconDownload, IconFilter, IconX } from '@/components/icons'; +import { IconDownload, IconFilter, IconUpload, IconX } from '@/components/icons'; import { cn } from '@/lib/utils'; import { loadStatsCenter } from '@/app/baseball/actions/games'; // V10 stat-visual chart gallery (stat-visuals packet). Mounted at team scope; it @@ -543,6 +543,27 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis const mastheadActions = (
+ {/* Ruling 2 (item 2): Upload + Import Center folded off the hub sub-nav + strip into persistent header-level actions here — Stats Center is + now the ONLY place they're reachable from the Stats & Performance + hub (the empty-state "Import a box score" CTA below stays too, for + the exact moment it's most useful). */} + + -
-
-
- - {/* ===== Body: grid + right rail ===== */} -
- {/* Athlete grid */} -
- {data.athletes.length === 0 ? ( - - - - - - ) : ( -
- - - - - - - - - - {canViewReadiness && } - - - - - {data.athletes.map((a) => { - const isSel = a.session_id === selectedId; - return ( - - - - - - - - {canViewReadiness && ( - - )} - - - - ); - })} - -
AthleteStatusStation / exercisePrescribedActualRPEReadinessUpdated -
-
- {a.needs_coach && ( - - )} -
-
{getFullName(a.first_name, a.last_name)}
-
- {a.primary_position ?? '—'} - {a.group_names[0] ? ` · ${a.group_names[0]}` : ''} -
-
-
-
- -
- {a.completed_exercises}/{a.total_exercises} lifts -
-
-
{a.current_exercise ?? '—'}
-
{a.current_station ?? '—'}
-
- {a.prescribed_load != null ? `${a.prescribed_load} lb` : '—'} - - {a.actual_load != null ? ( - - {a.actual_load} lb - - ) : ( - - )} - - {a.rpe != null ? a.rpe.toFixed(1) : '—'} - {relTime(a.last_update)} - -
-
- )} -
- - {/* ===== Right rail: 4 derived queues (spec L551-556) ===== */} - -
- - {/* ===== Bottom drawer: selected-athlete logger + actions (spec L558-566) ===== */} - {selected && ( - setSelectedId(null)} - runOptimistic={runOptimistic} - applyLoggedSet={applyLoggedSet} - /> - )} -
- ); -} - -// ---------------------------------------------------------------------------- - -function Stat({ label, value, tone }: { label: string; value: string | number; tone?: 'success' | 'error' }) { - const color = tone === 'error' ? 'text-red-700' : tone === 'success' ? 'text-primary-700' : 'text-warm-900'; - return ( -
-
{value}
-
{label}
-
- ); -} - -// Connectivity + offline-buffer indicator. Honest about state: green "Synced" -// when nothing is queued and online; amber "Offline — N saved" while sets are -// buffered locally (they are NOT lost — they replay on reconnect). Clickable to -// force a retry. This is the premium reassurance a dugout-side coach needs. -function SyncPill({ pending, isOnline, onRetry }: { pending: number; isOnline: boolean; onRetry: () => void }) { - if (pending === 0 && isOnline) { - return ( - - - Synced - - ); - } - const label = pending > 0 ? `${pending} set${pending === 1 ? '' : 's'} saved locally` : 'Offline'; - return ( - - ); -} - -interface QueueItem { key: string; name: string; detail: string; athleteId: string | null } - -function QueueCard({ - title, - empty, - tone, - items, - onSelect, -}: { - title: string; - empty: string; - tone: keyof typeof TONE_CLASS; - items: QueueItem[]; - onSelect: (sessionId: string) => void; -}) { - return ( - - -

{title}

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

{empty}

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

{playerName}

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

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

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

No exercises in this session.

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

Select an exercise to log sets.

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

Strength & conditioning

-

Lift

-

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

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

Daily check-in

-

Tell the staff how you feel before training.

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

Today

-
- - {todaysSession ? ( -
-
-

{todaysSession.title ?? 'Lift'}

-

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

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

Upcoming

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

    {s.title ?? 'Lift'}

    -

    {formatDate(s.scheduled_date)}

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

Recent

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

    {s.title ?? 'Lift'}

    -

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

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

Lift complete

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

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

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

{session.title ?? 'Lift'}

-
-

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

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

Complete your daily check-in first.

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

{sectionName}

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

{name}

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

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

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