From db14cc36aaef42b338c4c0c0b2201bcaf192fea3 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Tue, 30 Jun 2026 22:21:56 -0400 Subject: [PATCH 001/186] fix(baseball): align pipeline stages with 5-stage contract + robust DnD (#427, #428, #426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related pipeline-stage-correctness bugs on one surface: - #426: the UI built stage options from PIPELINE_STAGES (7 entries, incl. `contacted`/`campus_visit`) while the server contract (WatchlistSchemas.updateStatus / baseball_pipeline_stage enum) only accepts 5. Selecting a non-contract stage was rejected server-side, but the client never checked the result and refetched as if it worked. Reduced PIPELINE_STAGES to the 5 valid stages (also fixes the kanban grid: lg:grid-cols-7 -> 5), and handleStatusChange / handleBulkStatusChange now check the { success } return, surface an error toast, and leave rows unchanged on failure. - #427: with closestCorners, drag `over.id` is often another card's watchlist UUID, not a column stage — casting it straight to PipelineStage wrote an invalid stage. Now resolve the target stage from the column id, falling back to the hovered card's own stage; any other target is a no-op with no DB write. - #428: the drag handler fired an unconditional success toast even when updateStage returned false (it already shows its own error toast and only refetches on success). Now it reflects the boolean and shows no second toast, so failed drags revert the card without a false success. Adds a schema unit test pinning the 5-stage contract (rejects contacted / campus_visit) so UI and server can't drift apart again. Closes #427 Closes #428 Closes #426 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QuNrg1ugChWiPwerm2g2um --- .../dashboard/pipeline/PipelineClient.tsx | 50 ++++++++++++------- src/lib/recruiting/stages.ts | 16 ++---- src/lib/validation/action-schemas.test.ts | 34 +++++++++++++ 3 files changed, 71 insertions(+), 29 deletions(-) create mode 100644 src/lib/validation/action-schemas.test.ts diff --git a/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx b/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx index a490fc486..b629941de 100644 --- a/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/pipeline/PipelineClient.tsx @@ -42,6 +42,11 @@ const gradYearOptions = [ const statusOptions = PIPELINE_STAGES.map((s) => ({ value: s.id, label: s.label })); +// Droppable columns use the stage id; draggable cards use the watchlist-row +// UUID. This set lets the drag handler tell a real drop-target column apart +// from a card that `closestCorners` happened to land on. +const PIPELINE_STAGE_IDS = new Set(PIPELINE_STAGES.map((s) => s.id)); + const filterTabs = [ { value: 'all', label: 'All' }, ...PIPELINE_STAGES.map((s) => ({ value: s.id, label: s.label })), @@ -243,20 +248,23 @@ export default function PipelinePage() { return; } - const activeItem = filteredByGradYear.find((item) => item.id === active.id); - const newStage = over.id as PipelineStage; - - if (activeItem && activeItem.pipeline_stage !== newStage) { - try { - await updateStage(activeItem.player_id, newStage); - setError(null); - const playerName = getFullName(activeItem.player?.first_name, activeItem.player?.last_name); - const stageLabel = statusOptions.find(o => o.value === newStage)?.label || newStage; - showToast(`${playerName} moved to ${stageLabel}`, 'success'); - } catch (err) { - console.error('Error updating pipeline stage:', err); - setError('Failed to update player stage. Please try again.'); - } + const draggedItem = filteredByGradYear.find((item) => item.id === active.id); + + // With closestCorners, `over` is often another card (a watchlist UUID), + // not a column. Resolve the target stage from the column id, falling back + // to the stage of the card being hovered; anything else is an invalid drop + // and must be a no-op (no DB write). + const overId = over.id as string; + const newStage: PipelineStage | null = PIPELINE_STAGE_IDS.has(overId as PipelineStage) + ? (overId as PipelineStage) + : (filteredByGradYear.find((item) => item.id === overId)?.pipeline_stage ?? null); + + if (draggedItem && newStage && draggedItem.pipeline_stage !== newStage) { + // updateStage shows its own success/error toast and only refetches on + // success (so the card reverts to its column on failure). Just reflect + // the boolean return — never a second, unconditional success toast. + const ok = await updateStage(draggedItem.player_id, newStage); + setError(ok ? null : 'Failed to update player stage. Please try again.'); } setActiveId(null); @@ -265,7 +273,11 @@ export default function PipelinePage() { // Watchlist handlers async function handleStatusChange(watchlistId: string, newStatus: PipelineStage) { try { - await updateWatchlistStatus(watchlistId, newStatus); + const result = await updateWatchlistStatus(watchlistId, newStatus); + if (!result.success) { + showToast(result.error || 'Failed to update status', 'error'); + return; + } refetch(); } catch { showToast('Failed to update status', 'error'); @@ -336,11 +348,15 @@ export default function PipelinePage() { if (selectedPlayers.size === 0) return; try { - await Promise.all( + const results = await Promise.all( Array.from(selectedPlayers).map(watchlistId => updateWatchlistStatus(watchlistId, newStatus) ) ); + const failed = results.filter(r => !r.success).length; + if (failed > 0) { + showToast(`Failed to update ${failed} player${failed !== 1 ? 's' : ''}`, 'error'); + } refetch(); setSelectedPlayers(new Set()); } catch { @@ -547,7 +563,7 @@ export default function PipelinePage() { onDragStart={handleDragStart} onDragEnd={handleDragEnd} > -
+
{PIPELINE_STAGES.map((s) => ( { + const VALID_STAGES = ['watchlist', 'high_priority', 'offer_extended', 'committed', 'uninterested'] as const; + const LEGACY_STAGES = ['contacted', 'campus_visit'] as const; + + it.each(VALID_STAGES)('accepts the contract stage %s', (status) => { + const result = WatchlistSchemas.updateStatus.safeParse({ watchlist_id: VALID_UUID, status }); + expect(result.success).toBe(true); + }); + + it.each(LEGACY_STAGES)('rejects the removed legacy stage %s', (status) => { + const result = WatchlistSchemas.updateStatus.safeParse({ watchlist_id: VALID_UUID, status }); + expect(result.success).toBe(false); + }); + + it('rejects an arbitrary unknown stage', () => { + const result = WatchlistSchemas.updateStatus.safeParse({ watchlist_id: VALID_UUID, status: 'not_a_stage' }); + expect(result.success).toBe(false); + }); +}); From 49b70e8bff46b8597b99a0ced4ca2f3ac8ccef8b Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Tue, 30 Jun 2026 22:24:59 -0400 Subject: [PATCH 002/186] fix(baseball): route pipeline recruits to the public player profile (#425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline kanban cards and the Position Planner quick view linked to /baseball/dashboard/players/[id], which is roster-only — it 404s (notFound) for watchlist/pipeline recruits who aren't on the coach's team roster. Point both at the public profile route /baseball/player/[id], which gates access via resolvePublicProfileAccess() and is the correct surface for external prospects. Closes #425 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QuNrg1ugChWiPwerm2g2um --- src/components/baseball/position-planner/PlayerQuickView.tsx | 2 +- src/components/features/pipeline-card.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/baseball/position-planner/PlayerQuickView.tsx b/src/components/baseball/position-planner/PlayerQuickView.tsx index 330943f3b..3cd0011fa 100644 --- a/src/components/baseball/position-planner/PlayerQuickView.tsx +++ b/src/components/baseball/position-planner/PlayerQuickView.tsx @@ -351,7 +351,7 @@ export function PlayerQuickView({ player, watchlistItem, onClose }: PlayerQuickV 'backdrop-blur-sm' )} > - +
From 481ed003476722b8a9c9e1adf11ce4b529636d07 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 12:54:26 -0400 Subject: [PATCH 019/186] fix(baseball): persist career OBP/SLG/OPS in aggregate recalc (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recalculatePlayerAggregates only wrote career_avg, but My Stats StatsOverviewCards reads career_obp/career_slg/career_ops — columns that never existed on baseball_player_aggregates, so OBP/SLG/OPS permanently showed "---" despite sufficient counting stats. - Add idempotent migration adding career_obp/career_slg/career_ops (numeric) to baseball_player_aggregates (ADD COLUMN IF NOT EXISTS). - Extract a pure computeCareerSlashLine helper (standard formulas, null on zero denominator) and call it from recalculatePlayerAggregates, upserting the derived slash line. - Unit test covers a multi-session aggregate with walks/SF/HBP plus the zero-denominator honesty cases. Migration is committed only, not applied. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/app/baseball/actions/stats.ts | 8 +++ .../__tests__/career-slash-line.test.ts | 61 +++++++++++++++++++ .../baseball/aggregates/career-slash-line.ts | 61 +++++++++++++++++++ ..._baseball_player_aggregates_slash_line.sql | 20 ++++++ 4 files changed, 150 insertions(+) create mode 100644 src/lib/baseball/__tests__/career-slash-line.test.ts create mode 100644 src/lib/baseball/aggregates/career-slash-line.ts create mode 100644 supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql diff --git a/src/app/baseball/actions/stats.ts b/src/app/baseball/actions/stats.ts index 188c2f972..724853798 100644 --- a/src/app/baseball/actions/stats.ts +++ b/src/app/baseball/actions/stats.ts @@ -19,6 +19,7 @@ import { BaseballActionError, } from '@/lib/baseball/with-baseball-action'; import { BaseballCapabilityError, requireBaseballCapability } from '@/lib/baseball/capabilities'; +import { computeCareerSlashLine } from '@/lib/baseball/aggregates/career-slash-line'; /** * Shared error-message mapping for every action in this file. All exported @@ -551,6 +552,10 @@ const recalculatePlayerAggregatesAction = withBaseballAction( const totalAtBats = stats.reduce((sum, s) => sum + (s.at_bats || 0), 0); const totalHits = stats.reduce((sum, s) => sum + (s.hits || 0), 0); + // Slash line — OBP/SLG/OPS from summed counting stats (null on zero + // denominators). Pure helper is unit-tested; see #436. + const { obp: careerObp, slg: careerSlg, ops: careerOps } = computeCareerSlashLine(stats); + // Last 5 and 10 sessions const last5 = stats.slice(0, 5); const last10 = stats.slice(0, 10); @@ -584,6 +589,9 @@ const recalculatePlayerAggregatesAction = withBaseballAction( team_id: teamId, total_sessions: stats.length, career_avg: careerAvg, + career_obp: careerObp, + career_slg: careerSlg, + career_ops: careerOps, practice_avg: practiceAvg, game_avg: gameAvg, pressure_gap: pressureGap, diff --git a/src/lib/baseball/__tests__/career-slash-line.test.ts b/src/lib/baseball/__tests__/career-slash-line.test.ts new file mode 100644 index 000000000..219e08a0f --- /dev/null +++ b/src/lib/baseball/__tests__/career-slash-line.test.ts @@ -0,0 +1,61 @@ +// ============================================================================= +// career-slash-line.test.ts +// +// Guards the career OBP/SLG/OPS derivation used by +// `recalculatePlayerAggregates` (BaseballHelm #436). Pure function; no DB. +// The headline case is a multi-session aggregate that exercises walks, sac +// flies, and hit-by-pitch — the fields that pull OBP away from AVG. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { + computeCareerSlashLine, + type SlashLineStatRow, +} from '@/lib/baseball/aggregates/career-slash-line'; + +const row = (partial: Partial): SlashLineStatRow => ({ + at_bats: 0, + hits: 0, + doubles: 0, + triples: 0, + home_runs: 0, + walks: 0, + hit_by_pitch: 0, + sacrifice_flies: 0, + ...partial, +}); + +describe('computeCareerSlashLine', () => { + it('aggregates OBP/SLG/OPS across multiple sessions with walks, SF, and HBP', () => { + // Session 1: 4 AB, 2 H (1 double), 1 BB, 1 HBP + // Session 2: 6 AB, 2 H (1 HR), 1 BB, 1 SF + // Totals: AB 10, H 4, 2B 1, HR 1, BB 2, HBP 1, SF 1 + const stats = [ + row({ at_bats: 4, hits: 2, doubles: 1, walks: 1, hit_by_pitch: 1 }), + row({ at_bats: 6, hits: 2, home_runs: 1, walks: 1, sacrifice_flies: 1 }), + ]; + + const { obp, slg, ops } = computeCareerSlashLine(stats); + + // OBP = (H + BB + HBP) / (AB + BB + HBP + SF) = (4 + 2 + 1) / (10 + 2 + 1 + 1) = 7/14 = .500 + expect(obp).toBeCloseTo(0.5, 6); + // TB = H + 2B + 2·3B + 3·HR = 4 + 1 + 0 + 3 = 8; SLG = 8/10 = .800 + expect(slg).toBeCloseTo(0.8, 6); + // OPS = .500 + .800 = 1.300 + expect(ops).toBeCloseTo(1.3, 6); + }); + + it('returns null rates when every denominator is zero (no fabricated .000)', () => { + expect(computeCareerSlashLine([])).toEqual({ obp: null, slg: null, ops: null }); + expect(computeCareerSlashLine([row({})])).toEqual({ obp: null, slg: null, ops: null }); + }); + + it('yields a non-null OBP but null SLG/OPS when a player only walks (zero AB)', () => { + // A plate appearance that is all walks: OBP denominator is non-zero, AB is zero. + const { obp, slg, ops } = computeCareerSlashLine([row({ walks: 2, hit_by_pitch: 1 })]); + // OBP = (0 + 2 + 1) / (0 + 2 + 1 + 0) = 3/3 = 1.000 + expect(obp).toBeCloseTo(1, 6); + expect(slg).toBeNull(); // zero AB → null, not 0 + expect(ops).toBeNull(); // null when either component is null + }); +}); diff --git a/src/lib/baseball/aggregates/career-slash-line.ts b/src/lib/baseball/aggregates/career-slash-line.ts new file mode 100644 index 000000000..48c8c2c7c --- /dev/null +++ b/src/lib/baseball/aggregates/career-slash-line.ts @@ -0,0 +1,61 @@ +// ============================================================================= +// career-slash-line.ts +// +// Pure derivation of a player's career OBP / SLG / OPS from summed counting +// stats. Extracted from `recalculatePlayerAggregates` (src/app/baseball/ +// actions/stats.ts) so it can be unit-tested without a database. +// +// Honesty contract: every rate is null on a zero denominator — never a +// fabricated .000. See BaseballHelm #436. +// ============================================================================= + +import type { BaseballPlayerStats } from '@/lib/types'; + +/** The counting-stat fields the slash line is derived from. */ +export type SlashLineStatRow = Pick< + BaseballPlayerStats, + 'at_bats' | 'hits' | 'doubles' | 'triples' | 'home_runs' | 'walks' | 'hit_by_pitch' | 'sacrifice_flies' +>; + +export interface CareerSlashLine { + /** On-base percentage: (H + BB + HBP) / (AB + BB + HBP + SF); null on zero PA. */ + obp: number | null; + /** Slugging: total bases / AB; null on zero AB. */ + slg: number | null; + /** OPS = OBP + SLG; null when either component is null. */ + ops: number | null; +} + +/** + * Derive career OBP/SLG/OPS from a set of per-session counting stats. + * Sums across every row, then applies the standard formulas. Missing + * counting values are treated as 0; denominators of 0 yield null. + */ +export function computeCareerSlashLine( + stats: ReadonlyArray, +): CareerSlashLine { + const sum = (pick: (s: SlashLineStatRow) => number | null | undefined): number => + stats.reduce((acc, s) => acc + (pick(s) || 0), 0); + + const atBats = sum((s) => s.at_bats); + const hits = sum((s) => s.hits); + const doubles = sum((s) => s.doubles); + const triples = sum((s) => s.triples); + const homeRuns = sum((s) => s.home_runs); + const walks = sum((s) => s.walks); + const hitByPitch = sum((s) => s.hit_by_pitch); + const sacFlies = sum((s) => s.sacrifice_flies); + + // OBP = (H + BB + HBP) / (AB + BB + HBP + SF) + const obpDenominator = atBats + walks + hitByPitch + sacFlies; + const obp = obpDenominator === 0 ? null : (hits + walks + hitByPitch) / obpDenominator; + + // SLG = total bases / AB, where total bases = singles + 2·2B + 3·3B + 4·HR + // = H + 2B + 2·3B + 3·HR + const totalBases = hits + doubles + 2 * triples + 3 * homeRuns; + const slg = atBats === 0 ? null : totalBases / atBats; + + const ops = obp != null && slg != null ? obp + slg : null; + + return { obp, slg, ops }; +} diff --git a/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql b/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql new file mode 100644 index 000000000..06a50f3c0 --- /dev/null +++ b/supabase/migrations/20260701020000_baseball_player_aggregates_slash_line.sql @@ -0,0 +1,20 @@ +-- BaseballHelm #436 — persist the slash line on player aggregates. +-- +-- `recalculatePlayerAggregates` (src/app/baseball/actions/stats.ts) previously +-- only wrote `career_avg`, but My Stats `StatsOverviewCards` reads +-- `career_obp`, `career_slg`, and `career_ops`. Those columns never existed on +-- the live `baseball_player_aggregates` table, so OBP/SLG/OPS permanently +-- showed "---". Add them here (numeric, matching `career_avg`) so the recalc +-- can compute and upsert the full slash line. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS. No data backfill — the columns fill in +-- on the next aggregate recalculation for each player. + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_obp" numeric; + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_slg" numeric; + +ALTER TABLE "public"."baseball_player_aggregates" + ADD COLUMN IF NOT EXISTS "career_ops" numeric; From 39f0c0c5f213f0fd56878957d635aafd61245d6b Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 12:55:19 -0400 Subject: [PATCH 020/186] fix(baseball): scope academics eligibility reads to active team (#509) getTeamAcademics loaded eligibility rows by player_id only and defaulted missing records to is_eligible:true / academic_standing:'good', which leaked another team's record for transfers/multi-team players and inflated the "Eligible" / understated "At Risk" summary cards. - Filter the eligibility query by team_id and order updated_at desc so the per-player match resolves to the active team's latest-semester row. - Default players with no eligibility row to is_eligible:false / academic_standing:null (unknown/ineligible-safe), so the UI shows "Ineligible" / "Unknown" instead of "Eligible" / "Good Standing". - Summary cards (eligibleCount / atRiskCount) now recompute from the scoped, non-inflated data. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/app/baseball/actions/academics.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/baseball/actions/academics.ts b/src/app/baseball/actions/academics.ts index 13fd9999e..3f7c04721 100644 --- a/src/app/baseball/actions/academics.ts +++ b/src/app/baseball/actions/academics.ts @@ -161,11 +161,16 @@ const getTeamAcademicsAction = withBaseballAction( let eligibilityRecords: BaseballAcademicEligibility[] = []; if (playerIds.length > 0) { + // Scope to the active team so transfers / multi-team players don't leak + // another team's record. Order latest-first so the per-player `.find` + // below picks this team's most recent (latest semester) record. // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: eligData } = await (supabase as any) .from('baseball_academic_eligibility') .select('*') - .in('player_id', playerIds); + .eq('team_id', teamId) + .in('player_id', playerIds) + .order('updated_at', { ascending: false }); eligibilityRecords = (eligData || []) as BaseballAcademicEligibility[]; } @@ -210,8 +215,10 @@ const getTeamAcademicsAction = withBaseballAction( gpa: eligibility?.gpa ?? player?.gpa ?? null, credits_completed: eligibility?.credits_completed ?? null, credits_required: eligibility?.credits_required ?? 60, - is_eligible: eligibility?.is_eligible ?? true, - academic_standing: eligibility?.academic_standing ?? 'good' as const, + // No eligibility record => treat as unknown / ineligible-safe rather + // than inflating "Eligible" / "Good Standing" summary cards. + is_eligible: eligibility?.is_eligible ?? false, + academic_standing: eligibility?.academic_standing ?? null, class_count: classCounts[m.player_id] || 0, eligibility_id: eligibility?.id ?? null, }; From ceea8f90140d7e6e432da9ad14aa37303d93517d Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 12:55:28 -0400 Subject: [PATCH 021/186] fix(baseball): persist and surface requires_rsvp for calendar events (#445) Baseball calendar hardcoded requires_rsvp: false on read, and createBaseballEvent never wrote requires_rsvp even though the shared calendar UI sends requiresRsvp. Player RSVP controls in the shared EventDetailModal/MobileEventSheet gate on event.requires_rsvp, so RSVP was effectively dead for BaseballHelm. - createBaseballEvent now persists requires_rsvp (input.requiresRsvp ?? false) - updateBaseballEvent now persists requires_rsvp when provided - Calendar page reads the real requires_rsvp value into CalendarEvent instead of hardcoding false The requires_rsvp column already exists on baseball_events but is not yet in the generated types (schema drift), so the read/insert route through the existing fromUntyped escape hatch, mirroring the update path. No migration required. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../baseball/(dashboard)/dashboard/calendar/page.tsx | 10 ++++++---- src/app/baseball/actions/calendar.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx index 4edcbae08..324a8aaee 100644 --- a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx @@ -1,4 +1,5 @@ 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'; @@ -73,9 +74,10 @@ export default async function BaseballCalendarPage() { if (teamId) { const [eventsResult, membersResult, teamOrgResult] = await Promise.all([ - supabase - .from('baseball_events') - .select('id, team_id, title, event_type, start_time, end_time, location, description, is_mandatory, max_attendees, rsvp_deadline, created_by') + // Read via fromUntyped because requires_rsvp is not yet in the generated + // baseball_events types (schema drift — the column exists in the DB). + fromUntyped(supabase, 'baseball_events') + .select('id, team_id, title, event_type, start_time, end_time, location, description, is_mandatory, max_attendees, rsvp_deadline, requires_rsvp, created_by') .eq('team_id', teamId) .order('start_time', { ascending: true }), supabase @@ -106,7 +108,7 @@ export default async function BaseballCalendarPage() { max_attendees: event.max_attendees, rsvp_deadline: event.rsvp_deadline, created_by: event.created_by, - requires_rsvp: false, + requires_rsvp: event.requires_rsvp ?? false, })); // Coaches on this team. Read non-PII identity from the baseball_coaches_public diff --git a/src/app/baseball/actions/calendar.ts b/src/app/baseball/actions/calendar.ts index 331ab7547..08a463332 100644 --- a/src/app/baseball/actions/calendar.ts +++ b/src/app/baseball/actions/calendar.ts @@ -184,8 +184,10 @@ const createBaseballEventAction = withBaseballAction( input.timezoneOffset, ); - const { data, error } = await supabase - .from('baseball_events') + // Routed through fromUntyped because requires_rsvp is not yet in the + // generated baseball_events types (schema drift — the column exists in + // the DB). Mirrors the update path below. + const { data, error } = await fromUntyped(supabase, 'baseball_events') .insert({ team_id: resolvedTeamId, created_by: coachId, @@ -198,6 +200,7 @@ const createBaseballEventAction = withBaseballAction( is_mandatory: input.isMandatory ?? false, max_attendees: input.maxAttendees || null, rsvp_deadline: buildRsvpDeadline(input.rsvpDeadline, input.timezoneOffset), + requires_rsvp: input.requiresRsvp ?? false, created_by_id: ctx.user.id, }) .select() @@ -270,6 +273,7 @@ const updateBaseballEventAction = withBaseballAction( if (input.location !== undefined) updateData.location = input.location; if (input.description !== undefined) updateData.description = input.description; if (input.isMandatory !== undefined) updateData.is_mandatory = input.isMandatory; + if (input.requiresRsvp !== undefined) updateData.requires_rsvp = input.requiresRsvp; if (input.maxAttendees !== undefined) updateData.max_attendees = input.maxAttendees; if (input.rsvpDeadline !== undefined) { updateData.rsvp_deadline = buildRsvpDeadline(input.rsvpDeadline, input.timezoneOffset); From a1d9fdaf6060b0566f1fc0d9f17dc5aa11f6e4f1 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 12:57:09 -0400 Subject: [PATCH 022/186] fix(baseball): sum innings pitched in outs, not base-10 decimals (#434) `.1`/`.2` in innings-pitched notation are outs (thirds of an inning), so adding them with `+` was wrong: `6.1 + 6.2` gave `12.3` instead of `13.0`. Summed IP and the ERA/WHIP/K9 derived from it were incorrect on any total that included partial innings. Introduce a shared `src/lib/baseball/innings.ts` helper (`ipToOuts`/`outsToIp`/`ipToInnings`/`sumInningsPitched`) that converts to outs and back, and divide rate stats by TRUE innings (outs / 3). Wire it into every IP-summing site: - BoxScoreView team pitching totals + team ERA/WHIP - BoxScoreEntry pitching totals footer - PlayerGameLog pitching totals footer ERA/WHIP - read-models/stats-center `addPitching` accumulation + `finalizePitching` Adds `innings.test.ts` covering partial-inning sums and the resulting ERA/WHIP/K9. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../baseball/box-score/BoxScoreEntry.tsx | 4 +- .../baseball/box-score/BoxScoreView.tsx | 10 ++- .../baseball/season-stats/PlayerGameLog.tsx | 11 ++- src/lib/baseball/innings.test.ts | 73 +++++++++++++++++++ src/lib/baseball/innings.ts | 41 +++++++++++ src/lib/baseball/read-models/stats-center.ts | 20 +++-- 6 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 src/lib/baseball/innings.test.ts create mode 100644 src/lib/baseball/innings.ts diff --git a/src/components/baseball/box-score/BoxScoreEntry.tsx b/src/components/baseball/box-score/BoxScoreEntry.tsx index 155f6c427..a0a3f9553 100644 --- a/src/components/baseball/box-score/BoxScoreEntry.tsx +++ b/src/components/baseball/box-score/BoxScoreEntry.tsx @@ -11,6 +11,7 @@ import type { } from '@/lib/types'; import { Button } from '@/components/ui/button'; import { IconSave, IconUser, IconTrendingUp } from '@/components/icons'; +import { sumInningsPitched } from '@/lib/baseball/innings'; interface PlayerRow { id: string; @@ -454,7 +455,8 @@ export function BoxScoreEntry({ game, teamPlayers, initialBatting, initialPitchi TOTALS - {pitchingRows.reduce((s, r) => s + r.ip, 0).toFixed(1)} + {/* .1/.2 are outs — sum via outs, not base-10 (#434) */} + {sumInningsPitched(pitchingRows.map((r) => r.ip)).toFixed(1)} {pitchingRows.reduce((s, r) => s + r.h, 0)} {pitchingRows.reduce((s, r) => s + r.r, 0)} diff --git a/src/components/baseball/box-score/BoxScoreView.tsx b/src/components/baseball/box-score/BoxScoreView.tsx index 4802de941..046f1dfef 100644 --- a/src/components/baseball/box-score/BoxScoreView.tsx +++ b/src/components/baseball/box-score/BoxScoreView.tsx @@ -2,6 +2,7 @@ import type { BaseballGame, BaseballBoxScoreBatting, BaseballBoxScorePitching } from '@/lib/types'; import { IconCalendar, IconMapPin } from '@/components/icons'; +import { sumInningsPitched, ipToInnings } from '@/lib/baseball/innings'; interface BoxScoreViewProps { game: BaseballGame; @@ -57,7 +58,8 @@ export function BoxScoreView({ game, batting, pitching }: BoxScoreViewProps) { // Pitching totals const pitchTotals = pitching.reduce( (acc, r) => ({ - ip: acc.ip + r.ip, + // .1/.2 are outs, not decimals — sum via outs (6.1 + 6.2 → 13.0). (#434) + ip: sumInningsPitched([acc.ip, r.ip]), h: acc.h + r.h, r: acc.r + r.r, er: acc.er + r.er, @@ -68,8 +70,10 @@ export function BoxScoreView({ game, batting, pitching }: BoxScoreViewProps) { { ip: 0, h: 0, r: 0, er: 0, bb: 0, k: 0, hr: 0 } ); - const teamERA = pitchTotals.ip > 0 ? (9 * pitchTotals.er / pitchTotals.ip).toFixed(2) : '—'; - const teamWHIP = pitchTotals.ip > 0 ? ((pitchTotals.bb + pitchTotals.h) / pitchTotals.ip).toFixed(3) : '—'; + // Rates divide by TRUE innings (outs / 3), not the notation value. (#434) + const teamInnings = ipToInnings(pitchTotals.ip); + const teamERA = teamInnings > 0 ? (9 * pitchTotals.er / teamInnings).toFixed(2) : '—'; + const teamWHIP = teamInnings > 0 ? ((pitchTotals.bb + pitchTotals.h) / teamInnings).toFixed(3) : '—'; return (
diff --git a/src/components/baseball/season-stats/PlayerGameLog.tsx b/src/components/baseball/season-stats/PlayerGameLog.tsx index ef2397216..9d19e93fe 100644 --- a/src/components/baseball/season-stats/PlayerGameLog.tsx +++ b/src/components/baseball/season-stats/PlayerGameLog.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import type { BaseballBoxScoreBatting, BaseballBoxScorePitching, BaseballGame } from '@/lib/types'; import { IconCalendar } from '@/components/icons'; import { Button } from '@/components/ui/button'; +import { sumInningsPitched, ipToInnings } from '@/lib/baseball/innings'; type BattingWithGame = BaseballBoxScoreBatting & { game: Partial }; type PitchingWithGame = BaseballBoxScorePitching & { game: Partial }; @@ -59,14 +60,16 @@ export function PlayerGameLog({ batting, pitching }: PlayerGameLogProps) { { ab: 0, r: 0, h: 0, doubles: 0, triples: 0, hr: 0, rbi: 0, bb: 0, k: 0 } ); - // Running pitching totals + // Running pitching totals — .1/.2 are outs, so sum IP via outs (6.1 + 6.2 → 13.0). (#434) const pitchingTotals = filteredPitching.reduce( (acc, r) => ({ - ip: acc.ip + r.ip, h: acc.h + r.h, r: acc.r + r.r, er: acc.er + r.er, + ip: sumInningsPitched([acc.ip, r.ip]), h: acc.h + r.h, r: acc.r + r.r, er: acc.er + r.er, bb: acc.bb + r.bb, k: acc.k + r.k, hr: acc.hr + r.hr, }), { ip: 0, h: 0, r: 0, er: 0, bb: 0, k: 0, hr: 0 } ); + // Rates divide by TRUE innings (outs / 3), not the notation value. (#434) + const pitchingInnings = ipToInnings(pitchingTotals.ip); const hasBatting = batting.length > 0; const hasPitching = pitching.length > 0; @@ -303,10 +306,10 @@ export function PlayerGameLog({ batting, pitching }: PlayerGameLogProps) { {pitchingTotals.k} - {fmtERA(pitchingTotals.er, pitchingTotals.ip)} + {fmtERA(pitchingTotals.er, pitchingInnings)} - {pitchingTotals.ip > 0 ? ((pitchingTotals.h + pitchingTotals.bb) / pitchingTotals.ip).toFixed(3) : '—'} + {pitchingInnings > 0 ? ((pitchingTotals.h + pitchingTotals.bb) / pitchingInnings).toFixed(3) : '—'} diff --git a/src/lib/baseball/innings.test.ts b/src/lib/baseball/innings.test.ts new file mode 100644 index 000000000..0a6d731f0 --- /dev/null +++ b/src/lib/baseball/innings.test.ts @@ -0,0 +1,73 @@ +/** + * Regression tests for innings-pitched arithmetic (#434). + * `.1`/`.2` are outs (thirds of an inning), so summing with `+` is wrong. + */ +import { describe, it, expect } from 'vitest'; +import { ipToOuts, outsToIp, ipToInnings, sumInningsPitched } from './innings'; + +describe('ipToOuts', () => { + it('treats the tenths digit as outs, not a base-10 fraction', () => { + expect(ipToOuts(6)).toBe(18); + expect(ipToOuts(6.1)).toBe(19); + expect(ipToOuts(6.2)).toBe(20); + expect(ipToOuts(0.1)).toBe(1); + }); + + it('is null/NaN safe', () => { + expect(ipToOuts(null)).toBe(0); + expect(ipToOuts(undefined)).toBe(0); + expect(ipToOuts(Number.NaN)).toBe(0); + }); +}); + +describe('outsToIp', () => { + it('round-trips outs back into innings notation', () => { + expect(outsToIp(18)).toBeCloseTo(6.0, 10); + expect(outsToIp(19)).toBeCloseTo(6.1, 10); + expect(outsToIp(20)).toBeCloseTo(6.2, 10); + expect(outsToIp(39)).toBeCloseTo(13.0, 10); + }); +}); + +describe('sumInningsPitched — partial innings add via outs', () => { + it('sums 6.1 + 6.2 to 13.0, not 12.3', () => { + expect(sumInningsPitched([6.1, 6.2])).toBeCloseTo(13.0, 10); + }); + + it('carries thirds into whole innings', () => { + expect(sumInningsPitched([2.1, 1.2])).toBeCloseTo(4.0, 10); // 7 + 5 outs = 12 outs + expect(sumInningsPitched([0.1, 0.1, 0.1])).toBeCloseTo(1.0, 10); + expect(sumInningsPitched([5.2, 1.2, 1.1])).toBeCloseTo(8.2, 10); // 17+5+4 = 26 outs + }); + + it('ignores null/undefined lines', () => { + expect(sumInningsPitched([6.1, null, undefined, 6.2])).toBeCloseTo(13.0, 10); + expect(sumInningsPitched([])).toBe(0); + }); +}); + +describe('derived ERA/WHIP/K9 use true innings', () => { + // 6.1 + 6.2 = 39 outs = 13.0 innings exactly. + it('computes ERA/WHIP over the correctly summed innings', () => { + const totalIp = sumInningsPitched([6.1, 6.2]); + const innings = ipToInnings(totalIp); + expect(innings).toBeCloseTo(13, 10); + // 4 earned runs over 13 innings. + expect((9 * 4) / innings).toBeCloseTo(2.77, 2); + // 8 hits + 4 walks over 13 innings. + expect((8 + 4) / innings).toBeCloseTo(0.923, 3); + // 14 strikeouts per 9 over 13 innings. + expect((9 * 14) / innings).toBeCloseTo(9.69, 2); + }); + + it('uses true innings for a partial-inning total (not the notation value)', () => { + // 6.1 + 6.1 = 38 outs → 12.2 notation, but 12.667 true innings. + const totalIp = sumInningsPitched([6.1, 6.1]); + expect(totalIp).toBeCloseTo(12.2, 10); + const innings = ipToInnings(totalIp); + expect(innings).toBeCloseTo(38 / 3, 10); + // ERA over true innings differs from dividing by the 12.2 notation value. + expect((9 * 6) / innings).toBeCloseTo(4.26, 2); + expect((9 * 6) / innings).not.toBeCloseTo((9 * 6) / 12.2, 2); + }); +}); diff --git a/src/lib/baseball/innings.ts b/src/lib/baseball/innings.ts new file mode 100644 index 000000000..d814909c2 --- /dev/null +++ b/src/lib/baseball/innings.ts @@ -0,0 +1,41 @@ +/** + * Innings-pitched arithmetic (#434). + * + * Innings pitched are recorded in baseball's "outs" convention where the + * fractional digit counts thirds of an inning (outs), NOT a base-10 fraction: + * 6.0 → 6 innings, 6.1 → 6⅓ innings (19 outs), 6.2 → 6⅔ innings (20 outs). + * + * Adding these values with plain `+` is wrong: `6.1 + 6.2` yields `12.3` + * instead of the correct `13.0` (39 outs). Convert to outs, add, convert back. + * Rate stats (ERA/WHIP/K9) must divide by TRUE innings (outs / 3), not the + * notation value, or the denominator is off whenever partial innings exist. + */ + +/** Convert innings-pitched notation (6.2) into a whole out count (20). */ +export function ipToOuts(ip: number | null | undefined): number { + if (ip == null || !Number.isFinite(ip)) return 0; + const whole = Math.trunc(ip); + // The tenths digit is the out count (0, 1, or 2). Round to guard against + // floating-point noise (e.g. 6.1 stored as 6.09999999). + const outs = Math.round((ip - whole) * 10); + return whole * 3 + outs; +} + +/** Convert a whole out count (20) back into innings-pitched notation (6.2). */ +export function outsToIp(outs: number): number { + const innings = Math.floor(outs / 3); + return innings + (outs % 3) / 10; +} + +/** True decimal innings (6.2 → 6.6667), for ERA/WHIP/K9 rate math. */ +export function ipToInnings(ip: number | null | undefined): number { + return ipToOuts(ip) / 3; +} + +/** + * Sum innings-pitched values correctly via outs so partial innings add up in + * baseball notation (e.g. `[6.1, 6.2]` → `13.0`). + */ +export function sumInningsPitched(ips: Array): number { + return outsToIp(ips.reduce((acc, ip) => acc + ipToOuts(ip), 0)); +} diff --git a/src/lib/baseball/read-models/stats-center.ts b/src/lib/baseball/read-models/stats-center.ts index 984e5aa12..7319a4dc0 100644 --- a/src/lib/baseball/read-models/stats-center.ts +++ b/src/lib/baseball/read-models/stats-center.ts @@ -44,6 +44,7 @@ import 'server-only'; import { createClient } from '@/lib/supabase/server'; import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { sumInningsPitched, ipToInnings } from '@/lib/baseball/innings'; import type { BaseballGameType } from '@/lib/types'; // ----------------------------------------------------------------------------- @@ -431,13 +432,15 @@ export function finalizeBatting(b: BattingSplit): BattingSplit { } export function finalizePitching(p: PitchingSplit): PitchingSplit { - if (p.ip > 0) { - p.era = round2((p.er * 9) / p.ip); - p.whip = round2((p.bb + p.h) / p.ip); - p.k9 = round2((p.k * 9) / p.ip); - p.bb9 = round2((p.bb * 9) / p.ip); - p.hr9 = round2((p.hr * 9) / p.ip); - p.h9 = round2((p.h * 9) / p.ip); + // .1/.2 in p.ip are outs, so rates divide by TRUE innings (outs / 3). (#434) + const innings = ipToInnings(p.ip); + if (innings > 0) { + p.era = round2((p.er * 9) / innings); + p.whip = round2((p.bb + p.h) / innings); + p.k9 = round2((p.k * 9) / innings); + p.bb9 = round2((p.bb * 9) / innings); + p.hr9 = round2((p.hr * 9) / innings); + p.h9 = round2((p.h * 9) / innings); } p.kPct = ratio(p.k, p.bf); p.bbPct = ratio(p.bb, p.bf); @@ -923,7 +926,8 @@ export async function getStatsCenter( }; const addPitching = (dst: PitchingSplit, line: PitchingLineRow) => { - dst.ip += line.ip ?? 0; + // .1/.2 are outs — accumulate IP via outs so partials add up (6.1 + 6.2 → 13.0). (#434) + dst.ip = sumInningsPitched([dst.ip, line.ip]); dst.h += line.h ?? 0; dst.r += line.r ?? 0; dst.er += line.er ?? 0; From 80258cff0a042817b3cc05ef6c11674844cfe55c Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 13:22:43 -0400 Subject: [PATCH 023/186] fix(baseball): per-row ERA/WHIP divide by true innings in PlayerGameLog (#434) Verifier gap: the per-game rate cells still used notation IP (row.ip) as the denominator; a 0.2 IP line rendered ERA as 9*er/0.2 instead of /0.667. Divide by ipToInnings(row.ip) to match the corrected footer totals. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/components/baseball/season-stats/PlayerGameLog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/baseball/season-stats/PlayerGameLog.tsx b/src/components/baseball/season-stats/PlayerGameLog.tsx index 9d19e93fe..4e99ef1dc 100644 --- a/src/components/baseball/season-stats/PlayerGameLog.tsx +++ b/src/components/baseball/season-stats/PlayerGameLog.tsx @@ -275,9 +275,9 @@ export function PlayerGameLog({ batting, pitching }: PlayerGameLogProps) { {row.k} {row.hr} {row.pitch_count ?? '—'} - {fmtERA(row.er, row.ip)} + {fmtERA(row.er, ipToInnings(row.ip))} - {row.ip > 0 ? ((row.h + row.bb) / row.ip).toFixed(3) : '—'} + {ipToInnings(row.ip) > 0 ? ((row.h + row.bb) / ipToInnings(row.ip)).toFixed(3) : '—'} {row.result && ( From 48d739cec346696605b21cdc254f7c4046367559 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 13:25:36 -0400 Subject: [PATCH 024/186] fix(baseball): type the calendar events map row (fromUntyped implicit-any) The #445 fix reads baseball_events via fromUntyped (requires_rsvp not yet in generated types), which made the map param implicitly any. Annotate the row. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../(dashboard)/dashboard/calendar/page.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx index 324a8aaee..2991cf250 100644 --- a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx @@ -92,8 +92,23 @@ export default async function BaseballCalendarPage() { .maybeSingle(), ]); - // Map baseball_events → CalendarEvent - events = (eventsResult.data || []).map((event) => ({ + // Map baseball_events → CalendarEvent. Row is annotated because the query + // uses fromUntyped (requires_rsvp not yet in generated types). + events = (eventsResult.data || []).map((event: { + id: string; + team_id: string | null; + title: string; + event_type: string | null; + start_time: string; + end_time: string | null; + location: string | null; + description: string | null; + is_mandatory: boolean | null; + max_attendees: number | null; + rsvp_deadline: string | null; + requires_rsvp: boolean | null; + created_by: string | null; + }) => ({ id: event.id, team_id: event.team_id || '', title: event.title, From 84418b934da515735a81b8f9cc402def00bf819e Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:24:55 -0400 Subject: [PATCH 025/186] fix(baseball): practice coach embeds select full_name (not first/last) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseball_coaches has only full_name — the practice-planner and team-peek queries embedded coach_owner:baseball_coaches(id, first_name, last_name), which crashed PostgREST with "column baseball_coaches_2.first_name does not exist". This hard-failed the coach Practice Planner ("Something went wrong") and the player Practice tab ("Could not load practice plans"). - practice.ts (getCoachPractices ~136, getPlayerPracticeSchedule ~664): embed full_name; derive coach_owner_name from full_name. - PracticePlannerClient.tsx (~269): staff dropdown reads full_name. - TeamPeekPanel.tsx (~130): coach staff reads full_name. Enhancement: add an editorial section header + plan count above the practice list for GolfHelm-grade hierarchy. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/app/baseball/actions/practice.ts | 8 ++++---- .../practice-planner/PracticePlannerClient.tsx | 12 ++++++++++-- src/components/panels/TeamPeekPanel.tsx | 6 +++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/app/baseball/actions/practice.ts b/src/app/baseball/actions/practice.ts index 7bbd3e474..5bf5e358c 100644 --- a/src/app/baseball/actions/practice.ts +++ b/src/app/baseball/actions/practice.ts @@ -133,7 +133,7 @@ export async function getTeamPractices(): Promise ({ ...b, coach_owner_name: b.coach_owner - ? `${b.coach_owner.first_name ?? ''} ${b.coach_owner.last_name ?? ''}`.trim() || null + ? (b.coach_owner.full_name?.trim() || null) : null, })) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -661,7 +661,7 @@ export async function getPlayerPractices(): Promise< id, start_offset_min, duration_min, activity, description, location, station_type, group_label, equipment, is_measured, coach_owner_id, visibility, - coach_owner:baseball_coaches(id, first_name, last_name) + coach_owner:baseball_coaches(id, full_name) ), attendance:baseball_practice_attendance(player_id, status) `, @@ -769,7 +769,7 @@ export async function getPlayerPractices(): Promise< const blocks: PlayerPracticeBlockView[] = visibleBlocks.map((b: any) => { const coachOwner = Array.isArray(b.coach_owner) ? b.coach_owner[0] : b.coach_owner; const coachName = coachOwner - ? `${coachOwner.first_name ?? ''} ${coachOwner.last_name ?? ''}`.trim() || null + ? (coachOwner.full_name?.trim() || null) : null; const { hasConflict, names } = detectConflicts( diff --git a/src/components/baseball/practice-planner/PracticePlannerClient.tsx b/src/components/baseball/practice-planner/PracticePlannerClient.tsx index 3f6cf0695..a965c8915 100644 --- a/src/components/baseball/practice-planner/PracticePlannerClient.tsx +++ b/src/components/baseball/practice-planner/PracticePlannerClient.tsx @@ -266,7 +266,7 @@ export function PracticePlannerClient() { const { data: coaches } = await supabase .from('baseball_team_coach_staff') - .select('coach:baseball_coaches(id, first_name, last_name)') + .select('coach:baseball_coaches(id, full_name)') .eq('team_id', selectedTeamId); const staffList: StaffCoach[] = (coaches ?? []) @@ -274,7 +274,7 @@ export function PracticePlannerClient() { .map((c: any) => { const co = Array.isArray(c.coach) ? c.coach[0] : c.coach; if (!co) return null; - const name = [co.first_name, co.last_name].filter(Boolean).join(' ') || 'Coach'; + const name = (co.full_name as string | null)?.trim() || 'Coach'; return { id: co.id as string, name }; }) .filter((c): c is StaffCoach => c !== null) @@ -877,6 +877,14 @@ export function PracticePlannerClient() { /> ) : (
+
+

+ {isCoach ? 'Practices' : 'Your schedule'} +

+ + {practices.length} {practices.length === 1 ? 'plan' : 'plans'} + +
{practices.map((p, idx) => ( 0 ? supabase .from('baseball_team_coach_staff') - .select('is_primary, role, baseball_coaches!inner(id, first_name, last_name, email, avatar_url)') + .select('is_primary, role, baseball_coaches!inner(id, full_name, email, avatar_url)') .in('team_id', teamIds) : Promise.resolve({ data: null }), teamIds.length > 0 @@ -148,12 +148,12 @@ export function TeamPeekPanel({ teamId, onClose }: TeamPeekPanelProps) { }>; const sortedStaff = [...rawStaff].sort((a, b) => (b.is_primary ? 1 : 0) - (a.is_primary ? 1 : 0)); sortedStaff.forEach((row) => { - const c = row.baseball_coaches as { id: string; first_name: string | null; last_name: string | null; email: string | null; avatar_url: string | null } | null; + const c = row.baseball_coaches as { id: string; full_name: string | null; email: string | null; avatar_url: string | null } | null; if (!c || seenCoaches.has(c.id)) return; seenCoaches.add(c.id); staffList.push({ id: c.id, - name: `${c.first_name ?? ''} ${c.last_name ?? ''}`.trim() || 'Coach', + name: c.full_name?.trim() || 'Coach', title: row.is_primary ? 'Head Coach' : (row.role ?? 'Assistant Coach'), avatarUrl: c.avatar_url, email: c.email, From e10babaf4a3b64370276c3a6328e7fc86d50af25 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:29:39 -0400 Subject: [PATCH 026/186] fix(baseball): eliminate duplicate top-bar chrome across dashboard shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the Fairway dashboard shell (BaseballFairwayShell -> AppShell) owns the single global top bar (sidebar toggle, global search, notification bell, user identity, breadcrumb). Un-migrated pages still mounted the legacy `
` from @/components/layout/header, which renders that SAME global chrome again — producing two search boxes, two notification bells, and two user menus stacked under the shell bar on Travel, Videos, Settings (index + program/philosophy/season/permissions), Profile, dev-plans, academics, camps, my-stats, and more. Fix once in the shared component: when the redesign flag is ON, `
` degrades to a lightweight page-title bar (title, subtitle, back link, and page-specific actions only) — no global chrome. Exactly one search field, one bell, and one user menu per screen. The legacy flag-OFF path is unchanged and fully reversible. Also: - Travel: removed the now-redundant page-level `
` (TravelClient already renders its own title + primary action), collapsing the triple-stacked "Travel" heading to one; added lightweight headings for the loading/error/ no-team states so orientation is preserved. - Videos: fixed the misleading "Team film — 5 views" subtitle (read as video views but meant the 5 tab views, and contradicted the "No videos yet" empty state) -> "Team film, clips, and evidence". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../(dashboard)/dashboard/travel/page.tsx | 55 ++++++++--------- .../baseball/video/VideoLibraryClient.tsx | 2 +- src/components/layout/header.tsx | 61 +++++++++++++++++++ 3 files changed, 87 insertions(+), 31 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/travel/page.tsx b/src/app/baseball/(dashboard)/dashboard/travel/page.tsx index 36d535945..b1ae25b6a 100644 --- a/src/app/baseball/(dashboard)/dashboard/travel/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/travel/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Header } from '@/components/layout/header'; import { PageLoading } from '@/components/ui/loading'; import { useAuth } from '@/hooks/use-auth'; import { useTeamStore } from '@/stores/team-store'; @@ -100,50 +99,46 @@ export default function BaseballTravelPage() { } } + // NOTE: the page-level global
was removed here — the dashboard shell + // now owns the single top bar (search / notifications / user menu / breadcrumb), + // and TravelClient renders its own page title + primary action. Rendering the + // legacy Header here produced the duplicate top-bar chrome and a redundant + // "Travel" heading stacked above TravelClient's own. The lightweight page + // headings below keep orientation in the non-content states. if (authLoading || loading) { - return ( - <> -
- - - ); + return ; } if (!teamId) { return ( - <> -
-
-

No Team Found

+
+

Travel

+
+

No team found

You must be on a team to access travel itineraries.

- +
); } if (loadError) { return ( - <> -
-
- void detectRoleAndLoad()} - /> -
- +
+

Travel

+ void detectRoleAndLoad()} + /> +
); } return ( - <> -
- - + ); } diff --git a/src/components/baseball/video/VideoLibraryClient.tsx b/src/components/baseball/video/VideoLibraryClient.tsx index 277ca8e85..2308ecfc3 100644 --- a/src/components/baseball/video/VideoLibraryClient.tsx +++ b/src/components/baseball/video/VideoLibraryClient.tsx @@ -1254,7 +1254,7 @@ export function VideoLibraryClient({ <>
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 14b056bfe..897296d68 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -9,6 +9,7 @@ import { JUCOModeToggle } from '@/components/baseball/coach/ModeToggle'; import { useAuth } from '@/hooks/use-auth'; import { useNotifications } from '@/hooks/use-notifications'; import { useSidebar } from '@/contexts/sidebar-context'; +import { isRedesignEnabled } from '@/lib/redesign/flag'; import { getFullName, cn } from '@/lib/utils'; import { IconUser, @@ -82,6 +83,66 @@ export function Header({ title, subtitle, children, backHref }: HeaderProps) { router.push('/baseball/login'); }; + // --------------------------------------------------------------------------- + // FAIRWAY REDESIGN (flag ON) — the shell (BaseballFairwayShell → AppShell) + // owns the ONE global top bar: sidebar toggle, global search, notification + // bell, user identity, and breadcrumb. Un-migrated pages that still mount + // this legacy
were rendering that global chrome a SECOND time, + // producing the duplicate search box / notification bell / user menu stacked + // directly under the shell bar (Travel, Videos, Settings, Profile, dev-plans, + // academics, camps, my-stats, …). + // + // Fixing it here — once, in the shared component — degrades
to a + // lightweight page-title bar in redesign mode: the page title, subtitle, + // back link, and page-specific action(s) ONLY. No global chrome, so there is + // exactly one search field, one bell, and one user menu per screen. The + // legacy (flag-OFF) path below is left byte-for-byte unchanged and fully + // reversible. + // --------------------------------------------------------------------------- + if (isRedesignEnabled()) { + const hasContent = Boolean(title || subtitle || children || backHref || isJucoCoach); + if (!hasContent) return null; + + return ( +
+
+
+ {backHref && ( + +
+ + {(children || isJucoCoach) && ( +
+ {isJucoCoach && ( +
+ +
+ )} + {children} +
+ )} +
+
+ ); + } + return (
From 627d16422e10131bb9fbf37cb92adeac9ca79de9 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:32:58 -0400 Subject: [PATCH 027/186] fix(baseball): repair nav dead-ends (player Roster, coach Profile, recruiting sidebar) Cluster: nav-deadends. Three role/nav mismatches where the sidebar pointed a role at a page it could never use, plus premium empty/redirect states in place of bare walls. 1. Player "Roster" -> coach-only wall The `roster` nav entry was role:'both', but the roster surface is the STAFF management workspace (read-models/roster.ts authorizes staff only, RosterClient renders add-player/lineup/export). Players were routed into "You do not have permission to view this team roster." Scope roster to role:'coach' so it never appears in the player nav; players reach team surfaces via Today/Schedule/Team hub. The residual direct-URL case now renders a role-aware "Roster is coach-only" EmptyState (glass card, icon, copy, CTA back to the player's own home) instead of a bare sentence, and the load-error wall became a proper "Try again" empty state. 2. Coach reaching /profile -> bare "This page is only available to players" "My Profile" is the player's athlete profile; coaches have no player record. Redirect coaches to the Command Center on mount (mirrors Analytics/settings redirects) so they are never stranded, and upgrade the non-player fallback to a composed EmptyState with a Command Center CTA. 3. Sidebar advertised Pipeline/Discover/Watchlist/Compare that silently bounced Root cause: program_type is nullable and unset on some teams (the demo was created with team_type:'college' but no program_type). Middleware treated null program_type as "recruiting disabled" and hard-redirected the recruiting routes to Command Center while the nav still showed them. Both middleware and nav-context now derive the SAME effective program type (program_type, then team_type fallback), so nav visibility/ordering and route gating read one source of truth and a college team is never stripped of its recruiting surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../(dashboard)/dashboard/profile/page.tsx | 41 ++++++++++-- .../dashboard/roster/RosterClient.tsx | 66 ++++++++++++------- src/lib/baseball/nav-context.ts | 35 +++++++--- src/lib/baseball/nav-registry.ts | 15 +++-- src/lib/supabase/middleware.ts | 13 +++- 5 files changed, 123 insertions(+), 47 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/profile/page.tsx b/src/app/baseball/(dashboard)/dashboard/profile/page.tsx index 71d72b322..e940d02d3 100644 --- a/src/app/baseball/(dashboard)/dashboard/profile/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/profile/page.tsx @@ -1,26 +1,55 @@ 'use client'; import Link from 'next/link'; +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; import { Header } from '@/components/layout/header'; import { Button } from '@/components/ui/button'; import { PageLoading } from '@/components/ui/loading'; +import { EmptyState } from '@/components/ui/empty-state'; import { ProfileEditor } from '@/components/features/profile-editor'; import { CollegeProfileEditor } from '@/components/baseball/profile'; import { useAuth } from '@/hooks/use-auth'; -import { IconGlobe } from '@/components/icons'; +import { IconGlobe, IconUser } from '@/components/icons'; import { Player } from '@/lib/types'; export default function ProfilePage() { + const router = useRouter(); const { user, player, loading, updatePlayer } = useAuth(); - if (loading) return ; + const isCoach = user?.role === 'coach'; + + // "My Profile" is the PLAYER's own athlete profile — coaches have no player + // record here. Rather than strand a coach on a bare "players only" wall (the + // QA nav dead-end), send them to their own home, mirroring how Analytics and + // the coach-only settings surfaces already redirect coaches to the Command + // Center. A coach's own account/program identity lives under Settings. + useEffect(() => { + if (!loading && isCoach) { + router.replace('/baseball/dashboard/command-center'); + } + }, [loading, isCoach, router]); + + if (loading || isCoach) return ; if (user?.role !== 'player' || !player) { return ( -
-
-

This page is only available to players

-
+
+ } + title="Profile unavailable" + description="We couldn't load a player profile for this account. If you're a player, refresh to try again — otherwise head back to your dashboard." + action={ + + + + } + />
); } diff --git a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx index f5801a4ab..0de07d5e8 100644 --- a/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/roster/RosterClient.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Header } from '@/components/layout/header'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; @@ -8,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { NativeSelect } from '@/components/ui/select'; import { PageLoading } from '@/components/ui/loading'; +import { EmptyState } from '@/components/ui/empty-state'; import { SkeletonTable } from '@/components/ui/skeleton'; import { IconUsers, @@ -405,38 +407,52 @@ export function RosterClient({ teamId: serverTeamId, initialModel }: RosterClien if (authLoading) return ; - if (loadError === 'unauthorized') { - return ( -
- - -

You do not have permission to view this team roster.

-
-
-
- ); - } + // Role-aware "back home" target for the coach-only walls below, so a player + // who lands here by direct URL is guided back to their own home rather than a + // coach surface (the nav no longer advertises Roster to players). + const homeHref = + user?.role === 'player' + ? '/baseball/player/today' + : '/baseball/dashboard/command-center'; - if (loadError === 'roster') { + if (loadError === 'unauthorized' || (loadError === null && user?.role !== 'coach')) { return ( -
- - -

We could not load the roster. Try refreshing the page.

-
-
+
+ } + title="Roster is coach-only" + description="The team roster workspace — lineups, player management, and exports — is available to coaches and staff. Your schedule, stats, and assignments live on your own dashboard." + action={ + + + + } + />
); } - if (user?.role !== 'coach') { + if (loadError === 'roster') { return ( -
- - -

Only coaches can access roster management.

-
-
+
+ } + title="We couldn't load the roster" + description="Something went wrong fetching your team roster. Refresh the page to try again." + action={ + + } + />
); } diff --git a/src/lib/baseball/nav-context.ts b/src/lib/baseball/nav-context.ts index 488e68285..c2b3c8011 100644 --- a/src/lib/baseball/nav-context.ts +++ b/src/lib/baseball/nav-context.ts @@ -54,10 +54,27 @@ import { type BaseballProgramType, } from '@/lib/types/baseball-settings'; +function normalizeProgramType(raw: unknown): BaseballProgramType | null { + return typeof raw === 'string' && + (BASEBALL_PROGRAM_TYPES as readonly string[]).includes(raw) + ? (raw as BaseballProgramType) + : null; +} + /** - * Read the active team's program_type (the variant engine's selector). RLS-scoped - * read; returns null on any miss so callers fall back to the college-neutral - * default nav order. Never throws into the nav resolve. + * Read the active team's effective program type (the variant engine's selector). + * RLS-scoped read; returns null on any miss so callers fall back to the + * college-neutral default nav order. Never throws into the nav resolve. + * + * FALLBACK (QA nav dead-end fix): `program_type` is nullable and is left unset on + * some teams (e.g. the demo team was created with `team_type: 'college'` but no + * `program_type`). Middleware's recruiting gate treats a null program type as + * "recruiting disabled" and hard-redirects Pipeline / Discover / Watchlist / + * Compare back to the Command Center — while the sidebar still advertised those + * items, producing silent dead links. We resolve the SAME effective program type + * here that the middleware now derives (program_type, then team_type), so nav + * visibility/ordering and route gating read one source of truth and a college + * team is never stripped of its recruiting surfaces. */ async function readActiveProgramType( teamId: string, @@ -65,14 +82,14 @@ async function readActiveProgramType( try { const supabase = await createClient(); const { data } = await fromUntyped(supabase, 'baseball_teams') - .select('program_type') + .select('program_type, team_type') .eq('id', teamId) .maybeSingle(); - const raw = (data as { program_type?: unknown } | null)?.program_type; - return typeof raw === 'string' && - (BASEBALL_PROGRAM_TYPES as readonly string[]).includes(raw) - ? (raw as BaseballProgramType) - : null; + const row = data as { program_type?: unknown; team_type?: unknown } | null; + return ( + normalizeProgramType(row?.program_type) ?? + normalizeProgramType(row?.team_type) + ); } catch { return null; } diff --git a/src/lib/baseball/nav-registry.ts b/src/lib/baseball/nav-registry.ts index 3910aa952..6bcb5bb69 100644 --- a/src/lib/baseball/nav-registry.ts +++ b/src/lib/baseball/nav-registry.ts @@ -290,11 +290,16 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ label: 'Roster', href: '/baseball/dashboard/roster', icon: IconUsers, - // Coaches need can_manage_roster to manage; players read their roster. We - // gate the COACH view on the capability and let players see it ungated by - // splitting role: this single entry is 'both' but only requires the cap for - // staff. requireBaseballCapability is enforced server-side on writes. - role: 'both', + // COACH-ONLY. The /baseball/dashboard/roster surface is the staff roster + // MANAGEMENT workspace: its read model (read-models/roster.ts → isTeamStaff) + // authorizes staff only, and RosterClient renders add-player / lineup / + // export affordances. Advertising it to players routed them straight into a + // "You do not have permission to view this team roster" wall (QA nav dead-end + // — the nav pointed a role at a page it could never use). Players reach their + // teammates/day-to-day surfaces through Today, Schedule, and the Team hub + // (announcements / tasks / documents) instead; there is no player-facing + // roster-management page, so this entry is correctly staff-scoped. + role: 'coach', requiredCapability: null, section: 'primary', }, diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index c09435500..af134f5b8 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -225,10 +225,19 @@ async function checkRouteAuthorization( const { data: team } = await supabase .from('baseball_teams') - .select('program_type') + .select('program_type, team_type') .eq('id', String(staffRow.team_id)) .maybeSingle(); - const programType = normalizeProgramType(team?.program_type); + // Effective program type: prefer the explicit program_type, but fall back to + // the team's team_type when program_type is unset. program_type is nullable and + // some teams (e.g. the demo, created with team_type:'college' but no + // program_type) would otherwise be treated as "recruiting disabled" here and + // have Pipeline/Discover/Watchlist/Compare silently bounced to the Command + // Center — even though the sidebar advertised them. nav-context.ts derives the + // SAME effective type, so nav visibility and route gating share one source of + // truth and a college team is never stripped of its recruiting surfaces. + const programType = + normalizeProgramType(team?.program_type) ?? normalizeProgramType(team?.team_type); if (requiredCapability && !staffHasCapability(staffRow, requiredCapability)) { return { From 73db16b5aae672ac0bf91915dcfc7bbc88e4629d Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:34:43 -0400 Subject: [PATCH 028/186] fix(baseball): resolve head-coach recruiting/scout/camp redirects to command-center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster: capability-redirects. Pipeline/Discover/Watchlist/Compare/Scout-Packets/ Camps silently bounced a real head coach to Command Center. Root cause (data): prod baseball_teams was missing the program_type column — migration 20260624000090 SECTION 1 was never applied to prod (schema drift). The recruiting middleware gate reads baseball_teams.program_type; the SELECT on a non-existent column errored, so programType resolved to null and every recruiting route (which includes scout-packets + camps) hard-redirected to command-center. The head-coach capability row was correct all along (is_head_coach / is_primary / can_export_reports all true), so the page-level caps check was never the problem — the middleware program-type gate fired first. Fixes: - Apply the additive program_type (+ companion) columns to prod, backfilled from team_type (idempotent; matches repo migration). Demo team now resolves 'college'. - middleware: derive programType from the always-present team_type when program_type is null/absent (defense-in-depth resolution — does NOT weaken the gate; a high_school program still fails the recruiting check). - seed: set program_type: 'college' explicitly on the demo team. UI/UX enhancement (GolfHelm-grade) on the now-reachable surfaces: - Camps: drop the legacy layout
that stacked a duplicate search box + avatar under the shell top bar; replace with the premium editorial header (eyebrow → title → subtitle + inline primary action), matching Scout Packets. - Scout Packets: upgrade the thin one-line empty state to a real empty state (icon + heading + copy + "Go to Roster" CTA) and a distinct filter-no-match state. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- scripts/seed-rini-baseball-demo.ts | 5 ++ .../(dashboard)/dashboard/camps/page.tsx | 74 +++++++++++++------ .../passport/ScoutPacketRosterList.tsx | 52 ++++++++++--- src/lib/supabase/middleware.ts | 13 +++- 4 files changed, 110 insertions(+), 34 deletions(-) diff --git a/scripts/seed-rini-baseball-demo.ts b/scripts/seed-rini-baseball-demo.ts index 2b7d97f7c..71275cf45 100644 --- a/scripts/seed-rini-baseball-demo.ts +++ b/scripts/seed-rini-baseball-demo.ts @@ -163,6 +163,11 @@ async function main() { await upsert('baseball_teams', [{ id: TEAM_ID, organization_id: ORG_ID, name: 'Rini University Baseball', team_type: 'college', + // program_type is the recruiting gate's source of truth (middleware.ts). It + // defaults to 'college' at the DB level, but set it explicitly so the demo + // team always resolves the college recruiting suite even on an environment + // where the program_type column backfill hasn't run. + program_type: 'college', join_code: 'RINIBB', primary_color: '#1f6f43', secondary_color: '#0f3d27', description: 'Rini University Baseball — full demo data.', created_by: COACH_ID, }]); diff --git a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx index b09947492..4c9c78fc6 100644 --- a/src/app/baseball/(dashboard)/dashboard/camps/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/camps/page.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, type ReactNode } from 'react'; import Link from 'next/link'; -import { Header } from '@/components/layout/header'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -218,6 +217,36 @@ function CampCard({ ); } +// Lightweight editorial page header. The dashboard shell already renders the +// global top bar (notifications + command palette), so this page must NOT mount +// the legacy layout
— doing so stacked a second search box + avatar +// under the shell bar. This mirrors the premium Scout Packets header pattern: +// eyebrow → title → subtitle, with the primary action inline on the right. +function CampsPageHeader({ + isCoach, + subtitle, + action, +}: { + isCoach: boolean; + subtitle: string; + action?: ReactNode; +}) { + return ( +
+
+

+ Recruiting +

+

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

+

{subtitle}

+
+ {action ?
{action}
: null} +
+ ); +} + export default function CampsPage() { const { user, coach, player } = useAuth(); const { showToast } = useToast(); @@ -354,24 +383,24 @@ export default function CampsPage() { if (loading) { return ( - <> -
+ - +
); } if (loadError) { return ( - <> -
+ -
+
- +
); } return ( <> -
- {isCoach && ( - - )} -
+ setShowCreateModal(true)}> + + Create Camp + + ) : undefined + } + /> {camps.length === 0 ? ( } diff --git a/src/components/baseball/passport/ScoutPacketRosterList.tsx b/src/components/baseball/passport/ScoutPacketRosterList.tsx index 689ba0c91..577955481 100644 --- a/src/components/baseball/passport/ScoutPacketRosterList.tsx +++ b/src/components/baseball/passport/ScoutPacketRosterList.tsx @@ -23,6 +23,7 @@ import { IconLock, IconArrowRight, IconCheckCircle2, + IconUsers, } from '@/components/icons'; import type { ScoutPacketRosterEntry } from '@/app/baseball/actions/scout-packet'; import { Button } from '@/components/ui/button'; @@ -96,16 +97,47 @@ export function ScoutPacketRosterList({ entries }: Props) {
{filtered.length === 0 ? ( - - {entries.length === 0 - ? 'No players on the roster yet.' - : 'No players match this filter.'} - + entries.length === 0 ? ( + + + + +

+ No players on the roster yet +

+

+ Add players to your roster to build source-backed scout packets and share + verified profiles with college coaches. +

+ + Go to Roster + + +
+ ) : ( + + + + +

No players match this filter

+

+ Try a different filter or clear your search. +

+
+ ) ) : (
    {filtered.map((e, idx) => ( diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index c09435500..bd964063b 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -225,10 +225,19 @@ async function checkRouteAuthorization( const { data: team } = await supabase .from('baseball_teams') - .select('program_type') + .select('program_type, team_type') .eq('id', String(staffRow.team_id)) .maybeSingle(); - const programType = normalizeProgramType(team?.program_type); + // Resolve the program mode with a defense-in-depth fallback: program_type is + // the controlling setting, but if it is ever null/absent (e.g. a team created + // before the program_type column was backfilled, or a transient read gap) we + // derive it from the always-present team_type enum. college/high_school/juco/ + // showcase are all valid program_type values, so a real coach is never + // silently stripped of recruiting just because program_type wasn't populated. + // This resolves the mode; it does NOT weaken the gate — a high_school program + // still fails the recruiting check below exactly as before. + const programType = + normalizeProgramType(team?.program_type) ?? normalizeProgramType(team?.team_type); if (requiredCapability && !staffHasCapability(staffRow, requiredCapability)) { return { From fcc98b7923e1ebab34a90dd0aa8a4c332be2d8bc Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:34:45 -0400 Subject: [PATCH 029/186] fix(baseball): reconcile Player Today lifts, fix coach-notes read, unclip lift names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player Today (src/app/baseball/player/today) contradicted itself and showed a spurious error banner on a fully-seeded team. Root causes + fixes: - Coach-notes error banner ("Your coach notes could not be loaded"): the read-model filtered baseball_coach_notes on `.is('deleted_at', null)`, but that column does not exist — the soft-delete column is `archived_at`. Every read failed. Verified live schema: the table DOES have title/pinned/archived_at (the old comment claiming otherwise was wrong). Now filters archived_at, selects the real title/pinned columns, and orders pinned-first. - Lift contradiction (right rail "2 overdue" vs body "0 lifts due" / tile 0): the assignments query started its window at today (`.gte('scheduled_date', day)`), so overdue-but-open sessions the right-rail Lift & Check-in card shows never reached the "Lifts Due" section or the summary tile. The feed now uses the same open-through-horizon window as the card, adds an `isOverdue` flag, sorts overdue/today/upcoming, and a new `assignmentsDue` summary (today + overdue) drives the tile so the two surfaces always agree. Tile relabeled "Lifts Due". - Truncated lift names ("To..."/"U..."): the right-rail row put title, badge and chevron on one axis in the narrow rail, clipping the title to two chars. Title now shares a row with the status badge and wraps to two lines; overdue rows get a red accent + "Overdue" badge (date line no longer repeats it). - Also: 4-up daily check-in grid collapsed to 2-up (was colliding to "ENERGY SORENE&&BM" in the narrow rail), and removed em-dash accents from player-facing copy per the anti-slop typography bar. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../baseball/performance/PlayerLiftToday.tsx | 55 +++++++++----- .../player-today/PlayerTodayClient.tsx | 52 +++++++++----- src/lib/baseball/read-models/player-today.ts | 71 ++++++++++++++----- 3 files changed, 129 insertions(+), 49 deletions(-) diff --git a/src/components/baseball/performance/PlayerLiftToday.tsx b/src/components/baseball/performance/PlayerLiftToday.tsx index e179eabb1..8cb8d705f 100644 --- a/src/components/baseball/performance/PlayerLiftToday.tsx +++ b/src/components/baseball/performance/PlayerLiftToday.tsx @@ -226,7 +226,7 @@ export default function PlayerLiftToday({
-
+
@@ -287,12 +287,10 @@ export default function PlayerLiftToday({ OPEN_STATUSES.includes(s.status) && s.scheduled_date != null && s.scheduled_date < today; + // The "Overdue" state is now carried by the status badge, so the + // date line just states the day (no redundant "Overdue ·" prefix). const dateLabel = - s.scheduled_date === today - ? 'Today' - : isOverdue - ? `Overdue · ${s.scheduled_date}` - : s.scheduled_date; + s.scheduled_date === today ? 'Today' : s.scheduled_date; // A coach-initiated modification (e.g. converted from a Signal) // carries the WHY in coach_note. Surfacing it inline turns a bare // "Adjusted" badge into a real coach->player message so the player @@ -307,14 +305,40 @@ export default function PlayerLiftToday({ href={`/baseball/dashboard/lift/${s.id}`} className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-warm-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500/40 lg:px-6" > -
- +
+
+ {/* The title now shares its OWN row with the status badge and + is allowed to wrap to two lines. The previous layout put the + title, badge and chevron on one horizontal axis inside the + narrow right rail, squeezing the title to "To…" / "U…". */}
-

- {s.title || 'Lift'} -

-

+

+

+ {s.title || 'Lift'} +

+ + {isOverdue ? 'Overdue' : badge.label} + +
+

{dateLabel} {s.estimated_minutes ? ` · ~${s.estimated_minutes} min` : ''}

@@ -327,12 +351,9 @@ export default function PlayerLiftToday({

)}
- - {badge.label} - ); @@ -354,7 +375,7 @@ export default function PlayerLiftToday({

-
+
0 ? 'bg-amber-50' : coachOpen > 0 ? 'bg-primary-50' : 'bg-warm-100', }, { - label: 'Lifts Today', - value: model.summary.assignmentsToday, + // "Lifts Due" (not "Lifts Today"): counts sessions the player still owes — + // today PLUS overdue-but-open — so this tile agrees with the right-rail + // Lift & Check-in card instead of reading 0 while the card shows overdue. + label: 'Lifts Due', + value: liftsDue, icon: , - tone: liftsOpen > 0 ? 'text-primary-600' : 'text-warm-600', - bg: liftsOpen > 0 ? 'bg-primary-50' : 'bg-warm-100', + tone: liftsDue > 0 ? 'text-primary-600' : 'text-warm-600', + bg: liftsDue > 0 ? 'bg-primary-50' : 'bg-warm-100', }, ]; @@ -436,24 +439,41 @@ function AssignmentsSection({
    {feed.items.map((a: PlayerTodayAssignment) => { - const badge = LIFT_STATUS_LABEL[a.status] ?? { - label: prettyLabel(a.status), - cls: 'bg-warm-100 text-warm-600', - }; + const badge = a.isOverdue + ? { label: 'Overdue', cls: 'bg-red-100 text-red-700' } + : (LIFT_STATUS_LABEL[a.status] ?? { + label: prettyLabel(a.status), + cls: 'bg-warm-100 text-warm-600', + }); + const dateLabel = a.isToday + ? 'Today' + : a.isOverdue + ? `Overdue · ${a.scheduledDate}` + : a.scheduledDate; return (
  • - +

    {a.title}

    -

    - {a.isToday ? 'Today' : a.scheduledDate} +

    + {dateLabel} {a.estimatedMinutes ? ` · ~${a.estimatedMinutes} min` : ''} {a.baseballContext ? ` · ${prettyLabel(a.baseballContext)}` : ''}

    @@ -619,7 +639,7 @@ function CoachAssignmentCard({ )} {completed && (

    - Nice work — your coach can see this is done. + Nice work. Your coach can see this is done.

    )}
    @@ -1579,7 +1599,7 @@ export function PlayerTodayClient({ // Derived values for first-viewport hero + CTA row const hasPendingAck = model.summary.eventsPendingAck > 0; - const hasLiftToday = model.summary.assignmentsToday > 0; + const hasLiftToday = model.summary.assignmentsDue > 0; const practiceId = model.practiceGroup.practiceId; // activeRole is always 'player' in this player-dashboard route group (coaches @@ -1600,7 +1620,7 @@ export function PlayerTodayClient({ {longDate}

    - Your daily rundown — schedule, recent work, and what needs your attention. + Your daily rundown: schedule, recent work, and what needs your attention.

    {/* Primary CTA row (spec lines 72-76): Check In · Acknowledge · View Today Plan. @@ -1725,7 +1745,7 @@ export function PlayerTodayClient({ } title="My Timeline" />

    - Your full development story — stats, notes, and coach insights in order. + Your full development story: stats, notes, and coach insights in order.

    = today), so an overdue lift showed on the right-rail + // card but NOT in the "Lifts Due" section or the summary tile — the page + // contradicted itself (card: 2 overdue / body: 0 due). We now use the same + // "open + not past horizon" window as the card so both surfaces agree. // Degrades to an honest empty result when the team has no lifting // organization or this player has no helm_lifting_athletes row yet. liftCtx && athleteId @@ -582,7 +596,6 @@ export async function getPlayerToday( ) .eq('athlete_id', athleteId) .eq('organization_id', liftCtx.organizationId) - .gte('scheduled_date', day) .lte('scheduled_date', horizonYmd) .in('status', ['assigned', 'started', 'modified']) .order('scheduled_date', { ascending: true }) @@ -644,18 +657,23 @@ export async function getPlayerToday( .order('task_id', { ascending: true }) .limit(20), // Player-visible coach notes (spec line 85): baseball_coach_notes where - // scope = 'player_visible' AND player_id = me AND team_id = teamId AND - // NOT soft-deleted (deleted_at is null). Staff-only scopes are NEVER shown. - // Real columns only (verified against migration 20260624000900 / - // BaseballCoachNoteRow): the table has no title, pinned, or archived_at - // column — selecting/filtering on those made this query fail outright (#507). + // scope = 'player_visible' AND player_id = me AND team_id = teamId AND NOT + // archived (archived_at is null). Staff-only scopes are NEVER shown. + // Columns verified live against the deployed table: it DOES have + // title / pinned / archived_at — the soft-delete column is `archived_at`, + // NOT `deleted_at`. The previous query filtered `.is('deleted_at', null)` + // on a non-existent column, which made every read fail and surfaced the + // spurious "Your coach notes could not be loaded." banner on Today. We now + // select the real title/pinned columns so the note cards render with their + // heading + pin state instead of stripping them to null. supabase .from('baseball_coach_notes') - .select('id, body, created_at') + .select('id, title, body, pinned, created_at') .eq('player_id', playerId) .eq('team_id', teamId) .eq('scope', 'player_visible') - .is('deleted_at', null) + .is('archived_at', null) + .order('pinned', { ascending: false }) .order('created_at', { ascending: false }) .limit(5), // Practice group (spec line 84): today's published practice plan for the @@ -802,6 +820,10 @@ export async function getPlayerToday( baseballContext: r.sport_context, estimatedMinutes: r.estimated_minutes, isToday: r.scheduled_date === day, + // Scheduled before today and still open (the query only returns + // assigned/started/modified) → overdue. Surfaced with an "Overdue" label + // so the player can lead with what they still owe. + isOverdue: r.scheduled_date < day, // The lift session row is the source object (source -> signal -> action: // opening the session is the action). sourceId cites the session id. sourceRef: buildSourceRef({ @@ -810,6 +832,16 @@ export async function getPlayerToday( label: 'Lift session', }), })); + // Surface what's owed first: overdue, then today, then upcoming — matching + // the right-rail card's "open sessions first" ordering. + items.sort((a, b) => { + const rank = (x: PlayerTodayAssignment) => + x.isOverdue ? 0 : x.isToday ? 1 : 2; + const ra = rank(a); + const rb = rank(b); + if (ra !== rb) return ra - rb; + return a.scheduledDate.localeCompare(b.scheduledDate); + }); assignments = { available: true, items, @@ -827,6 +859,12 @@ export async function getPlayerToday( const assignmentsOpen = assignments.items.filter( (a) => a.isToday && !TERMINAL_LIFT_STATUSES.has(a.status), ).length; + // "Due" = everything the player still owes right now: due today PLUS overdue. + // This is the figure the right-rail Lift & Check-in card shows, so the summary + // tile and the card can never disagree (the reconciliation the QA flagged). + const assignmentsDue = assignments.items.filter( + (a) => (a.isToday || a.isOverdue) && !TERMINAL_LIFT_STATUSES.has(a.status), + ).length; // ---- Coach-assigned actions (player side of source -> signal -> action) ---- let coachActions: PlayerActionsFeed = emptyCoachActions( @@ -1102,17 +1140,17 @@ export async function getPlayerToday( } else { const noteRows = (coachNotesRes?.data ?? []) as Array<{ id: string; + title: string | null; body: string; + pinned: boolean | null; created_at: string; }>; - // baseball_coach_notes has no title/pinned columns — always render an - // honest null/false rather than a value the table never actually stores. const noteItems: PlayerTodayCoachNote[] = noteRows.map((n) => ({ id: n.id, body: n.body, - title: null, + title: n.title ?? null, createdAt: n.created_at, - pinned: false, + pinned: n.pinned ?? false, })); coachNotes = { available: true, @@ -1199,6 +1237,7 @@ export async function getPlayerToday( recentStatCount: recentStats.length, assignmentsToday, assignmentsOpen, + assignmentsDue, coachActionsOpen, coachActionsDue, readinessNeedsAttention, From dc077dedca7105f8d363ad392510c705ecbed628 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:34:50 -0400 Subject: [PATCH 030/186] fix(baseball): repair Stats Center saved-views crash + elevate empty states Root cause: stat-visual-views.ts queried a hypothetical owner_user_id / visual_key / view_state shape from a scaffold migration that was never applied. The deployed baseball_stat_visual_views table uses created_by_coach_id / view_name / config_json (staff-scoped RLS, no unique constraint), so getStatVisualViews threw `column baseball_stat_visual_views.owner_user_id does not exist`, which the gallery hook surfaced as an error toast overlapping the Stats Center grid. - Rewrite all four actions against the real deployed schema, aliasing view_name->visual_key and config_json->view_state so the gallery contract is unchanged. Ownership stamps from ctx.activeCoachId; writes fail closed when no coach is resolved (RLS is staff-scoped). Manual non-destructive upsert (SELECT owning row -> UPDATE in place, else INSERT) since the table has no unique constraint to onConflict against. - Update the hand-written BaseballStatVisualView type to the real columns. - StatVisualsSection: collapse a family's wall of identical "no captured events" frames into ONE premium, on-brand empty state (glass card, Import CTA) per family; charts render in full the moment a read model feeds data. Add a source-provenance subtitle. - StatsCenterClient: when every rostered player has zero captured box-score lines, collapse the grid of N identical empty cards into a single team-level empty state with an Import CTA (per QA finding), instead of a screen of dead cards. Note: WITH DATA remains 0 because Stats Center reads box scores (baseball_box_score_batting/_pitching), which are empty; the seeded 244 rows live in the legacy baseball_player_stats table. Populating production is a data-layer (box-score seed) follow-up, out of scope for this presentation cluster. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/app/baseball/actions/stat-visual-views.ts | 232 ++++++++++++------ .../stat-visuals/StatVisualsSection.tsx | 103 +++++++- .../stats-center/StatsCenterClient.tsx | 25 ++ src/lib/types/baseball-stat-visuals.ts | 17 +- 4 files changed, 295 insertions(+), 82 deletions(-) diff --git a/src/app/baseball/actions/stat-visual-views.ts b/src/app/baseball/actions/stat-visual-views.ts index 422bb3a25..e838cc3fd 100644 --- a/src/app/baseball/actions/stat-visual-views.ts +++ b/src/app/baseball/actions/stat-visual-views.ts @@ -6,31 +6,42 @@ import { fromUntyped } from '@/lib/supabase/untyped'; // // Packet: stat-visuals (BaseballHelm — stats-integrations) // -// Server actions that WIRE the additive baseball_stat_visual_views table -// (migration 20260624000091) to the V10 stat-visual gallery. They persist the -// "saved chart filter state + pinned charts" capability the visual contracts -// call for (v10_baseball_stat_visual_contracts.md §"Source-Linked Trend Ribbon" -// / §"Player DNA Panel" + v10_premium_ui_system_by_tab.md §"Stats Lab" and -// §"Player Profile / Snapshot Cards") — until now the table/type/RLS were -// scaffolded but no code read or wrote them. +// Server actions that WIRE the baseball_stat_visual_views table to the V10 +// stat-visual gallery. They persist the "saved chart filter state + pinned +// charts" capability the visual contracts call for (v10_baseball_stat_visual_ +// contracts.md §"Source-Linked Trend Ribbon" / §"Player DNA Panel"). +// +// SCHEMA (the DEPLOYED table — verified against information_schema, NOT the +// never-applied scaffold migration): +// id, team_id, player_id, created_by_coach_id (→ baseball_coaches.id), +// view_name (text, NOT NULL — we store the stable chart key here), +// view_type / period_type / visibility / stat_keys (defaulted), +// config_json (jsonb — we store the serialized filter/tab state here), +// is_pinned, is_template, created_at, updated_at. +// +// The earlier revision of this file queried a hypothetical `owner_user_id` / +// `visual_key` / `view_state` shape from a scaffold migration that was never +// applied, which threw `column baseball_stat_visual_views.owner_user_id does +// not exist` and broke the Stats Center gallery. This revision reads/writes the +// real columns and aliases them back to the gallery's contract (visual_key / +// view_state) via PostgREST column aliases, so the client contract is unchanged. // // SECURITY / CONTRACT // * Every action runs inside withBaseballAction so auth + the server-validated // active baseball team/role are resolved before any query. The wrapper // sanitizes thrown errors so raw DB internals never reach the client. -// * Authorization for THIS table is per-user personalization, not a staff -// capability: saved views/pins must work for BOTH staff (Stats Center) AND -// players (player-profile pins, who hold no staff capability). So we set NO -// requiredCapability and rely on the table's OWNER-scoped + team-scoped RLS -// (owner_user_id = auth.uid() AND is_baseball_team_member(team_id)) for -// server-side enforcement — a user can only ever read/write their OWN rows -// on a team they belong to. -// * owner_user_id + team_id are stamped from the resolved server context on -// every write (never trusted from the client), so a row can't be written -// against another user or team. -// * NO destructive writes: the save path UPSERTs on the -// (owner_user_id, visual_key, player_id) UNIQUE constraint; pin/unpin and -// delete are explicit in-place operations a user performs on their own row. +// * The deployed table's RLS is STAFF-scoped for writes +// (is_baseball_team_staff(team_id)); players may only SELECT player-visible +// rows scoped to their own player_id. So writes require a resolved +// activeCoachId — we fail closed with an honest message otherwise, and RLS +// is the real enforcement boundary regardless. +// * created_by_coach_id + team_id are stamped from the resolved server context +// on every write (never trusted from the client), so a row can't be written +// against another coach or team. +// * NO destructive writes: the save/pin path is a non-destructive manual +// upsert (SELECT the owning row → UPDATE in place, else INSERT). There is no +// unique constraint on the deployed table to `onConflict` against, so we +// never delete-then-insert. // * revalidatePath refreshes the two surfaces that mount the gallery. // ============================================================================= @@ -47,9 +58,16 @@ const FEATURE = { featureArea: 'baseball-stat-visuals' } as const; const STATS_CENTER_PATH = '/baseball/dashboard/stats-center'; const PLAYER_STATS_PATH = '/baseball/dashboard/players'; +const TABLE = 'baseball_stat_visual_views'; + +// The gallery's contract (visual_key / view_state) mapped onto the deployed +// columns via PostgREST aliases so callers keep the stable field names. +const SELECT_COLS = + 'id, team_id, player_id, created_by_coach_id, is_pinned, created_at, updated_at, visual_key:view_name, view_state:config_json'; + type ActionResult = { success: boolean; error?: string; data?: T }; -/** Bound the visual_key to the table CHECK (1..80 chars) before it hits the DB. */ +/** Bound the visual_key to the column length (1..80 chars) before it hits the DB. */ function normalizeVisualKey(key: string): string | null { const trimmed = key.trim(); if (trimmed.length < 1 || trimmed.length > 80) return null; @@ -57,9 +75,9 @@ function normalizeVisualKey(key: string): string | null { } // ----------------------------------------------------------------------------- -// READ — the saved views (and pins) the current user owns, optionally scoped to -// a single player profile. RLS already restricts to owner + team; the explicit -// filters keep payloads small and let the player profile ask only for its pins. +// READ — the saved views (and pins) the current coach owns, optionally scoped to +// a single player profile. RLS already restricts to team staff (or a player's +// own visible rows); the explicit filters keep payloads small and per-coach. // ----------------------------------------------------------------------------- export const getStatVisualViews = withBaseballAction( @@ -71,13 +89,16 @@ export const getStatVisualViews = withBaseballAction( ): Promise> => { const supabase = await createClient(); - let query = fromUntyped(supabase, 'baseball_stat_visual_views') - .select( - 'id, team_id, owner_user_id, visual_key, player_id, view_state, is_pinned, created_at, updated_at', - ) - .eq('owner_user_id', ctx.user.id) + let query = fromUntyped(supabase, TABLE) + .select(SELECT_COLS) .eq('team_id', ctx.targetTeamId); + // Coaches see only their own saved views; a player (no activeCoachId) falls + // back to what RLS exposes (their player-visible rows). + if (ctx.activeCoachId) { + query = query.eq('created_by_coach_id', ctx.activeCoachId); + } + // Player profile asks for its player-scoped rows; team Stats Center asks for // team-scoped rows (player_id IS NULL) so a player's pins don't bleed in. if (input?.playerId) { @@ -94,11 +115,13 @@ export const getStatVisualViews = withBaseballAction( ); // ----------------------------------------------------------------------------- -// SAVE — upsert a user's filter/tab state for one chart (no delete-then-insert). +// SAVE — non-destructive manual upsert of a coach's filter/tab state for one +// chart. There is no unique constraint on the deployed table, so we resolve the +// owning row first and UPDATE it in place (never delete-then-insert). // ----------------------------------------------------------------------------- export interface SaveStatVisualViewInput { - /** Stable chart key, e.g. 'ev_la_matrix', 'player_dna', or a family tab key. */ + /** Stable chart key, e.g. 'family:hitting' or 'ev_la_matrix'. Stored in view_name. */ visualKey: string; /** Serialized filter/tab state (context filter, pitch-type tab, date window). */ viewState: Json; @@ -108,6 +131,28 @@ export interface SaveStatVisualViewInput { isPinned?: boolean; } +/** + * Find the coach's existing saved-view row id for (visual_key, player scope). + * Returns null when none exists yet. Scoped to the resolved coach + team. + */ +async function findOwnedRowId( + supabase: Awaited>, + coachId: string, + teamId: string, + visualKey: string, + playerId: string | null, +): Promise<{ id: string | null; error: unknown }> { + let q = fromUntyped(supabase, TABLE) + .select('id') + .eq('created_by_coach_id', coachId) + .eq('team_id', teamId) + .eq('view_name', visualKey); + q = playerId ? q.eq('player_id', playerId) : q.is('player_id', null); + + const { data, error } = await q.maybeSingle(); + return { id: (data as { id: string } | null)?.id ?? null, error }; +} + export const saveStatVisualView = withBaseballAction( 'saveStatVisualView', FEATURE, @@ -115,41 +160,66 @@ export const saveStatVisualView = withBaseballAction( ctx, input: SaveStatVisualViewInput, ): Promise> => { - const supabase = await createClient(); + const coachId = ctx.activeCoachId; + if (!coachId) { + return { success: false, error: 'Only coaching staff can save chart views.' }; + } const visualKey = normalizeVisualKey(input.visualKey); if (!visualKey) return { success: false, error: 'Invalid chart key.' }; + const supabase = await createClient(); + const playerId = input.playerId ?? null; const nowIso = new Date().toISOString(); - const row = { - team_id: ctx.targetTeamId, - owner_user_id: ctx.user.id, - visual_key: visualKey, - player_id: input.playerId ?? null, - view_state: input.viewState ?? {}, - ...(input.isPinned !== undefined ? { is_pinned: input.isPinned } : {}), - updated_at: nowIso, - }; - - const { data, error } = await fromUntyped(supabase, 'baseball_stat_visual_views') - .upsert(row, { - onConflict: 'owner_user_id,visual_key,player_id', - ignoreDuplicates: false, + + const { id: existingId, error: findErr } = await findOwnedRowId( + supabase, + coachId, + ctx.targetTeamId, + visualKey, + playerId, + ); + if (findErr) return { success: false, error: sanitizeDbError(findErr, 'stats') }; + + if (existingId) { + const { error } = await fromUntyped(supabase, TABLE) + .update({ + config_json: input.viewState ?? {}, + ...(input.isPinned !== undefined ? { is_pinned: input.isPinned } : {}), + updated_at: nowIso, + }) + .eq('id', existingId); + if (error) return { success: false, error: sanitizeDbError(error, 'stats') }; + + revalidatePath(STATS_CENTER_PATH); + if (playerId) revalidatePath(`${PLAYER_STATS_PATH}/${playerId}`); + return { success: true, data: { viewId: existingId } }; + } + + const { data, error } = await fromUntyped(supabase, TABLE) + .insert({ + team_id: ctx.targetTeamId, + created_by_coach_id: coachId, + view_name: visualKey, + config_json: input.viewState ?? {}, + player_id: playerId, + is_pinned: input.isPinned ?? false, + updated_at: nowIso, }) .select('id') .single(); if (error) return { success: false, error: sanitizeDbError(error, 'stats') }; revalidatePath(STATS_CENTER_PATH); - if (input.playerId) revalidatePath(`${PLAYER_STATS_PATH}/${input.playerId}`); + if (playerId) revalidatePath(`${PLAYER_STATS_PATH}/${playerId}`); return { success: true, data: { viewId: (data as { id: string }).id } }; }, ); // ----------------------------------------------------------------------------- -// PIN — flip whether a chart is pinned to the owner's snapshot. Upserts so a -// pin can be set even before any filter state was saved (no destructive write). +// PIN — flip whether a chart is pinned to the coach's snapshot. Non-destructive +// upsert so a pin can be set even before any filter state was saved. // ----------------------------------------------------------------------------- export const setStatVisualPinned = withBaseballAction( @@ -159,34 +229,53 @@ export const setStatVisualPinned = withBaseballAction( ctx, input: { visualKey: string; isPinned: boolean; playerId?: string | null }, ): Promise => { - const supabase = await createClient(); + const coachId = ctx.activeCoachId; + if (!coachId) { + return { success: false, error: 'Only coaching staff can pin chart views.' }; + } const visualKey = normalizeVisualKey(input.visualKey); if (!visualKey) return { success: false, error: 'Invalid chart key.' }; - const { error } = await fromUntyped(supabase, 'baseball_stat_visual_views') - .upsert( - { - team_id: ctx.targetTeamId, - owner_user_id: ctx.user.id, - visual_key: visualKey, - player_id: input.playerId ?? null, - is_pinned: input.isPinned, - updated_at: new Date().toISOString(), - }, - { onConflict: 'owner_user_id,visual_key,player_id', ignoreDuplicates: false }, - ); - if (error) return { success: false, error: sanitizeDbError(error, 'stats') }; + const supabase = await createClient(); + const playerId = input.playerId ?? null; + const nowIso = new Date().toISOString(); + + const { id: existingId, error: findErr } = await findOwnedRowId( + supabase, + coachId, + ctx.targetTeamId, + visualKey, + playerId, + ); + if (findErr) return { success: false, error: sanitizeDbError(findErr, 'stats') }; + + if (existingId) { + const { error } = await fromUntyped(supabase, TABLE) + .update({ is_pinned: input.isPinned, updated_at: nowIso }) + .eq('id', existingId); + if (error) return { success: false, error: sanitizeDbError(error, 'stats') }; + } else { + const { error } = await fromUntyped(supabase, TABLE).insert({ + team_id: ctx.targetTeamId, + created_by_coach_id: coachId, + view_name: visualKey, + player_id: playerId, + is_pinned: input.isPinned, + updated_at: nowIso, + }); + if (error) return { success: false, error: sanitizeDbError(error, 'stats') }; + } revalidatePath(STATS_CENTER_PATH); - if (input.playerId) revalidatePath(`${PLAYER_STATS_PATH}/${input.playerId}`); + if (playerId) revalidatePath(`${PLAYER_STATS_PATH}/${playerId}`); return { success: true }; }, ); // ----------------------------------------------------------------------------- -// DELETE — drop a user's own saved view (explicit user action on their own row). +// DELETE — drop a coach's own saved view (explicit action on their own row). // ----------------------------------------------------------------------------- export const deleteStatVisualView = withBaseballAction( @@ -196,16 +285,21 @@ export const deleteStatVisualView = withBaseballAction( ctx, input: { visualKey: string; playerId?: string | null }, ): Promise => { - const supabase = await createClient(); + const coachId = ctx.activeCoachId; + if (!coachId) { + return { success: false, error: 'Only coaching staff can remove chart views.' }; + } const visualKey = normalizeVisualKey(input.visualKey); if (!visualKey) return { success: false, error: 'Invalid chart key.' }; - let query = fromUntyped(supabase, 'baseball_stat_visual_views') + const supabase = await createClient(); + + let query = fromUntyped(supabase, TABLE) .delete() - .eq('owner_user_id', ctx.user.id) + .eq('created_by_coach_id', coachId) .eq('team_id', ctx.targetTeamId) - .eq('visual_key', visualKey); + .eq('view_name', visualKey); query = input.playerId ? query.eq('player_id', input.playerId) : query.is('player_id', null); diff --git a/src/components/baseball/stat-visuals/StatVisualsSection.tsx b/src/components/baseball/stat-visuals/StatVisualsSection.tsx index 82f19057f..3d8cee2cc 100644 --- a/src/components/baseball/stat-visuals/StatVisualsSection.tsx +++ b/src/components/baseball/stat-visuals/StatVisualsSection.tsx @@ -27,9 +27,16 @@ // ============================================================================= import * as React from 'react'; +import Link from 'next/link'; import { cn } from '@/lib/utils'; import type { Json } from '@/lib/types'; -import { IconStar, IconStarFilled, IconBookmark } from '@/components/icons'; +import { + IconStar, + IconStarFilled, + IconBookmark, + IconChartBar, + IconArrowRight, +} from '@/components/icons'; import { TabStrip } from './chart-primitives'; import { // hitting @@ -153,6 +160,74 @@ const PLAYER_FAMILIES: { value: Family; label: string }[] = [ { value: 'performance', label: 'Performance' }, ]; +/** + * Whether a family has ANY source-backed points to draw. Drives the collapse + * from a wall of identical "no captured events" frames (which reads as broken on + * a seeded team) into ONE honest, premium family empty state. As soon as a read + * model feeds a family real inputs, its charts render in full. + */ +function familyHasData(family: Family, data: StatVisualsData): boolean { + const some = (...arrs: (unknown[] | undefined)[]) => + arrs.some((a) => Array.isArray(a) && a.length > 0); + switch (family) { + case 'hitting': + return some(data.evLa, data.zoneCells, data.spray, data.countLadder, data.gamePracticeGap); + case 'pitching': + return some(data.pitchShape, data.command, data.decaySegments, data.pitchMix, data.release); + case 'fielding': + return some(data.defensiveEvents, data.catcherWorkload, data.battery); + case 'baserunning': + return some(data.baserunning); + case 'performance': + return some(data.readiness, data.liftProgression, data.pitcherWorkload); + case 'quality': + return some(data.sourceCoverage, data.practiceFocus, data.importDiff); + case 'dna': + return some(data.playerDna); + default: + return false; + } +} + +/** + * One honest, on-brand empty surface for a whole family — a single glass card + * that teaches what unlocks the charts, instead of a dead grid of empty frames. + */ +function FamilyEmptyState({ label, scope }: { label: string; scope: Scope }) { + return ( +
    +
    + + + +
    +

    + {label} charts light up with captured events +

    +

    + {scope === 'player' + ? 'Pitch-by-pitch, batted-ball, and workload events for this player render here as they’re captured from a connected source or uploaded box score.' + : 'These source-backed visuals draw from pitch-by-pitch, batted-ball, and workload events. Import a box score or connect a source and they populate automatically.'} +

    +
    + {scope === 'team' && ( + + Import stats + + + )} +
    +
    + ); +} + // ----------------------------------------------------------------------------- // Saved-view persistence — wires the additive baseball_stat_visual_views table // (migration 20260624000091). The gallery stays presentational: the parent owns @@ -246,6 +321,10 @@ export function StatVisualsSection({ const [family, setFamily] = React.useState(initialFamily); const open = onOpenSources; + const activeFamilyLabel = + families.find((f) => f.value === family)?.label ?? 'These'; + const activeFamilyHasData = familyHasData(family, data); + const canPersist = Boolean(onSaveView); const activeKey = familyVisualKey(family); const isPinned = savedByKey.get(activeKey)?.is_pinned ?? false; @@ -278,6 +357,10 @@ export function StatVisualsSection({

    Source-backed charts

    +

    + Every point traces back to a captured event — click a datum to open + its source. +

- {family === 'dna' && ( + {!activeFamilyHasData && ( + + )} + + {activeFamilyHasData && family === 'dna' && ( )} - {family === 'hitting' && ( + {activeFamilyHasData && family === 'hitting' && ( <> )} - {family === 'pitching' && ( + {activeFamilyHasData && family === 'pitching' && ( <> )} - {family === 'fielding' && ( + {activeFamilyHasData && family === 'fielding' && ( <> @@ -379,11 +466,11 @@ export function StatVisualsSection({ )} - {family === 'baserunning' && ( + {activeFamilyHasData && family === 'baserunning' && ( )} - {family === 'performance' && ( + {activeFamilyHasData && family === 'performance' && ( <> @@ -394,7 +481,7 @@ export function StatVisualsSection({ )} - {family === 'quality' && ( + {activeFamilyHasData && family === 'quality' && ( <> diff --git a/src/components/baseball/stats-center/StatsCenterClient.tsx b/src/components/baseball/stats-center/StatsCenterClient.tsx index c0f3e69ab..9f9136b29 100644 --- a/src/components/baseball/stats-center/StatsCenterClient.tsx +++ b/src/components/baseball/stats-center/StatsCenterClient.tsx @@ -833,6 +833,10 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis const hasActiveFilters = positions.length > 0 || side !== 'both'; const rows = model.rows; + // When EVERY rostered player has zero captured box-score lines, a grid of N + // identical "no data" cards reads as broken. Collapse to one honest, premium + // team-level empty state (the moment any player has a line, the grid returns). + const allNoData = rows.length > 0 && rows.every((r) => r.noData); const handleExport = useCallback(() => { const csv = buildCsv(rows, gameSet); @@ -1036,6 +1040,27 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis : { label: 'Go to roster', href: '/baseball/dashboard/roster' } } /> + ) : allNoData ? ( + } + title={ + hasActiveFilters + ? 'No box scores for this filter yet' + : `No box scores captured for ${model.seasonYear} yet` + } + description={ + hasActiveFilters + ? `All ${rows.length} matching players are without a captured line in this game set. Clear the filter or import a box score to populate production.` + : `Your ${rows.length}-player roster is set, but no box-score lines have been captured this season. Import a box score or enter one to light up team-wide production, splits, and the source-backed charts below.` + } + action={{ label: 'Import stats', href: '/baseball/dashboard/import' }} + secondaryAction={ + hasActiveFilters + ? { label: 'Clear filters', onClick: clearFilters } + : { label: 'Go to roster', href: '/baseball/dashboard/roster' } + } + /> ) : (
Date: Wed, 1 Jul 2026 14:35:52 -0400 Subject: [PATCH 031/186] fix(baseball): render calendar events + de-ambiguate calendar toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the empty calendar: the baseball calendar page and the create/update event actions selected/wrote a `requires_rsvp` column that does NOT exist on `baseball_events`. PostgREST rejected the whole query, so `.data` came back null and the grid rendered empty even with seeded events (incl. the Jun 28 Coastal State game) — and "Add Event" silently failed the same way. The in-code comment claiming the column exists was wrong (verified against the live schema). - Calendar page: drop `requires_rsvp` from the select; add the real `all_day`/`status`/`recurring` columns and normalize all-day events to local midnight so they land in the all-day rail. Fixes empty calendar for BOTH coach and player (shared query). - Calendar actions: stop writing `requires_rsvp` on insert/update; persist `all_day` instead. RSVP intent stays modeled in baseball_event_attendance. - WeekView: make the sticky day-of-week header near-opaque (bg-cream/95) so the scrolled "11 AM" time-axis label no longer bleeds through it. - Calendar toolbar globe: was `variant="primary"` (solid green) competing with the Add Event CTA — now a de-emphasized ghost control with a visible tooltip + always-present accessible label. - Event ribbons: add game/scrimmage/camp/tryout (and previously-missing qualifier/travel) type→color mappings + tokens so baseball events get a distinct left-border accent instead of the neutral "other" grey. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../(dashboard)/dashboard/calendar/page.tsx | 66 ++++++++++++------- src/app/baseball/actions/calendar.ts | 14 ++-- src/components/golf/calendar/WeekView.tsx | 6 +- src/components/ui/page-header.tsx | 22 ++++--- src/lib/calendar/premium-utils.ts | 8 +++ src/styles/calendar-tokens.css | 12 +++- 6 files changed, 90 insertions(+), 38 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx index 2991cf250..1e9aa8949 100644 --- a/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/calendar/page.tsx @@ -74,10 +74,17 @@ export default async function BaseballCalendarPage() { if (teamId) { const [eventsResult, membersResult, teamOrgResult] = await Promise.all([ - // Read via fromUntyped because requires_rsvp is not yet in the generated - // baseball_events types (schema drift — the column exists in the DB). + // Read via fromUntyped so the select is not type-checked against the + // generated baseball_events types (which drift from the live schema). + // + // IMPORTANT: the column list MUST match the live baseball_events schema. + // A previous revision selected `requires_rsvp`, which does NOT exist on + // baseball_events — PostgREST rejected the whole query, so `.data` came + // back null and the calendar rendered EMPTY even with seeded events. + // `all_day` / `status` / `recurring` DO exist and are needed so the grid + // places all-day events correctly and dims cancelled ones. fromUntyped(supabase, 'baseball_events') - .select('id, team_id, title, event_type, start_time, end_time, location, description, is_mandatory, max_attendees, rsvp_deadline, requires_rsvp, created_by') + .select('id, team_id, title, event_type, start_time, end_time, location, description, is_mandatory, max_attendees, rsvp_deadline, all_day, status, recurring, created_by') .eq('team_id', teamId) .order('start_time', { ascending: true }), supabase @@ -93,7 +100,9 @@ export default async function BaseballCalendarPage() { ]); // Map baseball_events → CalendarEvent. Row is annotated because the query - // uses fromUntyped (requires_rsvp not yet in generated types). + // uses fromUntyped (the generated types drift from the live schema). + // All-day events are normalized to local midnight so the week/day grid + // places them in the all-day rail rather than at a UTC-shifted hour. events = (eventsResult.data || []).map((event: { id: string; team_id: string | null; @@ -106,25 +115,38 @@ export default async function BaseballCalendarPage() { is_mandatory: boolean | null; max_attendees: number | null; rsvp_deadline: string | null; - requires_rsvp: boolean | null; + all_day: boolean | null; + status: string | null; + recurring: boolean | null; created_by: string | null; - }) => ({ - id: event.id, - team_id: event.team_id || '', - title: event.title, - event_type: event.event_type || 'other', - start_date: event.start_time, - end_date: event.end_time || event.start_time, - start_time: event.start_time, - end_time: event.end_time, - location: event.location || undefined, - description: event.description || undefined, - is_mandatory: event.is_mandatory ?? false, - max_attendees: event.max_attendees, - rsvp_deadline: event.rsvp_deadline, - created_by: event.created_by, - requires_rsvp: event.requires_rsvp ?? false, - })); + }) => { + const normalizeAllDay = (d: string) => `${d.slice(0, 10)}T00:00:00`; + const startDate = event.all_day ? normalizeAllDay(event.start_time) : event.start_time; + const endDate = event.all_day + ? normalizeAllDay(event.end_time || event.start_time) + : event.end_time || event.start_time; + return { + id: event.id, + team_id: event.team_id || '', + title: event.title, + event_type: event.event_type || 'other', + start_date: startDate, + end_date: endDate, + start_time: event.start_time, + end_time: event.end_time, + location: event.location || undefined, + description: event.description || undefined, + is_mandatory: event.is_mandatory ?? false, + max_attendees: event.max_attendees, + rsvp_deadline: event.rsvp_deadline, + all_day: event.all_day ?? false, + status: event.status ?? undefined, + recurring: event.recurring ?? false, + created_by: event.created_by, + // baseball_events has no requires_rsvp column; RSVP is not modeled here. + requires_rsvp: false, + }; + }); // Coaches on this team. Read non-PII identity from the baseball_coaches_public // view (not the base table) so this player-reachable roster panel keeps diff --git a/src/app/baseball/actions/calendar.ts b/src/app/baseball/actions/calendar.ts index 08a463332..b2f3ff821 100644 --- a/src/app/baseball/actions/calendar.ts +++ b/src/app/baseball/actions/calendar.ts @@ -184,9 +184,11 @@ const createBaseballEventAction = withBaseballAction( input.timezoneOffset, ); - // Routed through fromUntyped because requires_rsvp is not yet in the - // generated baseball_events types (schema drift — the column exists in - // the DB). Mirrors the update path below. + // Routed through fromUntyped because the generated baseball_events types + // drift from the live schema. The column list MUST match the live table: + // baseball_events has NO `requires_rsvp` column — writing it made PostgREST + // reject the whole insert, so "Add Event" silently failed. RSVP intent is + // instead expressed by seeding attendance rows below. const { data, error } = await fromUntyped(supabase, 'baseball_events') .insert({ team_id: resolvedTeamId, @@ -195,12 +197,12 @@ const createBaseballEventAction = withBaseballAction( event_type: input.eventType, start_time: startDateTime, end_time: endDateTime, + all_day: input.allDay ?? false, location: input.location || null, description: input.description || null, is_mandatory: input.isMandatory ?? false, max_attendees: input.maxAttendees || null, rsvp_deadline: buildRsvpDeadline(input.rsvpDeadline, input.timezoneOffset), - requires_rsvp: input.requiresRsvp ?? false, created_by_id: ctx.user.id, }) .select() @@ -273,7 +275,9 @@ const updateBaseballEventAction = withBaseballAction( if (input.location !== undefined) updateData.location = input.location; if (input.description !== undefined) updateData.description = input.description; if (input.isMandatory !== undefined) updateData.is_mandatory = input.isMandatory; - if (input.requiresRsvp !== undefined) updateData.requires_rsvp = input.requiresRsvp; + // NOTE: baseball_events has no `requires_rsvp` column — do NOT write it here + // (it rejects the whole update). RSVP intent lives in baseball_event_attendance. + if (input.allDay !== undefined) updateData.all_day = input.allDay; if (input.maxAttendees !== undefined) updateData.max_attendees = input.maxAttendees; if (input.rsvpDeadline !== undefined) { updateData.rsvp_deadline = buildRsvpDeadline(input.rsvpDeadline, input.timezoneOffset); diff --git a/src/components/golf/calendar/WeekView.tsx b/src/components/golf/calendar/WeekView.tsx index 3f878bf9f..9b4e6be9c 100644 --- a/src/components/golf/calendar/WeekView.tsx +++ b/src/components/golf/calendar/WeekView.tsx @@ -341,7 +341,9 @@ export function WeekView({ {/* Header row - Day names and dates */}
@@ -389,7 +391,7 @@ export function WeekView({ {hasAllDayEvents && (
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 0a230f7c5..55ec04c05 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -930,25 +930,31 @@ function CalendarHeader({ {/* Timezone Toggle — desktop only */} {!isMobile && onSecondaryTimezoneChange && ( + {/* Secondary-timezone overlay toggle. Rendered as a de-emphasized + ghost control (not `variant="primary"`, which painted it solid + green and made it compete with the Add Event CTA) and carries a + visible tooltip so the globe glyph isn't ambiguous. When active it + shows the chosen timezone label inline. */} - diff --git a/src/lib/calendar/premium-utils.ts b/src/lib/calendar/premium-utils.ts index eb11fca53..a8b5f8889 100644 --- a/src/lib/calendar/premium-utils.ts +++ b/src/lib/calendar/premium-utils.ts @@ -16,8 +16,16 @@ function getEventTypeClass(eventType: string): string { practice: 'event-type-practice', match: 'event-type-match', tournament: 'event-type-tournament', + qualifier: 'event-type-qualifier', meeting: 'event-type-meeting', + travel: 'event-type-travel', social: 'event-type-social', + // Baseball event types — give games/scrimmages/camps/tryouts a distinct + // colored ribbon instead of falling back to the neutral "other" grey. + game: 'event-type-game', + scrimmage: 'event-type-scrimmage', + camp: 'event-type-camp', + tryout: 'event-type-tryout', }; return typeMap[eventType] || 'event-type-other'; } diff --git a/src/styles/calendar-tokens.css b/src/styles/calendar-tokens.css index d971920ed..d5b3a309a 100644 --- a/src/styles/calendar-tokens.css +++ b/src/styles/calendar-tokens.css @@ -10,7 +10,8 @@ EVENT TYPE COLORS Left border accent colors for instant event recognition Canonical source: src/lib/calendar/event-styles.ts - Valid DB types: practice, tournament, qualifier, meeting, travel, other + Golf DB types: practice, tournament, qualifier, meeting, travel, other + Baseball DB types add: game, scrimmage, camp, tryout =================================================================== */ --event-practice: theme(colors.stone.400); --event-tournament: theme(colors.emerald.600); @@ -18,6 +19,11 @@ --event-meeting: theme(colors.sky.500); --event-travel: theme(colors.purple.500); --event-other: theme(colors.stone.400); + /* Baseball-specific event types */ + --event-game: theme(colors.blue.600); + --event-scrimmage: theme(colors.teal.500); + --event-camp: theme(colors.orange.500); + --event-tryout: theme(colors.rose.500); /* =================================================================== EVENT STATUS COLORS @@ -168,6 +174,10 @@ .event-type-meeting { border-left-color: var(--event-meeting); } .event-type-travel { border-left-color: var(--event-travel); } .event-type-other { border-left-color: var(--event-other); } +.event-type-game { border-left-color: var(--event-game); } +.event-type-scrimmage { border-left-color: var(--event-scrimmage); } +.event-type-camp { border-left-color: var(--event-camp); } +.event-type-tryout { border-left-color: var(--event-tryout); } .status-draft { opacity: 0.6; From 4923bd62ca5b6dede52ce45f0201d8d75af3c53a Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:38:03 -0400 Subject: [PATCH 032/186] fix(baseball): resolve misc-visual QA cluster (stats W-L, videos copy, settings height, activate skeleton, performance dup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster misc-visual — 5 root-cause fixes plus GolfHelm-grade polish: - stats-season W-L mismatch: GamesList computed the record from its own possibly-limit-truncated fetch, so the "Recent Games" widget (limit=5) read "3 played · 3W-0L" while the full Games page read "6 played · 5W-0L-1T". Added getTeamSeasonRecord — a full-season selector (all completed games, respects season/gameType, ignores display limit) — and both surfaces now derive their record from it, so they can never disagree. - videos copy: header subtitle "Team film — 5 views" (meaning 5 tabs) read as a play count and contradicted the "No videos yet" empty state. Now derives an honest subtitle from the real clip count, suppressed at zero. - settings/program height blowout: SectionCard used whileInView with initial opacity:0, so every section below the initial viewport stayed invisible-but-full-height (~6,900px of "empty" canvas). Switched to a mount animation so all sections are always present. - activate skeleton: client component gated on useAuth().loading + a user.role check done during render (role is derived from profile presence in baseball, so it never matched) → permanent PageLoading shimmer. Split into a server page that resolves getSessionProfile() and redirects BEFORE any skeleton paints, plus a thin client activation island. - performance dup: page rendered BOTH PerformanceCommandCenter and a full PerformanceDashboardClient (two "Performance" headers, two readiness lists). Dashboard now renders in embedded mode below the Command Center — dropping the duplicate header, KPI summary, and readiness tab — keeping only the unique prescribe/library tools, and only for manage-lifting coaches. Rebalanced the Command Center grid (no dead left gutter when the board is empty), collapsed repetitive 8d+ stale readiness cards into one disclosure, and raised meta-text contrast to AA. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../(dashboard)/dashboard/activate/page.tsx | 314 ++++-------------- .../dashboard/performance/page.tsx | 32 +- src/app/baseball/actions/games.ts | 65 ++++ src/components/baseball/games/GamesList.tsx | 38 +-- .../GamesList.record-summary.test.tsx | 50 ++- .../performance/PerformanceCommandCenter.tsx | 147 ++++---- .../PerformanceDashboardClient.tsx | 52 ++- .../ActivateRecruitingClient.tsx | 230 +++++++++++++ .../settings/ProgramSettingsClient.tsx | 10 +- .../baseball/video/VideoLibraryClient.tsx | 16 +- 10 files changed, 585 insertions(+), 369 deletions(-) create mode 100644 src/components/baseball/player-access/ActivateRecruitingClient.tsx diff --git a/src/app/baseball/(dashboard)/dashboard/activate/page.tsx b/src/app/baseball/(dashboard)/dashboard/activate/page.tsx index 3cad1fc73..a50f0d954 100644 --- a/src/app/baseball/(dashboard)/dashboard/activate/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/activate/page.tsx @@ -1,272 +1,80 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +// ============================================================================= +// src/app/baseball/(dashboard)/dashboard/activate/page.tsx +// +// Recruiting activation — SERVER-GATED entry. +// +// This route used to be a client component that gated on useAuth()'s async +// `loading` + a `user.role` check done DURING render. For baseball, role is +// derived from profile presence (not users.role), so the guard frequently never +// matched and the page stranded on a permanent PageLoading shimmer skeleton +// (QA: "STILL-SKELETON", textLen=0). We now resolve the session server-side and +// redirect BEFORE any skeleton paints — the same fix already applied to +// /baseball/dashboard/page.tsx. The interactive activation UI is a thin client +// island that only mounts for an eligible, not-yet-activated player. +// ============================================================================= + +import { redirect } from 'next/navigation'; + +import { getSessionProfile } from '@/lib/auth/session'; import { Header } from '@/components/layout/header'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { PageLoading } from '@/components/ui/loading'; -import { IconTarget, IconUsers, IconChart, IconCheck, IconEye, IconLock } from '@/components/icons'; -import { useAuth } from '@/hooks/use-auth'; -import { activateRecruitingExposure } from '@/app/baseball/actions/player-access'; - -export default function ActivateRecruitingPage() { - const { user, player, loading: authLoading, updatePlayer } = useAuth(); - const router = useRouter(); - const [activating, setActivating] = useState(false); - const [error, setError] = useState(null); +import { Card, CardContent } from '@/components/ui/card'; +import { IconLock } from '@/components/icons'; +import { ActivateRecruitingClient } from '@/components/baseball/player-access/ActivateRecruitingClient'; - if (authLoading) return ; +export const metadata = { + title: 'Activate Recruiting | Helm Baseball', + description: 'Make your profile visible to college coaches.', +}; - if (user?.role !== 'player') { - router.push('/baseball/dashboard/command-center'); - return null; - } +export default async function ActivateRecruitingPage() { + const session = await getSessionProfile(); - if (player?.player_type === 'college') { - return ( -
- - -
- -
-

Not available for college players

-

- Recruiting activation is for high school, JUCO, and showcase players only. - As a college player, your team features are available from the main dashboard. -

-
-
-
- ); + if (!session) { + redirect('/baseball/login?returnTo=/baseball/dashboard/activate'); } - if (player?.recruiting_activated) { - router.push('/baseball/player/today'); - return null; + // Coaches (and any non-player) have no business here — bounce to their home. + if (!session.player) { + redirect('/baseball/dashboard/command-center'); } - const handleActivate = async () => { - if (!player?.id) { - setError('Player not found'); - return; - } - - setActivating(true); - setError(null); - - try { - // Go through the gated server action so the team's - // recruiting_exposure_enabled toggle is enforced. The old raw client-side - // update bypassed it — a program with exposure disabled could still be - // activated from this page if RLS permitted the write. - await activateRecruitingExposure(); - - // Update local state - await updatePlayer?.({ - recruiting_activated: true, - recruiting_activated_at: new Date().toISOString(), - }); - - router.push('/baseball/player/today'); - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred. Please try again.'); - setActivating(false); - } - }; - - return ( - <> -
-
- {/* Hero Section */} - - -
- -
-

- Ready to be recruited? -

-

- Activate recruiting to make your profile visible to college coaches and unlock powerful features - to help you get recruited to play at the next level. -

- - Free to activate • No credit card required - -
-
- - {/* Features Grid */} -
- - -
- -
-

Get Discovered

-
- -

- Make your profile visible in coach searches and recommendations. -

-
    -
  • - - Appear in coach discovery searches -
  • -
  • - - Get matched with relevant programs -
  • -
  • - - See who's viewing your profile -
  • -
-
-
- - - -
- -
-

Connect with Coaches

-
- -

- See which coaches are interested and message them directly. -

-
    -
  • - - Know which coaches viewed you -
  • -
  • - - Track watchlist additions -
  • -
  • - - Direct messaging with coaches -
  • -
-
-
+ const player = session.player; - - -
- -
-

Manage Your Journey

-
- -

- Track your recruiting progress and manage your college list. -

-
    -
  • - - Timeline of recruiting activity -
  • -
  • - - Track offers and commitments -
  • -
  • - - Organize your target schools -
  • -
-
-
+ // Already activated → straight to the player surface. + if (player.recruiting_activated) { + redirect('/baseball/player/today'); + } - - -
- + // College players never activate recruiting exposure. Show a clear, honest + // state instead of a dead skeleton. + if (player.player_type === 'college') { + return ( + <> +
+
+ + +
+
-

Track Analytics

- - -

- Understand your recruiting reach and optimize your profile. +

+ Recruiting activation isn't for college players +

+

+ Recruiting activation is for high school, JUCO, and showcase players. As a college + player, your team features are available from the main dashboard.

-
    -
  • - - Profile view statistics -
  • -
  • - - Video engagement metrics -
  • -
  • - - Interest by program type -
  • -
+ + ); + } - {/* Privacy & Control */} - - -

You're in control

-
- -
-
-

Privacy Settings

-
    -
  • • Control what information is visible
  • -
  • • Choose who can contact you
  • -
  • • Deactivate recruiting anytime
  • -
-
-
-

Your Data

-
    -
  • • Your profile belongs to you
  • -
  • • Export your data anytime
  • -
  • • Delete your account anytime
  • -
-
-
-
-
- - {/* CTA */} - - - {error && ( -
- {error} -
- )} - -

- By activating, you agree to make your profile visible to college coaches -

-
-
-
+ return ( + <> +
+ ); } diff --git a/src/app/baseball/(dashboard)/dashboard/performance/page.tsx b/src/app/baseball/(dashboard)/dashboard/performance/page.tsx index a8f970e9d..1a7f26faf 100644 --- a/src/app/baseball/(dashboard)/dashboard/performance/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/performance/page.tsx @@ -233,6 +233,18 @@ export default async function PerformancePage() { return (
+ {/* + ONE performance surface. The Command Center is the primary dashboard + (header + KPI strip + Today Weight Room board + readiness queue). The + prescribe/library management tools render BELOW it in `embedded` mode — + which drops the duplicate header, the duplicate KPI summary, and the + duplicate readiness list the Command Center already shows. Previously + both components rendered in full, stacking two "Performance" headers and + two readiness lists of the same roster on one page. + The embedded section is only worth rendering for coaches who can manage + lifting (Assignments/Library); a readiness-only coach already sees + everything in the Command Center. + */} - + {canManageLifting && ( + + )}
); } diff --git a/src/app/baseball/actions/games.ts b/src/app/baseball/actions/games.ts index 78f986ff9..940631d3b 100644 --- a/src/app/baseball/actions/games.ts +++ b/src/app/baseball/actions/games.ts @@ -383,6 +383,71 @@ export async function getTeamGames( return { success: true, data: games }; } +export interface TeamSeasonRecord { + played: number; + wins: number; + losses: number; + ties: number; +} + +export interface GetTeamSeasonRecordResult { + success: boolean; + data?: TeamSeasonRecord; + error?: string; +} + +/** + * Season win-loss record computed over ALL completed games in the season — + * independent of any display `limit`. Both the Games & Scrimmages page and the + * Season Stats "Recent Games" widget derive their record from this single + * selector so the two surfaces can never disagree. Previously each surface + * computed the record from its own (sometimes limit-truncated) fetch, which is + * why a limit-5 "Recent Games" list read "3 played · 3W-0L" while the full + * Games page read "6 played · 5W-0L-1T". + */ +export async function getTeamSeasonRecord( + teamId: string, + seasonYear?: number, + gameType?: BaseballGameType, +): Promise { + const authResult = await requireCoachAuth(); + if ('error' in authResult) return { success: false, error: authResult.error }; + const { coach, supabase } = authResult; + + const hasAccess = await verifyTeamAccess(supabase, coach.id, teamId); + if (!hasAccess) return { success: false, error: 'Access denied' }; + + const year = seasonYear ?? new Date().getFullYear(); + + let query = (supabase as unknown as SupabaseClient) + .from('baseball_games') + .select('our_score, opponent_score, status, game_type') + .eq('team_id', teamId) + .eq('status', 'completed') + .gte('game_date', `${year}-01-01`) + .lte('game_date', `${year}-12-31`); + + if (gameType) query = query.eq('game_type', gameType); + + const { data, error } = await query; + if (error) return { success: false, error: sanitizeDbError(error, 'games') }; + + type ScoreRow = { our_score: number | null; opponent_score: number | null }; + const record = ((data ?? []) as ScoreRow[]).reduce( + (acc, g) => { + if (g.our_score == null || g.opponent_score == null) return acc; + acc.played += 1; + if (g.our_score > g.opponent_score) acc.wins += 1; + else if (g.our_score < g.opponent_score) acc.losses += 1; + else acc.ties += 1; + return acc; + }, + { played: 0, wins: 0, losses: 0, ties: 0 }, + ); + + return { success: true, data: record }; +} + export interface GetGameBoxScoreResult { success: boolean; game?: BaseballGame; diff --git a/src/components/baseball/games/GamesList.tsx b/src/components/baseball/games/GamesList.tsx index 7ee51d29b..b5204ec61 100644 --- a/src/components/baseball/games/GamesList.tsx +++ b/src/components/baseball/games/GamesList.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { getBaseballStatsGameCreateHref } from '@/lib/baseball/stats-route-aliases'; import { GameCard } from './GameCard'; -import { getTeamGames, deleteGame } from '@/app/baseball/actions/games'; +import { getTeamGames, getTeamSeasonRecord, deleteGame, type TeamSeasonRecord } from '@/app/baseball/actions/games'; import type { BaseballGame, BaseballGameType } from '@/lib/types'; import { IconPlus, IconRefresh } from '@/components/icons'; import { Button } from '@/components/ui/button'; @@ -33,6 +33,10 @@ export function GamesList({ teamId, title = 'Games & Scrimmages', showAddButton const [activeTab, setActiveTab] = useState('all'); const [seasonYear, setSeasonYear] = useState(new Date().getFullYear()); const [deletingId, setDeletingId] = useState(null); + // Season record is computed over the FULL season (all completed games) via a + // dedicated selector — never over the possibly limit-truncated display list — + // so this header matches the Games & Scrimmages page exactly on every surface. + const [seasonRecord, setSeasonRecord] = useState(null); const fetchGames = useCallback( async (isRefresh = false) => { @@ -40,17 +44,18 @@ export function GamesList({ teamId, title = 'Games & Scrimmages', showAddButton else setLoading(true); setError(null); - const result = await getTeamGames(teamId, { - gameType: activeTab === 'all' ? undefined : (activeTab as BaseballGameType), - seasonYear, - limit, - }); + const gameType = activeTab === 'all' ? undefined : (activeTab as BaseballGameType); + const [result, recordResult] = await Promise.all([ + getTeamGames(teamId, { gameType, seasonYear, limit }), + getTeamSeasonRecord(teamId, seasonYear, gameType), + ]); if (result.success) { setGames(result.data ?? []); } else { setError(result.error ?? 'Failed to load games'); } + setSeasonRecord(recordResult.success ? (recordResult.data ?? null) : null); setLoading(false); setRefreshing(false); }, @@ -73,27 +78,16 @@ export function GamesList({ teamId, title = 'Games & Scrimmages', showAddButton setDeletingId(null); } - const completedGames = games.filter((g) => g.status === 'completed'); - const { wins, losses, ties } = completedGames.reduce( - (record, g) => { - if (g.our_score == null || g.opponent_score == null) return record; - if (g.our_score > g.opponent_score) record.wins += 1; - else if (g.our_score < g.opponent_score) record.losses += 1; - else record.ties += 1; - return record; - }, - { wins: 0, losses: 0, ties: 0 } - ); - return (
{/* Header */}
-

{title}

- {completedGames.length > 0 && ( -

- {completedGames.length} played · {wins}W-{losses}L{ties > 0 ? `-${ties}T` : ''} + {title ?

{title}

: null} + {seasonRecord && seasonRecord.played > 0 && ( +

+ {seasonRecord.played} played · {seasonRecord.wins}W-{seasonRecord.losses}L + {seasonRecord.ties > 0 ? `-${seasonRecord.ties}T` : ''}

)}
diff --git a/src/components/baseball/games/__tests__/GamesList.record-summary.test.tsx b/src/components/baseball/games/__tests__/GamesList.record-summary.test.tsx index 8d88cb0f0..fc8cf0a3b 100644 --- a/src/components/baseball/games/__tests__/GamesList.record-summary.test.tsx +++ b/src/components/baseball/games/__tests__/GamesList.record-summary.test.tsx @@ -4,18 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react'; import type { BaseballGame } from '@/lib/types'; // ───────────────────────────────────────────────────────────────────────────── -// Regression guard for #438: GamesList used to compute -// `losses = completedGames.length - wins`, so a tied game (our_score === -// opponent_score) silently fell into the loss bucket and the header -// ("X played · YW ZL") misstated the team record. The fix must treat ties -// as their own bucket and only surface "-NT" in the header when N > 0. +// Regression guard for #438 + the season W-L mismatch: the record header must +// treat ties as their own bucket and only surface "-NT" when N > 0. The record +// is now sourced from `getTeamSeasonRecord` (a full-season selector, independent +// of any display `limit`) so the "Recent Games" widget and the full Games page +// can never disagree — the header renders exactly what that selector returns. // ───────────────────────────────────────────────────────────────────────────── const getTeamGames = vi.fn(); +const getTeamSeasonRecord = vi.fn(); const deleteGame = vi.fn(); vi.mock('@/app/baseball/actions/games', () => ({ getTeamGames: (...args: unknown[]) => getTeamGames(...args), + getTeamSeasonRecord: (...args: unknown[]) => getTeamSeasonRecord(...args), deleteGame: (...args: unknown[]) => deleteGame(...args), })); @@ -65,17 +67,25 @@ function makeGame(overrides: Partial): BaseballGame { beforeEach(() => { getTeamGames.mockReset(); + getTeamSeasonRecord.mockReset(); deleteGame.mockReset(); + // Default: the display list carries the games; the record comes from its own + // selector. Individual tests override the record. + getTeamGames.mockResolvedValue({ success: true, data: [] }); }); describe('GamesList record summary', () => { - it('counts a tied game as a tie, not a loss', async () => { + it('renders a tie as its own bucket (never folded into losses)', async () => { const games: BaseballGame[] = [ makeGame({ id: 'w-1', our_score: 5, opponent_score: 2 }), makeGame({ id: 'l-1', our_score: 1, opponent_score: 4 }), makeGame({ id: 't-1', our_score: 3, opponent_score: 3 }), ]; getTeamGames.mockResolvedValue({ success: true, data: games }); + getTeamSeasonRecord.mockResolvedValue({ + success: true, + data: { played: 3, wins: 1, losses: 1, ties: 1 }, + }); render(); @@ -85,11 +95,10 @@ describe('GamesList record summary', () => { }); it('omits the tie segment when there are no ties', async () => { - const games: BaseballGame[] = [ - makeGame({ id: 'w-1', our_score: 5, opponent_score: 2 }), - makeGame({ id: 'l-1', our_score: 1, opponent_score: 4 }), - ]; - getTeamGames.mockResolvedValue({ success: true, data: games }); + getTeamSeasonRecord.mockResolvedValue({ + success: true, + data: { played: 2, wins: 1, losses: 1, ties: 0 }, + }); render(); @@ -97,4 +106,23 @@ describe('GamesList record summary', () => { expect(screen.getByText('2 played · 1W-1L')).toBeInTheDocument(); }); }); + + it('reflects the FULL-season record even when the display list is limited', async () => { + // The "Recent Games" widget passes limit={5}; the record must still be the + // full-season figure from the selector, not derived from the shown games. + getTeamGames.mockResolvedValue({ + success: true, + data: [makeGame({ id: 'w-1', our_score: 5, opponent_score: 2 })], + }); + getTeamSeasonRecord.mockResolvedValue({ + success: true, + data: { played: 6, wins: 5, losses: 0, ties: 1 }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('6 played · 5W-0L-1T')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/baseball/performance/PerformanceCommandCenter.tsx b/src/components/baseball/performance/PerformanceCommandCenter.tsx index e4ec943bd..c4738ee84 100644 --- a/src/components/baseball/performance/PerformanceCommandCenter.tsx +++ b/src/components/baseball/performance/PerformanceCommandCenter.tsx @@ -112,6 +112,10 @@ export function PerformanceCommandCenter({ }: Props) { const [statusFilter, setStatusFilter] = useState('all'); const [selectedPlayerId, setSelectedPlayerId] = useState(null); + // Stale check-ins (8d+ old, low confidence) collapse into one summary row so + // the queue surfaces genuinely-actionable flags first instead of a wall of + // near-identical "check-in is 8d old · stale data" cards. + const [staleOpen, setStaleOpen] = useState(false); const prefersReducedMotion = useReducedMotion(); const router = useRouter(); @@ -194,6 +198,56 @@ export function PerformanceCommandCenter({ [readiness], ); + // Split into actionable (fresh) flags vs stale check-ins. The stale group is + // dominated by identical "8d+ old, low-confidence" rows and gets collapsed. + const freshQueue = useMemo(() => queue.filter((r) => !r.stale), [queue]); + const staleQueue = useMemo(() => queue.filter((r) => r.stale), [queue]); + const boardEmpty = board.length === 0; + + const renderQueueRow = (r: BaseballReadinessComputation) => { + const isSelected = selectedPlayerId === r.player_id; + return ( +
  • + +
  • + ); + }; + if (isLoading) { return (
    @@ -329,9 +383,9 @@ export function PerformanceCommandCenter({ ))} -
    +
    {/* Today Weight Room board */} -
    +
    @@ -439,60 +493,41 @@ export function PerformanceCommandCenter({ ) : queue.length === 0 ? ( ) : ( -
      - {queue.map((r) => { - const isSelected = selectedPlayerId === r.player_id; - return ( -
    • - -
    • - ); - })} -
    +
    + {freshQueue.length > 0 ? ( +
      {freshQueue.map(renderQueueRow)}
    + ) : ( + staleQueue.length > 0 && ( +

    + No fresh flags today. The check-ins below are just out of date — nudge these + athletes to re-report. +

    + ) + )} + + {staleQueue.length > 0 && ( +
    + + {staleOpen && ( +
      {staleQueue.map(renderQueueRow)}
    + )} +
    + )} +
    )} diff --git a/src/components/baseball/performance/PerformanceDashboardClient.tsx b/src/components/baseball/performance/PerformanceDashboardClient.tsx index f9cb48071..096ee9f3a 100644 --- a/src/components/baseball/performance/PerformanceDashboardClient.tsx +++ b/src/components/baseball/performance/PerformanceDashboardClient.tsx @@ -76,6 +76,15 @@ interface PerformanceDashboardClientProps { groups?: Pick[]; /** Show skeleton loaders while data is loading. */ isLoading?: boolean; + /** + * Rendered as a section BELOW the PerformanceCommandCenter (default on the + * main Performance page). In embedded mode this drops the duplicate + * "Performance" hero header, the duplicate KPI summary strip, and the + * Readiness tab — all of which the Command Center already owns — so the page + * no longer stacks two full dashboards with two headers and two readiness + * lists. Only the unique prescribe/library management tools remain. + */ + embedded?: boolean; } // V11 exercise vocabulary (mirrors the migration CHECK constraints). Surfaced as @@ -146,16 +155,21 @@ export function PerformanceDashboardClient({ readiness, groups, isLoading = false, + embedded = false, }: PerformanceDashboardClientProps) { void teamId; // team scope is enforced server-side; kept for prop-contract clarity. + // In embedded mode the Command Center above owns readiness, so this section + // only offers the prescribe/library management tabs. + const showReadinessTab = canViewReadiness && !embedded; + const [assignments, setAssignments] = useState(initialAssignments); const [exercises, setExercises] = useState(initialExercises); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [isPending, startTransition] = useTransition(); - const defaultTab = canViewReadiness ? 'readiness' : 'assignments'; + const defaultTab = showReadinessTab ? 'readiness' : 'assignments'; const [tab, setTab] = useState(defaultTab); const prefersReducedMotion = useReducedMotion(); @@ -445,19 +459,31 @@ export function PerformanceDashboardClient({ return ( {/* Header */} -
    -

    Strength & conditioning

    -

    Performance

    -

    - Track readiness, prescribe lifts, and keep your exercise library in one place. -

    -
    + {embedded ? ( +
    +

    Prescribe & library

    +

    + Assignments & exercise library +

    +

    + Prescribe lifts and keep your team's exercise library in one place. +

    +
    + ) : ( +
    +

    Strength & conditioning

    +

    Performance

    +

    + Track readiness, prescribe lifts, and keep your exercise library in one place. +

    +
    + )} {/* Notices */} {error && ( @@ -488,7 +514,8 @@ export function PerformanceDashboardClient({
    )} - {/* Summary */} + {/* Summary — hidden when embedded (Command Center owns the KPI strip). */} + {!embedded && (
    } @@ -519,10 +546,11 @@ export function PerformanceDashboardClient({ )}
    + )} - {canViewReadiness && ( + {showReadinessTab && ( Readiness )} {canManageLifting && ( @@ -536,7 +564,7 @@ export function PerformanceDashboardClient({ {/* ---------------------------------------------------------------- */} {/* READINESS */} {/* ---------------------------------------------------------------- */} - {canViewReadiness && ( + {showReadinessTab && ( {tab === 'readiness' && ( diff --git a/src/components/baseball/player-access/ActivateRecruitingClient.tsx b/src/components/baseball/player-access/ActivateRecruitingClient.tsx new file mode 100644 index 000000000..c62282e07 --- /dev/null +++ b/src/components/baseball/player-access/ActivateRecruitingClient.tsx @@ -0,0 +1,230 @@ +'use client'; + +// ============================================================================= +// src/components/baseball/player-access/ActivateRecruitingClient.tsx +// +// Thin client for the recruiting-activation surface. All auth/role/type/ +// already-activated gating happens SERVER-SIDE in the page (getSessionProfile → +// redirect) before this component ever mounts, so it never renders an auth +// skeleton and can never strand on an infinite loading state. This component is +// only mounted for a signed-in, non-college player who has NOT yet activated. +// +// The activation write goes through the gated `activateRecruitingExposure` +// server action so the program's recruiting_exposure_enabled toggle is enforced +// server-side (a raw client update would bypass it). +// ============================================================================= + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { IconTarget, IconUsers, IconChart, IconCheck, IconEye } from '@/components/icons'; +import { useAuth } from '@/hooks/use-auth'; +import { activateRecruitingExposure } from '@/app/baseball/actions/player-access'; + +interface FeatureCard { + icon: React.ReactNode; + iconWrap: string; + title: string; + blurb: string; + points: string[]; +} + +const FEATURES: FeatureCard[] = [ + { + icon: , + iconWrap: 'bg-primary-50', + title: 'Get discovered', + blurb: 'Make your profile visible in coach searches and recommendations.', + points: [ + 'Appear in coach discovery searches', + 'Get matched with relevant programs', + "See who's viewing your profile", + ], + }, + { + icon: , + iconWrap: 'bg-primary-50', + title: 'Connect with coaches', + blurb: 'See which coaches are interested and message them directly.', + points: [ + 'Know which coaches viewed you', + 'Track watchlist additions', + 'Direct messaging with coaches', + ], + }, + { + icon: , + iconWrap: 'bg-primary-50', + title: 'Manage your journey', + blurb: 'Track your recruiting progress and manage your college list.', + points: [ + 'Timeline of recruiting activity', + 'Track offers and commitments', + 'Organize your target schools', + ], + }, + { + icon: , + iconWrap: 'bg-primary-50', + title: 'Track analytics', + blurb: 'Understand your recruiting reach and optimize your profile.', + points: [ + 'Profile view statistics', + 'Video engagement metrics', + 'Interest by program type', + ], + }, +]; + +export function ActivateRecruitingClient({ playerId }: { playerId: string }) { + const router = useRouter(); + const { updatePlayer } = useAuth(); + const [activating, setActivating] = useState(false); + const [error, setError] = useState(null); + + const handleActivate = async () => { + setActivating(true); + setError(null); + try { + // Gated server action enforces the program's recruiting_exposure_enabled + // toggle before flipping the player's activation flag. + await activateRecruitingExposure(); + // Keep the shared auth store in sync so the destination renders activated. + await updatePlayer?.({ + recruiting_activated: true, + recruiting_activated_at: new Date().toISOString(), + }); + router.push('/baseball/player/today'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); + setActivating(false); + } + }; + + return ( +
    + {/* Hero */} + + +
    + +
    +

    + Ready to be recruited? +

    +

    + Activate recruiting to make your profile visible to college coaches and unlock the tools + that help you get recruited to the next level. +

    + + Free to activate · No credit card required + +
    +
    + + {/* Feature grid */} +
    + {FEATURES.map((f) => ( + + +
    + {f.icon} +
    +

    {f.title}

    +

    {f.blurb}

    +
    + +
      + {f.points.map((p) => ( +
    • + + {p} +
    • + ))} +
    +
    +
    + ))} +
    + + {/* Privacy & control */} + + +

    You're in control

    +

    + Activation is opt-in and reversible. You decide what coaches can see. +

    +
    + +
    +
    +

    Privacy settings

    +
      +
    • + + Control what information is visible +
    • +
    • + + Choose who can contact you +
    • +
    • + + Deactivate recruiting anytime +
    • +
    +
    +
    +

    Your data

    +
      +
    • + + Your profile belongs to you +
    • +
    • + + Export your data anytime +
    • +
    • + + Delete your account anytime +
    • +
    +
    +
    +
    +
    + + {/* CTA */} + + + {error && ( +
    + {error} +
    + )} + +

    + By activating, you agree to make your profile visible to college coaches. +

    +
    +
    +
    + ); +} diff --git a/src/components/baseball/settings/ProgramSettingsClient.tsx b/src/components/baseball/settings/ProgramSettingsClient.tsx index 2e98ad6a8..73270b0c4 100644 --- a/src/components/baseball/settings/ProgramSettingsClient.tsx +++ b/src/components/baseball/settings/ProgramSettingsClient.tsx @@ -152,11 +152,15 @@ function SectionCard({ index?: number; reduceMotion?: boolean | null; }) { + // Reveal on MOUNT, not on scroll. A settings form is a single tall document; + // gating each section behind `whileInView` left every card below the initial + // viewport stuck at opacity:0 (invisible but full-height) until the user + // scrolled to it — which read as ~6,900px of empty canvas below a short form + // in a full-page render. Animating on mount keeps all sections present. return ( diff --git a/src/components/baseball/video/VideoLibraryClient.tsx b/src/components/baseball/video/VideoLibraryClient.tsx index 277ca8e85..f8d140578 100644 --- a/src/components/baseball/video/VideoLibraryClient.tsx +++ b/src/components/baseball/video/VideoLibraryClient.tsx @@ -1241,6 +1241,17 @@ export function VideoLibraryClient({ evidence: evidence.totalCount, }; + // Honest header subtitle: derive from the real clip count, never a fixed + // number. The old copy read "Team film — 5 views" (meaning 5 organizational + // tabs), which reads as a play/view count and directly contradicted the + // "No videos yet" empty state below it on a library with zero clips. + const totalClips = library.totalCount; + const headerSubtitle = isCoach + ? totalClips > 0 + ? `Team film · ${totalClips} ${totalClips === 1 ? 'clip' : 'clips'}` + : 'Team film and tagged clips' + : 'Your videos and team film'; + // Mutations (upload / edit / delete / set-primary) call revalidatePath() // server-side, but this client component's props are only re-fetched when the // router actually re-renders the server tree — router.refresh() triggers that @@ -1252,10 +1263,7 @@ export function VideoLibraryClient({ return ( <> -
    +
    {/* Tab bar */} From 4dbcc30d480a320b06bcfa20a01d8bd023ae845f Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 14:45:59 -0400 Subject: [PATCH 033/186] fix(calendar): move JSX comment out of conditional expression in page-header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secondary-timezone toggle enhancement placed a {/* */} JSX comment as the first child inside {cond && ( ... )}, which is an expression context, not JSX-children — invalid syntax (TS1005/TS1382/TS17002). Moved the comment to before the conditional so the batch compiles clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- src/components/ui/page-header.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index 55ec04c05..5df147844 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -929,12 +929,11 @@ function CalendarHeader({ )} {/* Timezone Toggle — desktop only */} + {/* Secondary-timezone overlay toggle. De-emphasized ghost control (not + variant="primary", which competed with the Add Event CTA) with a + visible tooltip so the globe glyph isn't ambiguous. Shows the chosen + timezone label inline when active. */} {!isMobile && onSecondaryTimezoneChange && ( - {/* Secondary-timezone overlay toggle. Rendered as a de-emphasized - ghost control (not `variant="primary"`, which painted it solid - green and made it compete with the Add Event CTA) and carries a - visible tooltip so the globe glyph isn't ambiguous. When active it - shows the chosen timezone label inline. */}
    ); diff --git a/src/styles/baseball-living-annual.css b/src/styles/baseball-living-annual.css index 4777a28d5..a15a4fec2 100644 --- a/src/styles/baseball-living-annual.css +++ b/src/styles/baseball-living-annual.css @@ -28,11 +28,13 @@ * ========================================================================== */ :root { - /* ---- Base editorial neutrals ---- */ - --paper: #FFFEFA; /* cream canvas (alias — not previously defined) */ - --hairline: #E7E3D8; /* THE border — does the work of most cards */ + /* ---- Base editorial neutrals — DEEP WARM CREAM (founder: "less white") ---- */ + --paper: #F3EAD6; /* card cream — surfaces, never #fff/near-white */ + --paper-canvas: #EAE1CE; /* page cream — a shade deeper than the cards */ + --hairline: #D6CBB0; /* THE border — warm, does the work of most cards */ /* ---- Two inks ---- */ + --team-ink: #16A34A; /* green — team & development (alias of grade-plus) */ --pursuit-ink: #C2703D; /* infield-clay terracotta — recruiting & urgency */ --pursuit-deep: #7A2E22; /* oxblood — seals & stamps only */ @@ -46,3 +48,24 @@ --chalk: #F4EFE6; /* chalk hairlines/grid on clay */ --sodium: #F5A623; /* PR / live / in-progress accent — sparingly */ } + +/* ============================================================================ + * .living-annual — the BaseballHelm cream scope (founder: "less white, more + * cream"). Applied on the baseball redesign shell root (BaseballFairwayShell) + * so it wraps every baseball surface WITHOUT touching golf: it re-points the + * Fairway surface tokens (--fw-color-*) to warm cream for descendants only. + * The dark recessive nav rail (--nav-bg) is a SEPARATE token and stays dark — + * "less white" targets the content surfaces, not the rail. Text tokens keep + * their dark values → graphite-on-cream contrast holds. Golf never carries this + * class, so its palette is unchanged. + * ========================================================================== */ +.living-annual { + --fw-color-canvas: #EAE1CE; /* page — deep warm cream */ + --fw-color-surface: #F3EAD6; /* card — lighter cream, never white */ + --fw-color-surface-tint: #EDE4D0; + --fw-color-surface-sunken:#E1D7BF; + --fw-color-elevated: #F6EFDF; + --fw-color-border-subtle: #D6CBB0; /* warm hairline */ + --fw-color-border-strong: #C6B994; + background-color: var(--fw-color-canvas); +} From 987cce58e4f34ef526690c7a794d69e5934b8f90 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 16:01:17 -0400 Subject: [PATCH 039/186] feat(baseball): Living-Annual molecule layer (data + viz) + rate formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data molecules (compose atoms): SlashLine, StatLineStack, KPIContentsStrip, PlayerRowPlate (+Header), RecruitCard, ToolRailStack, GradeStampGrid, CoverHero, TearSheet, EmptyIssue (10-variant editorial empty-state presets). Viz molecules (ClayCanvas + Trace, honest empties): BreakPlot (pitch movement), SprayChart (batted-ball, clay|paper), ClimbArc (season development on paper). format.ts: baseball rate formatters — formatRate drops the leading zero below 1 (.341) and keeps it at/above 1 (1.089 OPS); formatRatio (ERA/WHIP); formatInnings (thirds). SlashLine now formats via formatRate. Barrel re-exports molecules/viz/format. tsc + eslint clean across the whole kit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../baseball/living-annual/format.ts | 35 +++ .../baseball/living-annual/index.ts | 8 + .../living-annual/molecules/CoverHero.tsx | 64 +++++ .../living-annual/molecules/EmptyIssue.tsx | 107 ++++++++ .../molecules/GradeStampGrid.tsx | 50 ++++ .../molecules/KPIContentsStrip.tsx | 68 +++++ .../molecules/PlayerRowPlate.tsx | 174 +++++++++++++ .../living-annual/molecules/RecruitCard.tsx | 127 +++++++++ .../living-annual/molecules/SlashLine.tsx | 73 ++++++ .../living-annual/molecules/StatLineStack.tsx | 37 +++ .../living-annual/molecules/TearSheet.tsx | 62 +++++ .../living-annual/molecules/ToolRailStack.tsx | 55 ++++ .../baseball/living-annual/molecules/index.ts | 43 +++ .../baseball/living-annual/viz/BreakPlot.tsx | 191 ++++++++++++++ .../baseball/living-annual/viz/ClimbArc.tsx | 246 ++++++++++++++++++ .../baseball/living-annual/viz/SprayChart.tsx | 218 ++++++++++++++++ .../baseball/living-annual/viz/index.ts | 20 ++ 17 files changed, 1578 insertions(+) create mode 100644 src/components/baseball/living-annual/format.ts create mode 100644 src/components/baseball/living-annual/molecules/CoverHero.tsx create mode 100644 src/components/baseball/living-annual/molecules/EmptyIssue.tsx create mode 100644 src/components/baseball/living-annual/molecules/GradeStampGrid.tsx create mode 100644 src/components/baseball/living-annual/molecules/KPIContentsStrip.tsx create mode 100644 src/components/baseball/living-annual/molecules/PlayerRowPlate.tsx create mode 100644 src/components/baseball/living-annual/molecules/RecruitCard.tsx create mode 100644 src/components/baseball/living-annual/molecules/SlashLine.tsx create mode 100644 src/components/baseball/living-annual/molecules/StatLineStack.tsx create mode 100644 src/components/baseball/living-annual/molecules/TearSheet.tsx create mode 100644 src/components/baseball/living-annual/molecules/ToolRailStack.tsx create mode 100644 src/components/baseball/living-annual/molecules/index.ts create mode 100644 src/components/baseball/living-annual/viz/BreakPlot.tsx create mode 100644 src/components/baseball/living-annual/viz/ClimbArc.tsx create mode 100644 src/components/baseball/living-annual/viz/SprayChart.tsx create mode 100644 src/components/baseball/living-annual/viz/index.ts diff --git a/src/components/baseball/living-annual/format.ts b/src/components/baseball/living-annual/format.ts new file mode 100644 index 000000000..c154f8984 --- /dev/null +++ b/src/components/baseball/living-annual/format.ts @@ -0,0 +1,35 @@ +/** + * format.ts — small baseball-native number formatters for the Living Annual kit. + * + * Pure, side-effect-free. Used by SlashLine / PlayerRowPlate / stat surfaces so + * rate stats read the way a scorekeeper writes them. + */ + +/** + * Format a rate stat (AVG / OBP / SLG / OPS / PCT) the baseball way: drop the + * leading zero when the magnitude is below 1 (`.341`, `-.012`) but KEEP it once + * the value reaches 1 (`1.089` OPS). Non-finite → an em dash (honest empty). + */ +export function formatRate(value: number, decimals = 3): string { + if (!Number.isFinite(value)) return '—'; + const fixed = Math.abs(value).toFixed(decimals); + const body = Math.abs(value) < 1 && fixed.startsWith('0') ? fixed.slice(1) : fixed; + return value < 0 ? `-${body}` : body; +} + +/** + * Format an ERA / WHIP-style rate: fixed decimals, leading zero KEPT (`3.50`, + * `0.98`). Non-finite → em dash. + */ +export function formatRatio(value: number, decimals = 2): string { + if (!Number.isFinite(value)) return '—'; + return value.toFixed(decimals); +} + +/** Innings pitched display: whole innings + thirds as `.1`/`.2` (`6.1` = 6⅓). */ +export function formatInnings(value: number): string { + if (!Number.isFinite(value)) return '—'; + const whole = Math.trunc(value); + const thirds = Math.round((value - whole) * 3); + return thirds === 0 ? `${whole}` : `${whole}.${thirds}`; +} diff --git a/src/components/baseball/living-annual/index.ts b/src/components/baseball/living-annual/index.ts index e05fe684b..aaabb33ee 100644 --- a/src/components/baseball/living-annual/index.ts +++ b/src/components/baseball/living-annual/index.ts @@ -77,3 +77,11 @@ export { GRADE_VAR, } from './grades'; export type { GradeBand } from './grades'; + +// Baseball number formatters (leading-zero-drop for rate stats, etc.) +export { formatRate, formatRatio, formatInnings } from './format'; + +// L2 molecules — the reusable surface building blocks composed from the atoms. +export * from './molecules'; +// L2 viz molecules — BreakPlot / SprayChart / ClimbArc (clay-canvas + paper). +export * from './viz'; diff --git a/src/components/baseball/living-annual/molecules/CoverHero.tsx b/src/components/baseball/living-annual/molecules/CoverHero.tsx new file mode 100644 index 000000000..0e7077e0c --- /dev/null +++ b/src/components/baseball/living-annual/molecules/CoverHero.tsx @@ -0,0 +1,64 @@ +/** + * CoverHero — the Command Center magazine cover line (spec §6 #3, north-star). + * + * The dashboard's one dominant hero: an `` kicker (`NEXT · SUN 4:30`), + * this week's opponent set as a big `font-annual` cover line (`vs COASTAL + * STATE`), and the record ruled beneath on a GREEN ``. When there + * is no next opponent it renders an honest `STANDING BY` editor's letter instead + * of broken zeros — the empty-state doctrine, never a yellow box. + * + * No hooks / handlers — safe in a server component. + */ +import { cn } from '@/lib/utils'; +import { Eyebrow, HairlineRule, EditorsLetter } from '..'; + +export interface CoverHeroProps { + /** Next opponent, e.g. `Coastal State`. Empty → the standing-by variant. */ + opponent: string; + /** Date / time kicker, e.g. `SUN 4:30`. */ + dateLabel?: string; + /** Team record, e.g. `12-4`. */ + record?: string; + /** `home` → `vs`, `away` → `at` (default home). */ + homeAway?: 'home' | 'away'; + /** Eyebrow lead word (default `NEXT`). */ + kicker?: string; + className?: string; +} + +export function CoverHero({ opponent, dateLabel, record, homeAway = 'home', kicker = 'NEXT', className }: CoverHeroProps) { + if (opponent.trim().length === 0) { + return ( + + ); + } + + const prefix = homeAway === 'away' ? 'at' : 'vs'; + + return ( +
    + + +

    + {prefix} + {opponent} +

    + + {record ? ( +
    + +
    + Record + {record} +
    +
    + ) : null} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/EmptyIssue.tsx b/src/components/baseball/living-annual/molecules/EmptyIssue.tsx new file mode 100644 index 000000000..a299c89be --- /dev/null +++ b/src/components/baseball/living-annual/molecules/EmptyIssue.tsx @@ -0,0 +1,107 @@ +/** + * EmptyIssue — per-surface empty-state presets over `` (spec §7 + * empty-state doctrine: "no yellow warning boxes anywhere"). + * + * Every zero/empty surface renders a composed editorial letter — a title + a + * reading-voice body in the lane ink, with a `STANDING BY` breathing dot on the + * surfaces genuinely awaiting live data. Day-one always reads like an unreleased + * first issue, never a broken CRM. The preset table is exported so a surface can + * reference / extend the canonical copy. + * + * No hooks / handlers — safe in a server component. + */ +import type { ReactNode } from 'react'; +import { EditorsLetter } from '..'; + +export type EmptyIssueVariant = + | 'stats' + | 'pipeline' + | 'roster' + | 'signals' + | 'today' + | 'messages' + | 'documents' + | 'calendar' + | 'tasks' + | 'generic'; + +export interface EmptyIssuePreset { + title: string; + body: string; + /** Surface is awaiting live data — show the `STANDING BY` breathing dot. */ + live?: boolean; +} + +/** Canonical empty-state copy, one composed letter per surface. */ +export const EMPTY_ISSUE_PRESETS: Record = { + stats: { + title: 'Standing by — awaiting first pitch.', + body: 'Box scores light up here after your first game.', + live: true, + }, + pipeline: { + title: 'The board is open.', + body: 'Add a recruit to start the file — watchlists, grades, and aging bars fill these columns.', + }, + roster: { + title: 'An unreleased first issue.', + body: 'Your roster spread prints here the moment players join the program.', + }, + signals: { + title: 'The wire is quiet.', + body: 'Commits, offers, and milestones ticker across this desk as they happen.', + live: true, + }, + today: { + title: 'Today’s assignment is being set.', + body: 'Your daily contract appears here once your coach posts the plan.', + live: true, + }, + messages: { + title: 'Quiet in the pressbox.', + body: 'Conversations with your staff and teammates collect here.', + }, + documents: { + title: 'The file drawer is empty.', + body: 'Uploaded packets, forms, and clips are filed here.', + }, + calendar: { + title: 'No dates on the card.', + body: 'Games, practices, and travel land on the schedule here.', + live: true, + }, + tasks: { + title: 'Nothing due — yet.', + body: 'Assignments and to-dos stack up here as they come in.', + }, + generic: { + title: 'Standing by.', + body: 'This section fills in as your season gets underway.', + live: true, + }, +}; + +export interface EmptyIssueProps { + variant: EmptyIssueVariant; + /** Lane ink (defaults to clay for `pipeline`, team green otherwise). */ + ink?: 'team' | 'pursuit'; + /** Optional call-to-action row. */ + action?: ReactNode; + className?: string; +} + +export function EmptyIssue({ variant, ink, action, className }: EmptyIssueProps) { + const preset = EMPTY_ISSUE_PRESETS[variant] ?? EMPTY_ISSUE_PRESETS.generic; + const laneInk = ink ?? (variant === 'pipeline' ? 'pursuit' : 'team'); + + return ( + + ); +} diff --git a/src/components/baseball/living-annual/molecules/GradeStampGrid.tsx b/src/components/baseball/living-annual/molecules/GradeStampGrid.tsx new file mode 100644 index 000000000..e3fed8a39 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/GradeStampGrid.tsx @@ -0,0 +1,50 @@ +/** + * GradeStampGrid — a row / grid of 20-80 ``s (spec §7). + * + * The evaluation block for a scouted player: HIT / POWER / RUN / ARM / FIELD as + * debossed, ramp-colored stamps that press in on mount. `present: false` renders + * a ghost dashed outline (a projected/ceiling grade not yet earned). Column + * count resolves through a static, JIT-safe class map so no arbitrary grid class + * is generated. + * + * No hooks / handlers — safe in a server component. + */ +import { cn } from '@/lib/utils'; +import { GradeStamp } from '..'; + +// Static column templates — the stamps are fixed-size, so `w-fit` keeps the grid +// snug to its content instead of stretching cells. +const COLS: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', +}; + +export interface GradeStampGridGrade { + /** Tool name shown as the ring label, e.g. `HIT`, `POWER`. */ + tool: string; + /** The 20-80 grade. */ + value: number; + /** Solid present grade vs. ghost future/projected outline. */ + present?: boolean; +} + +export interface GradeStampGridProps { + grades: Array; + /** Columns (default 5 — the classic five-tool row). */ + columns?: number; + className?: string; +} + +export function GradeStampGrid({ grades, columns = 5, className }: GradeStampGridProps) { + return ( +
    + {grades.map((g, i) => ( + + ))} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/KPIContentsStrip.tsx b/src/components/baseball/living-annual/molecules/KPIContentsStrip.tsx new file mode 100644 index 000000000..eefaf407a --- /dev/null +++ b/src/components/baseball/living-annual/molecules/KPIContentsStrip.tsx @@ -0,0 +1,68 @@ +/** + * KPIContentsStrip — the Command Center "table of contents" (spec §6 #3). + * + * A responsive grid of big KPI figures read as a masthead contents strip + * (`ROSTER 14 · ON THE RECORD 0 · OPEN RISKS 0`). Each cell is a `` + * (`size='row'`) so the numeral carries the contrast and rests on a GREEN + * baseline rule (team lane). `leader`/`emphasis` promote a figure to green so + * the eye lands on it; a settled figure flashes its rule green when a background + * sync lands a value (owned by the atom). + * + * Column counts resolve through a static class map (no arbitrary grid classes, + * Tailwind-JIT safe). No hooks / handlers — safe in a server component. + */ +import { cn } from '@/lib/utils'; +import { RuledStatLine } from '..'; + +// Static, JIT-safe column templates. Base is a single column; larger counts +// step up at sm/lg so the strip never crams on mobile. +const COLS: Record = { + 1: '', + 2: 'sm:grid-cols-2', + 3: 'sm:grid-cols-2 lg:grid-cols-3', + 4: 'sm:grid-cols-2 lg:grid-cols-4', + 5: 'sm:grid-cols-2 lg:grid-cols-5', + 6: 'sm:grid-cols-2 lg:grid-cols-6', +}; + +export interface KPIContentsItem { + /** Small-caps KPI label, e.g. `ROSTER`, `ON THE RECORD`, `OPEN RISKS`. */ + label: string; + /** The figure. Numbers roll on the odometer; strings render statically. */ + value: number | string; + /** Trailing unit (small mono figures). */ + unit?: string; + /** Team-leading value — green numeral + `LEADS` tick. */ + leader?: boolean; + /** Render the numeral in team-ink green for emphasis. */ + emphasis?: boolean; + /** Decimals for a numeric value. */ + decimals?: number; +} + +export interface KPIContentsStripProps { + items: Array; + /** Target columns at the widest breakpoint (default 3). */ + columns?: number; + className?: string; +} + +export function KPIContentsStrip({ items, columns = 3, className }: KPIContentsStripProps) { + return ( +
    + {items.map((it, i) => ( + + ))} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/PlayerRowPlate.tsx b/src/components/baseball/living-annual/molecules/PlayerRowPlate.tsx new file mode 100644 index 000000000..3546a24c8 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/PlayerRowPlate.tsx @@ -0,0 +1,174 @@ +'use client'; + +/** + * PlayerRowPlate — THE record-book stats / roster row (spec §6 #5, #6). + * + * A serif name plate (given lighter + `SURNAME` semibold small-caps) with an + * optional jersey and ``, followed by a right-aligned run of + * `` figures resting on a GREEN bottom rule (clay in the War Room). + * Per the founder addendum a column `leader` figure renders in lane ink so the + * team-best number reads green. Rows are keyboard-accessible when clickable + * (link via `href`, or button semantics via `onClick`). + * + * `PlayerRowPlateHeader` renders the small-caps column labels on the SAME fixed + * stat-column grid so a Stats Center wall lines up under one header. + */ +import Link from 'next/link'; +import type { KeyboardEvent } from 'react'; +import { cn } from '@/lib/utils'; +import { StatReadout, PositionChip, HairlineRule } from '..'; + +// Shared stat-column geometry so the header and every row align to one grid. +const STAT_COL = 'w-20 shrink-0'; +const STAT_GAP = 'gap-x-6'; + +const INK_TEXT: Record<'team' | 'pursuit', string> = { + team: 'text-grade-plus', + pursuit: 'text-pursuit', +}; + +const FOCUS: Record<'team' | 'pursuit', string> = { + team: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-grade-plus', + pursuit: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pursuit', +}; + +export interface PlayerRowStat { + /** Small-caps column label (rendered above the figure on a standalone row). */ + label?: string; + /** The figure. Numbers roll on the odometer; strings render statically. */ + value: number | string; + /** Decimals for a numeric value. */ + decimals?: number; + /** Team-leading value in this column — renders in lane ink (green/clay). */ + leader?: boolean; +} + +export interface PlayerRowPlateProps { + firstName: string; + lastName: string; + /** Jersey number, set in small mono figures ahead of the name. */ + jerseyNumber?: number | string; + /** Position / role chip, e.g. `SS`, `RHP`. */ + position?: string; + /** The stat figures, left→right. */ + stats: Array; + /** Navigate on click (renders as a link). */ + href?: string; + /** Activate on click (renders with button semantics + keyboard support). */ + onClick?: () => void; + /** Lane ink for the bottom rule, leaders + focus ring (default team green). */ + ink?: 'team' | 'pursuit'; + className?: string; +} + +export interface PlayerRowPlateHeaderProps { + /** Column labels, aligned to the row stat columns. */ + columns: string[]; + className?: string; +} + +/** Column-label header row for a wall of ``s. */ +export function PlayerRowPlateHeader({ columns, className }: PlayerRowPlateHeaderProps) { + return ( +
    + Player +
    + {columns.map((c, i) => ( + + {c} + + ))} +
    +
    + ); +} + +export function PlayerRowPlate({ + firstName, + lastName, + jerseyNumber, + position, + stats, + href, + onClick, + ink = 'team', + className, +}: PlayerRowPlateProps) { + const clickable = Boolean(href || onClick); + + function handleKey(e: KeyboardEvent) { + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(); + } + } + + const inner = ( + <> +
    + {/* Name plate */} +
    + {jerseyNumber != null ? ( + {jerseyNumber} + ) : null} + + {firstName} + + {lastName} + + + {position ? ( + + ) : null} +
    + + {/* Stat run */} +
    + {stats.map((s, i) => ( +
    + {s.label ? ( + {s.label} + ) : null} + +
    + ))} +
    +
    + + {/* Green (team) / clay (pursuit) bottom rule the row rests on. */} + + + ); + + const base = cn( + 'block w-full text-left', + clickable && cn('cursor-pointer rounded-fw-md transition-colors hover:bg-[color:var(--paper-canvas)]', FOCUS[ink]), + className, + ); + + if (href) { + return ( + + {inner} + + ); + } + + if (onClick) { + return ( +
    + {inner} +
    + ); + } + + return
    {inner}
    ; +} diff --git a/src/components/baseball/living-annual/molecules/RecruitCard.tsx b/src/components/baseball/living-annual/molecules/RecruitCard.tsx new file mode 100644 index 000000000..c7c14cfb3 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/RecruitCard.tsx @@ -0,0 +1,127 @@ +'use client'; + +/** + * RecruitCard — the War Room pipeline chip (spec §6 #2). CLAY lane only. + * + * A mini box-score chip on Paper stock: a serif surname over a `pos · class · + * state` ``, ONE clay `` top measurable, up to three + * ``s, a clay `` that darkens like a deadline, and an + * `` stamping the pipeline stage. Drag is the only elevation moment, + * so this stays flat; it is drag-friendly — it forwards `className` (for the dnd + * transform) and is keyboard-activatable when `onClick` is set. + */ +import type { KeyboardEvent } from 'react'; +import { cn } from '@/lib/utils'; +import { PaperCard, Eyebrow, RuledStatLine, GradeStamp, AgingBar, InkBadge } from '..'; + +export interface RecruitCardGrade { + /** Tool name, e.g. `HIT`, `POWER`, `ARM`. */ + tool: string; + /** The 20-80 grade. */ + value: number; + /** Solid present grade vs. ghost future/projected outline. */ + present?: boolean; +} + +export interface RecruitCardProps { + firstName: string; + lastName: string; + /** Position / role, e.g. `SS`, `RHP`. */ + position?: string; + /** Class year, e.g. `2027`. */ + classYear?: string; + /** Home state, e.g. `TX`. */ + state?: string; + /** The headline measurable (one ruled clay stat line). */ + topStat?: { label: string; value: number | string; unit?: string }; + /** Up to three tool grades (extra entries are dropped). */ + grades?: Array; + /** Days since last contact — drives the clay aging bar. */ + daysSinceContact?: number; + /** Pipeline stage, stamped as an ink badge, e.g. `HIGH PRIORITY`. */ + stage?: string; + /** Open the dossier on click (keyboard-activatable). */ + onClick?: () => void; + className?: string; +} + +export function RecruitCard({ + firstName, + lastName, + position, + classYear, + state, + topStat, + grades, + daysSinceContact, + stage, + onClick, + className, +}: RecruitCardProps) { + const clickable = Boolean(onClick); + + function handleKey(e: KeyboardEvent) { + if (onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(); + } + } + + const inner = ( + +
    +
    + {firstName} + + {lastName} + +
    + {stage ? : null} +
    + + {position || classYear || state ? ( + + ) : null} + + {topStat ? ( +
    + +
    + ) : null} + + {grades && grades.length > 0 ? ( +
    + {grades.slice(0, 3).map((g, i) => ( + + ))} +
    + ) : null} + + {daysSinceContact != null ? ( +
    + +
    + ) : null} +
    + ); + + const base = cn( + 'block w-full text-left', + clickable && + 'cursor-pointer rounded-card transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pursuit', + className, + ); + + if (onClick) { + return ( +
    + {inner} +
    + ); + } + + return
    {inner}
    ; +} diff --git a/src/components/baseball/living-annual/molecules/SlashLine.tsx b/src/components/baseball/living-annual/molecules/SlashLine.tsx new file mode 100644 index 000000000..19a0bd6d2 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/SlashLine.tsx @@ -0,0 +1,73 @@ +/** + * SlashLine — the batting slash line `.341 / .420 / .611` (spec §7 "stacks + * fractally for slash lines"). + * + * Three `` figures set in `font-annual tabular-nums`, joined by thin + * recessive slashes. Per the founder addendum the figures carry the contrast in + * near-black graphite by default; the `leader` figure (AVG/OBP/SLG) renders in + * lane ink — GREEN in team lanes, clay in the War Room — so the eye lands on the + * best number. Numeric values are formatted the baseball way via `formatRate` + * (leading zero dropped below 1 → `.341`, kept at/above 1 → `1.089` OPS); pass a + * pre-formatted string to render it verbatim. + * + * No hooks / handlers — composes client `` but is itself safe in a + * server component. + */ +import { Fragment } from 'react'; +import { cn } from '@/lib/utils'; +import { StatReadout } from '..'; +import { formatRate } from '../format'; + +const SIZE: Record<'sm' | 'md' | 'lg', string> = { + sm: 'text-h2', + md: 'text-h1', + lg: 'text-display', +}; + +const INK_TEXT: Record<'team' | 'pursuit', string> = { + team: 'text-grade-plus', + pursuit: 'text-pursuit', +}; + +export interface SlashLineProps { + /** Batting average (`.341` or `0.341`). */ + avg: number | string; + /** On-base percentage. */ + obp: number | string; + /** Slugging percentage. */ + slg: number | string; + /** Figure scale (default `md`). */ + size?: 'sm' | 'md' | 'lg'; + /** Which slot leads — renders that figure in lane ink (green/clay). */ + leader?: 'avg' | 'obp' | 'slg'; + /** Lane ink for the leader figure (default team green). */ + ink?: 'team' | 'pursuit'; + className?: string; +} + +export function SlashLine({ avg, obp, slg, size = 'md', leader, ink = 'team', className }: SlashLineProps) { + const cells: Array<{ key: 'avg' | 'obp' | 'slg'; value: number | string; aria: string }> = [ + { key: 'avg', value: avg, aria: 'Batting average' }, + { key: 'obp', value: obp, aria: 'On-base percentage' }, + { key: 'slg', value: slg, aria: 'Slugging percentage' }, + ]; + + return ( +
    + {cells.map((c, i) => ( + + {i > 0 ? ( + + / + + ) : null} + + + ))} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/StatLineStack.tsx b/src/components/baseball/living-annual/molecules/StatLineStack.tsx new file mode 100644 index 000000000..ae4c4f7ee --- /dev/null +++ b/src/components/baseball/living-annual/molecules/StatLineStack.tsx @@ -0,0 +1,37 @@ +/** + * StatLineStack — a vertical column of ``s (spec §7 "as column + * spines"; the passport measurable stack, §6 #1). + * + * The Player Passport's `60-YARD / EXIT VELO / POP TIME` stack, or any column of + * ruled measurables. Each row self-settles + draws its own green baseline rule + * on mount (the atom owns its motion), so this molecule is pure layout: it maps + * `items` → `` at one of two rhythms. + * + * No hooks / handlers — safe in a server component. + */ +import { cn } from '@/lib/utils'; +import { RuledStatLine } from '..'; +import type { RuledStatLineProps } from '..'; + +const GAP: Record<'tight' | 'normal', string> = { + tight: 'gap-3', + normal: 'gap-6', +}; + +export interface StatLineStackProps { + /** The measurables, each a full `` spec. */ + items: Array; + /** Row rhythm (default `normal`). */ + gap?: 'tight' | 'normal'; + className?: string; +} + +export function StatLineStack({ items, gap = 'normal', className }: StatLineStackProps) { + return ( +
    + {items.map((item, i) => ( + + ))} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/TearSheet.tsx b/src/components/baseball/living-annual/molecules/TearSheet.tsx new file mode 100644 index 000000000..6c4b068aa --- /dev/null +++ b/src/components/baseball/living-annual/molecules/TearSheet.tsx @@ -0,0 +1,62 @@ +/** + * TearSheet — the Scout Packet one-page layout (spec §6 #9, the exportable + * artifact). + * + * A print-perfect tear-sheet on Paper stock: a `` name block with its + * green accent rule and a pressed ``, a `` of ruled + * measurables, a `children` viz slot (a spray/break infographic), and a footer + * dateline (`ISSUED BY … · JUL ’26`) under a hairline. Screen and PDF are one + * object, so the layout is print-friendly (borderless / shadowless in print). + * + * No hooks / handlers — safe in a server component. + */ +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { Masthead, PaperCard, HairlineRule, PacketSeal } from '..'; +import type { RuledStatLineProps } from '..'; +import { StatLineStack } from './StatLineStack'; + +export interface TearSheetPlayer { + firstName: string; + lastName: string; + /** Small-caps dateline, e.g. `SS · 2027 · TX`. */ + eyebrow?: string; +} + +export interface TearSheetProps { + player: TearSheetPlayer; + /** Ruled measurables (the passport stat stack). */ + measurables: Array; + /** Footer dateline, e.g. `ISSUED BY RINI UNIVERSITY BASEBALL · JUL ’26`. */ + footer?: string; + /** Viz slot — a spray chart / break plot infographic. */ + children?: ReactNode; + className?: string; +} + +export function TearSheet({ player, measurables, footer, children, className }: TearSheetProps) { + return ( + +
    + + +
    + +
    + +
    + + {children ?
    {children}
    : null} + + {footer ? ( +
    + +

    {footer}

    +
    + ) : null} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/ToolRailStack.tsx b/src/components/baseball/living-annual/molecules/ToolRailStack.tsx new file mode 100644 index 000000000..0360441f7 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/ToolRailStack.tsx @@ -0,0 +1,55 @@ +/** + * ToolRailStack — a player's 20-80 tool card (spec §7, §6 #10 Decision Room). + * + * A vertical stack of ``s — one per tool (HIT / POWER / RUN / ARM / + * FIELD) — each drawing its baseline rule and placing a present pip (+ optional + * future/ceiling pip) on mount. `compare` overlays a second athlete on the SAME + * rails (the Decision Room compare): the primary reads team green, the compared + * player reads clay, so the louder tool is visible before a digit. A tool with + * `present: false` (not yet earned) dims to a projection. + * + * No hooks / handlers — safe in a server component. + */ +import { cn } from '@/lib/utils'; +import { ToolRail } from '..'; + +export interface ToolRailStackTool { + /** Tool name, hung left, e.g. `POWER`. */ + tool: string; + /** Present 20-80 grade (filled pip). */ + value: number; + /** Graded yet? `false` dims the rail to a projection. */ + present?: boolean; + /** Projected / ceiling grade (hollow pip + connector). */ + future?: number; +} + +export interface ToolRailStackProps { + tools: Array; + /** A second athlete overlaid on the same rails (clay), matched by tool name. */ + compare?: Array<{ tool: string; value: number }>; + className?: string; +} + +export function ToolRailStack({ tools, compare, className }: ToolRailStackProps) { + const compareMap = new Map((compare ?? []).map((c) => [c.tool, c.value])); + + return ( +
    + {tools.map((t, i) => { + const cmp = compareMap.get(t.tool); + return ( + + ); + })} +
    + ); +} diff --git a/src/components/baseball/living-annual/molecules/index.ts b/src/components/baseball/living-annual/molecules/index.ts new file mode 100644 index 000000000..e8b9c1f39 --- /dev/null +++ b/src/components/baseball/living-annual/molecules/index.ts @@ -0,0 +1,43 @@ +/** + * BaseballHelm "The Living Annual" — L2 MOLECULES. + * + * Composed, reused-across-surfaces blocks built strictly from the L1 atom kit + * (spec: docs/baseball/design-system-living-annual.md §7; map: + * docs/baseball/ui-migration-map.md L2). Every molecule composes atoms — it + * never re-implements a rule, numeral, stamp, or seal. Import from this barrel: + * + * import { PlayerRowPlate, RecruitCard, EmptyIssue } from + * '@/components/baseball/living-annual/molecules'; + */ + +// ── Stat composition ── +export { SlashLine } from './SlashLine'; +export type { SlashLineProps } from './SlashLine'; +export { StatLineStack } from './StatLineStack'; +export type { StatLineStackProps } from './StatLineStack'; +export { KPIContentsStrip } from './KPIContentsStrip'; +export type { KPIContentsStripProps, KPIContentsItem } from './KPIContentsStrip'; + +// ── Record-book rows ── +export { PlayerRowPlate, PlayerRowPlateHeader } from './PlayerRowPlate'; +export type { PlayerRowPlateProps, PlayerRowPlateHeaderProps, PlayerRowStat } from './PlayerRowPlate'; + +// ── Recruiting (clay lane) ── +export { RecruitCard } from './RecruitCard'; +export type { RecruitCardProps, RecruitCardGrade } from './RecruitCard'; + +// ── Evaluation block ── +export { ToolRailStack } from './ToolRailStack'; +export type { ToolRailStackProps, ToolRailStackTool } from './ToolRailStack'; +export { GradeStampGrid } from './GradeStampGrid'; +export type { GradeStampGridProps, GradeStampGridGrade } from './GradeStampGrid'; + +// ── Hero + artifact ── +export { CoverHero } from './CoverHero'; +export type { CoverHeroProps } from './CoverHero'; +export { TearSheet } from './TearSheet'; +export type { TearSheetProps, TearSheetPlayer } from './TearSheet'; + +// ── Composed empty states ── +export { EmptyIssue, EMPTY_ISSUE_PRESETS } from './EmptyIssue'; +export type { EmptyIssueProps, EmptyIssueVariant, EmptyIssuePreset } from './EmptyIssue'; diff --git a/src/components/baseball/living-annual/viz/BreakPlot.tsx b/src/components/baseball/living-annual/viz/BreakPlot.tsx new file mode 100644 index 000000000..62a296795 --- /dev/null +++ b/src/components/baseball/living-annual/viz/BreakPlot.tsx @@ -0,0 +1,191 @@ +/** + * BreakPlot — the pitch-movement instrument of "The Break Room" (spec §6 P1 #4). + * + * A Statcast-style break plot dropped onto the ONE dark surface: a home-plate / + * strike-zone axis drawn in chalk on a {@link ClayCanvas}, with each pitch type + * a `` stroke BREAKING from a common release point down to its plate + * location (hbreak/vbreak, in inches). Velocity drives glow intensity, and an + * optional `compareTo` older arsenal GHOSTS behind in chalk so *added break* is + * legible as the distance between the old and new strokes. + * + * Colour law (spec §4.2 / §6): green = the working arsenal, `--sodium` marks the + * single highlighted (hardest) pitch, chalk = the prior/compare shape. + * + * ┌─ COORDINATE CONTRACT ─────────────────────────────────────────────────────┐ + * │ `hbreak` / `vbreak` are BREAK IN INCHES, catcher's view: │ + * │ • +hbreak → arm-side / to the plot's RIGHT, −hbreak → glove-side / LEFT. │ + * │ • +vbreak → "rise" / UP (less drop), −vbreak → DOWN (more drop). │ + * │ Zero break is dead-center. Break is clamped to ±22" so a wild value can't │ + * │ escape the frame; a real feed can rescale by pre-normalising to that range. │ + * └────────────────────────────────────────────────────────────────────────────┘ + * + * Composition only — no hooks. `` carries its own client boundary and the + * reduced-motion contract (strokes render fully-drawn + static when reduced). + * Empty `pitches` → an honest {@link EditorsLetter}, never a fabricated chart. + */ +import { cn } from '@/lib/utils'; +import { ClayCanvas } from '../ClayCanvas'; +import { EditorsLetter } from '../EditorsLetter'; +import { Trace } from '../Trace'; + +export interface BreakPlotPitch { + /** Pitch label, e.g. `FF`, `SL`, `CH`. */ + type: string; + /** Horizontal break in inches (catcher's view: +right / −left). */ + hbreak: number; + /** Vertical break in inches (+rise / −drop). */ + vbreak: number; + /** Release velocity (mph). Drives glow intensity + the sodium highlight. */ + velo?: number; + /** Sample size behind this shape (shown in the label). */ + count?: number; +} + +export interface BreakPlotProps { + /** The working arsenal. Empty → honest empty state. */ + pitches: BreakPlotPitch[]; + /** An older arsenal to ghost behind in chalk (added-break comparison). */ + compareTo?: BreakPlotPitch[]; + /** Chalk dateline shown in the canvas corner. */ + title?: string; + className?: string; +} + +// ── Frame geometry (SVG user units; the caller owns the viewBox) ────────────── +const VB = 320; +const CENTER = VB / 2; +const RELEASE = { x: CENTER, y: 26 }; +const MAX_BREAK_IN = 22; +const PLATE_RADIUS = 128; +const SCALE = PLATE_RADIUS / MAX_BREAK_IN; // px per inch + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +/** Map break inches → a plate point in the frame's coordinate space. */ +function platePoint(hbreak: number, vbreak: number): { px: number; py: number } { + const px = CENTER + clamp(hbreak, -MAX_BREAK_IN, MAX_BREAK_IN) * SCALE; + const py = CENTER - clamp(vbreak, -MAX_BREAK_IN, MAX_BREAK_IN) * SCALE; + return { px, py }; +} + +/** A late-breaking curve from the shared release point to a plate point. */ +function breakPath(px: number, py: number): string { + const cx = RELEASE.x + (px - RELEASE.x) * 0.22; + const cy = (RELEASE.y + py) / 2 + 8; + return `M ${RELEASE.x} ${RELEASE.y} Q ${cx.toFixed(2)} ${cy.toFixed(2)} ${px.toFixed(2)} ${py.toFixed(2)}`; +} + +/** A round-cap dot (a near-zero-length subpath renders as a filled circle). */ +function dotPath(px: number, py: number): string { + return `M ${px.toFixed(2)} ${py.toFixed(2)} l 0.01 0`; +} + +function LegendSwatch({ className, children }: { className: string; children: string }) { + return ( + + + {children} + + ); +} + +export function BreakPlot({ pitches, compareTo, title, className }: BreakPlotProps) { + if (!pitches.length) { + return ( + + ); + } + + // Velo drives glow + the single sodium highlight (the hardest pitch stands out). + const velos = pitches + .map((p) => p.velo) + .filter((v): v is number => typeof v === 'number'); + const maxVelo = velos.length ? Math.max(...velos) : null; + const highlightIdx = + maxVelo === null ? -1 : pitches.findIndex((p) => p.velo === maxVelo); + const isHot = (velo: number | undefined): boolean => + maxVelo !== null && typeof velo === 'number' && velo >= maxVelo - 2; + + return ( + +
    + + {/* Chalk reference graticule — zero-break cross + strike zone. */} + + + + + {/* Home plate at the base of the frame. */} + + + + {/* Prior arsenal — chalk ghost, drawn first (static, behind). */} + {compareTo?.map((p, i) => { + const { px, py } = platePoint(p.hbreak, p.vbreak); + return ( + + ); + })} + + {/* The working arsenal — one breaking Trace + endpoint dot per pitch. */} + {pitches.map((p, i) => { + const { px, py } = platePoint(p.hbreak, p.vbreak); + const ink = i === highlightIdx ? 'sodium' : 'team'; + const hot = isHot(p.velo); + return ( + + + + + ); + })} + + + {/* Pitch labels — HTML overlay so numerals stay crisp + tabular. */} + {pitches.map((p, i) => { + const { px, py } = platePoint(p.hbreak, p.vbreak); + const highlight = i === highlightIdx; + return ( + + + {p.type} + {typeof p.velo === 'number' ? ( + {Math.round(p.velo)} + ) : null} + + + ); + })} +
    + + {/* Legend — mono/small-caps colorway caption. */} +
    + Arsenal + {highlightIdx >= 0 ? Top velo : null} + {compareTo?.length ? Prior : null} +
    +
    + ); +} diff --git a/src/components/baseball/living-annual/viz/ClimbArc.tsx b/src/components/baseball/living-annual/viz/ClimbArc.tsx new file mode 100644 index 000000000..200aa0f37 --- /dev/null +++ b/src/components/baseball/living-annual/viz/ClimbArc.tsx @@ -0,0 +1,246 @@ +/** + * ClimbArc — the Development "Climb" (spec §6 P2 #8; replaces "no plan yet"). + * + * A season progress arc on PAPER (not clay — this is the athlete's own cover + * story, not the analytics desk). One tracked skill (exit velo, K-rate, a + * composite…) draws as a `` filling a smooth arc across the season, with: + * • a faint "then" GHOST trace at the start (where the season began), + * • a bright, glowing "now" head at the latest reading, + * • the single best day pinned in `--sodium` as a PR marker, + * • an optional coach `goal` as a target tick line. + * The live current value rides a {@link StatReadout} in the header so it rolls on + * the odometer and flashes sodium the day it becomes a personal record. + * + * ┌─ COORDINATE CONTRACT ─────────────────────────────────────────────────────┐ + * │ `points` are an ORDERED season series (oldest → newest); index is the │ + * │ x-axis (evenly spaced), `value` is the y-axis. The plot auto-scales to the │ + * │ value range (plus any `goal`) so a feed hands raw values in — no │ + * │ pre-normalising. Higher `value` sits higher on the arc. │ + * └────────────────────────────────────────────────────────────────────────────┘ + * + * Composition only — no hooks. `` / `` own their client + * boundaries + the reduced-motion contract. Fewer than 2 points → an honest + * {@link EditorsLetter} ("not enough data yet"), never a fabricated curve. + */ +import { cn } from '@/lib/utils'; +import { EditorsLetter } from '../EditorsLetter'; +import { Eyebrow } from '../Eyebrow'; +import { PaperCard } from '../PaperCard'; +import { StatReadout } from '../StatReadout'; +import { Trace } from '../Trace'; + +export interface ClimbPoint { + /** Optional axis label for this reading (e.g. a date). */ + label?: string; + /** The tracked value. */ + value: number; +} + +export interface ClimbArcProps { + /** Ordered season series (oldest → newest). Fewer than 2 → honest empty. */ + points: ClimbPoint[]; + /** Coach-assigned target — shown as a tick line across the arc. */ + goal?: number; + /** Small-caps unit for the readout + goal caption (e.g. `MPH`, `%`). */ + unit?: string; + /** Eyebrow title above the arc. */ + title?: string; + /** Lane ink for the climb stroke (default team green). */ + ink?: 'team'; + className?: string; +} + +// ── Frame geometry (SVG user units; the caller owns the viewBox) ────────────── +const VBW = 340; +const VBH = 180; +const PAD = { l: 18, r: 18, t: 22, b: 26 }; +const PLOT_W = VBW - PAD.l - PAD.r; +const PLOT_H = VBH - PAD.t - PAD.b; + +interface PlotPoint { + x: number; + y: number; + value: number; + label?: string; +} + +/** Smooth curve through points via quadratic midpoint interpolation. */ +function smoothPath(pts: PlotPoint[]): string { + const first = pts[0]; + if (!first) return ''; + let d = `M ${first.x.toFixed(2)} ${first.y.toFixed(2)}`; + let prev = first; + for (let i = 1; i < pts.length; i += 1) { + const cur = pts[i]; + if (!cur) continue; + const midX = (prev.x + cur.x) / 2; + const midY = (prev.y + cur.y) / 2; + d += ` Q ${prev.x.toFixed(2)} ${prev.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`; + prev = cur; + } + return `${d} L ${prev.x.toFixed(2)} ${prev.y.toFixed(2)}`; +} + +/** A round-cap dot (a near-zero-length subpath renders as a filled circle). */ +function dotPath(px: number, py: number): string { + return `M ${px.toFixed(2)} ${py.toFixed(2)} l 0.01 0`; +} + +function pct(coord: number, span: number): string { + return `${(coord / span) * 100}%`; +} + +export function ClimbArc({ points, goal, unit, title, ink = 'team', className }: ClimbArcProps) { + if (points.length < 2) { + return ( + + ); + } + + const values = points.map((p) => p.value); + let lo = Math.min(...values); + let hi = Math.max(...values); + if (typeof goal === 'number') { + lo = Math.min(lo, goal); + hi = Math.max(hi, goal); + } + if (hi === lo) { + // Flat series — give the arc a little air so it doesn't sit on the floor. + hi = lo + 1; + lo -= 1; + } else { + const airspace = (hi - lo) * 0.12; + lo -= airspace; + hi += airspace; + } + + const n = points.length; + const xAt = (i: number): number => PAD.l + (i / (n - 1)) * PLOT_W; + const yAt = (v: number): number => PAD.t + (1 - (v - lo) / (hi - lo)) * PLOT_H; + + const pts: PlotPoint[] = points.map((p, i) => ({ x: xAt(i), y: yAt(p.value), value: p.value, label: p.label })); + + let bestIdx = 0; + let bestVal = -Infinity; + values.forEach((v, i) => { + if (v > bestVal) { + bestVal = v; + bestIdx = i; + } + }); + const lastIdx = n - 1; + const nowIsPeak = bestIdx === lastIdx; + const decimals = values.some((v) => !Number.isInteger(v)) ? 1 : 0; + + // The faint "then" ghost — the opening stretch of the season, drawn behind. + const ghostCut = Math.max(2, Math.ceil(n * 0.35)); + const ghostPts = pts.slice(0, ghostCut); + + const now = pts[lastIdx]; + const best = pts[bestIdx]; + const start = pts[0]; + // Unreachable given the `points.length < 2` guard above; narrows for TS + // under noUncheckedIndexedAccess without a non-null assertion. + if (!now || !best || !start) return null; + + const goalY = typeof goal === 'number' ? yAt(goal) : null; + + return ( + + {/* Header — title + the live current reading (odometer + PR flash). */} +
    +
    + {title ? {title} : null} + Season climb +
    +
    + Now + + {unit ? ( + {unit} + ) : null} +
    +
    + + {/* The arc */} +
    + + {/* Goal target tick — a dashed team hairline across the plot. */} + {goalY !== null ? ( + + ) : null} + + {/* "Then" ghost — the opening stretch, faint + static, drawn behind. */} + + + {/* The climb — the signature filling arc. */} + + + {/* Start dot ("then" anchor). */} + + + {/* PR marker — the best day, pinned in sodium (skip if it IS "now"). */} + {nowIsPeak ? null : } + + {/* "Now" head — bright + glowing; sodium when the latest is the PR. */} + + + + {/* Overlay captions — small-caps, pinned to their marks. */} + + Then + + {nowIsPeak ? null : ( + + PR {best.value.toFixed(decimals)} + + )} + {goalY !== null ? ( + + Goal {goal?.toFixed(decimals)} + {unit ? ` ${unit}` : ''} + + ) : null} +
    + + {/* Axis — first + last labels. */} + {start.label || now.label ? ( +
    + {start.label ?? ''} + {now.label ?? ''} +
    + ) : null} +
    + ); +} diff --git a/src/components/baseball/living-annual/viz/SprayChart.tsx b/src/components/baseball/living-annual/viz/SprayChart.tsx new file mode 100644 index 000000000..670d1c413 --- /dev/null +++ b/src/components/baseball/living-annual/viz/SprayChart.tsx @@ -0,0 +1,218 @@ +/** + * SprayChart — the batted-ball half of "The Break Room" (spec §6 P1 #4). + * + * A baseball field wedge — foul lines + outfield arc + a hint of infield — drawn + * in chalk on the ONE dark {@link ClayCanvas}, or as a light `paper` variant + * (clay-ink field lines on cream). Batted balls arrive as small round `` + * marks coloured by outcome per the colour law (spec §4.2 / §6): + * • green = a hit (strength) + * • clay = an out (weak contact) + * • sodium = a home run (the single highlighted outcome) + * • neutral = anything else + * + * ┌─ COORDINATE CONTRACT ─────────────────────────────────────────────────────┐ + * │ `x` / `y` are NORMALISED field space in [0, 1], home plate at bottom-centre:│ + * │ • x: 0 = left-field foul line, 1 = right-field foul line, 0.5 = dead │ + * │ centre. Values are clamped into fair territory. │ + * │ • y: 0 = deep outfield fence, 1 = home plate. (depth from home = 1 − y). │ + * │ A real feed maps landing coordinates into this square before handing rows │ + * │ in; anything out of [0,1] is clamped so a stray point can't leave the wedge.│ + * └────────────────────────────────────────────────────────────────────────────┘ + * + * Composition only — no hooks. `` owns its client boundary + the + * reduced-motion contract. Empty `battedBalls` → an honest {@link EditorsLetter}. + */ +import { cn } from '@/lib/utils'; +import { ClayCanvas } from '../ClayCanvas'; +import { EditorsLetter } from '../EditorsLetter'; +import { Eyebrow } from '../Eyebrow'; +import { PaperCard } from '../PaperCard'; +import { Trace } from '../Trace'; + +export type SprayOutcome = 'hit' | 'out' | 'hr' | 'other'; + +export interface SprayBall { + /** Normalised lateral position [0,1] — 0 left line, 1 right line. */ + x: number; + /** Normalised depth [0,1] — 0 fence, 1 home plate. */ + y: number; + /** Batted-ball result; drives the mark ink. */ + outcome?: SprayOutcome; +} + +export interface SprayChartProps { + /** Charted batted balls. Empty → honest empty state. */ + battedBalls: SprayBall[]; + /** `clay` (default) = the dark viz surface; `paper` = a light cream variant. */ + surface?: 'clay' | 'paper'; + /** Dateline / eyebrow shown above the field. */ + title?: string; + className?: string; +} + +type Ink = 'team' | 'sodium' | 'chalk' | 'pursuit'; + +// ── Frame geometry (SVG user units; the caller owns the viewBox) ────────────── +const VBW = 320; +const VBH = 300; +const HOME = { x: 160, y: 264 }; +const LEFT_CORNER = { x: 42, y: 60 }; +const RIGHT_CORNER = { x: 278, y: 60 }; + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)); +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Map a normalised (x,y) field point into the fair wedge in frame space. */ +function fieldPoint(x: number, y: number): { px: number; py: number } { + const depth = 1 - clamp01(y); // 0 at home, 1 at the fence + const leftX = lerp(HOME.x, LEFT_CORNER.x, depth); + const rightX = lerp(HOME.x, RIGHT_CORNER.x, depth); + const rowY = lerp(HOME.y, (LEFT_CORNER.y + RIGHT_CORNER.y) / 2, depth); + return { px: lerp(leftX, rightX, clamp01(x)), py: rowY }; +} + +/** A round-cap dot (a near-zero-length subpath renders as a filled circle). */ +function dotPath(px: number, py: number): string { + return `M ${px.toFixed(2)} ${py.toFixed(2)} l 0.01 0`; +} + +/** Ink per outcome; the neutral case stays visible on either surface. */ +function ballInk(outcome: SprayOutcome | undefined, surface: 'clay' | 'paper'): Ink { + switch (outcome) { + case 'hr': + return 'sodium'; + case 'hit': + return 'team'; + case 'out': + return 'pursuit'; + default: + return surface === 'clay' ? 'chalk' : 'pursuit'; + } +} + +const OUTCOME_ORDER: SprayOutcome[] = ['hit', 'out', 'hr']; +const OUTCOME_SWATCH: Record = { + hit: 'bg-grade-plus', + out: 'bg-pursuit', + hr: 'bg-sodium', + other: 'bg-chalk', +}; +const OUTCOME_LABEL: Record = { + hit: 'Hit', + out: 'Out', + hr: 'HR', + other: 'Other', +}; + +export function SprayChart({ battedBalls, surface = 'clay', title, className }: SprayChartProps) { + if (!battedBalls.length) { + return ( + + ); + } + + const isClay = surface === 'clay'; + const lineInk: Ink = isClay ? 'chalk' : 'pursuit'; + const lineOpacity = isClay ? 'opacity-40' : 'opacity-30'; + + // Colorway legend counts (mono/small-caps caption). + const counts = OUTCOME_ORDER.map((o) => ({ + outcome: o, + n: battedBalls.filter((b) => (b.outcome ?? 'other') === o).length, + })).filter((c) => c.n > 0); + + const captionInk = isClay ? 'text-chalk/70' : 'text-text-tertiary'; + + const field = ( +
    + + {/* Field lines — foul lines, outfield arc, infield diamond. */} + + + + + {/* Infield diamond — home → 1B → 2B → 3B → home. */} + + + + {/* Batted balls — small round marks, one Trace dot each. */} + {battedBalls.map((b, i) => { + const { px, py } = fieldPoint(b.x, b.y); + const outcome = b.outcome ?? 'other'; + return ( + + ); + })} + +
    + ); + + const legend = ( +
    + {counts.map(({ outcome, n }) => ( + + + {OUTCOME_LABEL[outcome]} + {n} + + ))} +
    + ); + + if (isClay) { + return ( + + {field} + {legend} + + ); + } + + return ( + + {title ? {title} : null} + {field} + {legend} + + ); +} diff --git a/src/components/baseball/living-annual/viz/index.ts b/src/components/baseball/living-annual/viz/index.ts new file mode 100644 index 000000000..90ce03c20 --- /dev/null +++ b/src/components/baseball/living-annual/viz/index.ts @@ -0,0 +1,20 @@ +/** + * BaseballHelm "The Living Annual" — VIZ MOLECULE layer. + * + * The baseball-native data-viz that golf structurally can't do (spec §6 P1 #4 / + * P2 #8): pitch break + batted-ball spray on the ONE dark ClayCanvas, and the + * Development "Climb" arc on paper. Every molecule composes the atom kit's + * `` stroke language + honest `` empty states, so an + * empty feed reads like an unreleased first issue — never a fabricated chart. + * + * import { BreakPlot, SprayChart, ClimbArc } from '@/components/baseball/living-annual/viz'; + */ + +export { BreakPlot } from './BreakPlot'; +export type { BreakPlotProps, BreakPlotPitch } from './BreakPlot'; + +export { SprayChart } from './SprayChart'; +export type { SprayChartProps, SprayBall, SprayOutcome } from './SprayChart'; + +export { ClimbArc } from './ClimbArc'; +export type { ClimbArcProps, ClimbPoint } from './ClimbArc'; From e7c341a2e1b2fde727079a3596f26328d22c3e76 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 16:18:24 -0400 Subject: [PATCH 040/186] feat(baseball): migrate Command Center + Stats Center to the Living-Annual kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command Center → magazine cover: CoverHero (next opponent), KPIContentsStrip (real read-model KPIs on green rules), CoachHelm brief as a signed EditorsLetter (replaces two amber warning boxes). Collapsed the isRedesignEnabled fork; DELETED legacy CommandCenterClient + orphaned TeamPlayerPeekPanel; repointed the product-trust test. Stats Center → record-book roster spread: SectionMasthead + KPIContentsStrip + a PlayerRowPlate wall per side (batting/pitching), team-leaders in green (≥2 qualifiers), rate stats via formatRate/formatRatio, ghost rows for no-data, EmptyIssue/EditorsLetter empties. Batting/pitching + official/all toggles, CSV export, visuals gallery all preserved. DELETED the old identical-card wall (PlayerStatCard/BattingBlock/…). Presentation-only — read models, server actions, auth/RLS untouched. tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- .../dashboard/command-center/page.tsx | 83 +- .../command-center/CommandCenterClient.tsx | 865 ------------- .../command-center/CommandCenterFairway.tsx | 329 ++--- .../command-center/TeamPlayerPeekPanel.tsx | 293 ----- .../stats-center/StatsCenterClient.tsx | 1119 ++++++----------- .../baseball/product-trust.contract.test.ts | 11 +- 6 files changed, 611 insertions(+), 2089 deletions(-) delete mode 100644 src/components/baseball/command-center/CommandCenterClient.tsx delete mode 100644 src/components/baseball/command-center/TeamPlayerPeekPanel.tsx diff --git a/src/app/baseball/(dashboard)/dashboard/command-center/page.tsx b/src/app/baseball/(dashboard)/dashboard/command-center/page.tsx index b1994d984..7edc97c26 100644 --- a/src/app/baseball/(dashboard)/dashboard/command-center/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/command-center/page.tsx @@ -1,9 +1,8 @@ import { createClient } from '@/lib/supabase/server'; import { getSessionProfile } from '@/lib/auth/session'; import { redirect } from 'next/navigation'; -import { CommandCenterClient } from '@/components/baseball/command-center/CommandCenterClient'; import { CommandCenterFairway } from '@/components/baseball/command-center/CommandCenterFairway'; -import { isRedesignEnabled, fairwayScope } from '@/lib/redesign/flag'; +import { fairwayScope } from '@/lib/redesign/flag'; import { getCommandCenter } from '@/lib/baseball/read-models/command-center'; import { assembleCommandCenterClientProps } from '@/lib/baseball/read-models/command-center-adapter'; import { getCoachDailyContracts } from '@/lib/baseball/read-models/coach-daily-contracts'; @@ -48,31 +47,18 @@ export default async function CommandCenterPage() { ); if (!teamId) { - if (isRedesignEnabled()) { - return ( -
    - -
    - ); - } return ( - +
    + +
    ); } @@ -101,37 +87,20 @@ export default async function CommandCenterPage() { coachDailyContracts, }); - if (isRedesignEnabled()) { - return ( -
    - -
    - ); - } - return ( - +
    + +
    ); } diff --git a/src/components/baseball/command-center/CommandCenterClient.tsx b/src/components/baseball/command-center/CommandCenterClient.tsx deleted file mode 100644 index 1ceb6f9a5..000000000 --- a/src/components/baseball/command-center/CommandCenterClient.tsx +++ /dev/null @@ -1,865 +0,0 @@ -'use client'; - -import { useState, useMemo } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { LazyMotion, domAnimation, m, useReducedMotion, AnimatePresence } from 'framer-motion'; -import { BaseballInviteButton } from './BaseballInviteButton'; -import { TeamPlayerPeekPanel } from './TeamPlayerPeekPanel'; -import { - IconSearch, - IconUsers, - IconUpload, - IconChartBar, - IconCalendar, - IconTrendingUp, - IconTrendingDown, - IconMinus, - IconFilter, - IconChevronDown, -} from '@/components/icons'; -import type { BaseballRosterPlayer } from '@/lib/types'; -import type { RiskFeedItem } from '@/lib/baseball/read-models/command-center'; -import type { CoachDailyContractsReadModel } from '@/lib/baseball/read-models/coach-daily-contracts'; -import type { ReadModelLoadState } from '@/lib/product-trust/read-model-state'; -import { Button } from '@/components/ui/button'; -import { RiskFeedStrip } from './RiskFeedStrip'; -import { DailyBriefPanel } from './DailyBriefPanel'; -import { CoachDailyContracts } from '@/components/baseball/coach-daily-contract'; -import { - GameVsPracticePanel, - PlayerPerformanceGrid, - TeamBattingOverview, - TrendAnalysisPanel, -} from './analytics'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface CalendarEvent { - id: string; - title: string; - event_type: string; - start_time: string; - end_time: string | null; -} - -interface CommandCenterClientProps { - team: { - id: string; - name: string; - teamType: string; - inviteCode: string | null; - }; - players: BaseballRosterPlayer[]; - coachId: string; - coachName?: string; - calendarEvents?: CalendarEvent[]; - riskFeed?: RiskFeedItem[]; - riskFeedError?: string | null; - coachDailyContracts?: CoachDailyContractsReadModel; - summary?: { - openRisks: number; - criticalRisks: number; - rosterSize: number; - playersWithData: number; - eventsToday: number; - }; - loadState?: ReadModelLoadState; -} - -type MainTab = 'roster' | 'stats'; -type PositionFilter = 'all' | 'P' | 'C' | '1B' | '2B' | 'SS' | '3B' | 'OF'; -type SortOption = 'name' | 'avg' | 'trend'; -type StatTypeFilter = 'all' | 'game' | 'scrimmage'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as const; - -function formatAvg(v: number | null | undefined): string { - if (v == null) return '—'; - return v.toFixed(3).replace(/^0\./, '.'); -} - -function getWeekDays(): Array<{ date: Date; dayNum: number; dayLabel: string; isToday: boolean }> { - const now = new Date(); - const sunday = new Date(now); - sunday.setDate(now.getDate() - now.getDay()); - sunday.setHours(0, 0, 0, 0); - - return Array.from({ length: 7 }, (_, i) => { - const d = new Date(sunday); - d.setDate(sunday.getDate() + i); - const today = new Date(); - today.setHours(0, 0, 0, 0); - return { - date: d, - dayNum: d.getDate(), - dayLabel: DAY_LABELS[i] ?? 'S', - isToday: d.getTime() === today.getTime(), - }; - }); -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function TrendIndicator({ trend }: { trend: string | null | undefined }) { - if (trend === 'improving') { - return ( - - - Trending up - Up - - ); - } - if (trend === 'declining') { - return ( - - - Trending down - Down - - ); - } - return ( - - - Stable - Steady - - ); -} - -function StatChip({ label, value }: { label: string; value: string }) { - return ( -
    - {label} - {value} -
    - ); -} - -// ─── Player Roster Card ─────────────────────────────────────────────────────── - -function PlayerRosterCard({ - player, - onClick, -}: { - player: BaseballRosterPlayer; - onClick: () => void; -}) { - const fullName = `${player.first_name ?? ''} ${player.last_name ?? ''}`.trim(); - const initials = (player.first_name?.[0] ?? '') + (player.last_name?.[0] ?? ''); - const trend = player.aggregates?.recent_trend; - - return ( - - ); -} - -// ─── Sortable table header ──────────────────────────────────────────────────── - -type StatSortKey = 'name' | 'avg' | 'hr' | 'ops' | 'trend'; - -function SortableHeader({ - label, - sortKey, - activeKey, - onSort, - align = 'center', - className = '', -}: { - label: string; - sortKey: StatSortKey; - activeKey: StatSortKey; - onSort: (k: StatSortKey) => void; - align?: 'left' | 'center'; - className?: string; -}) { - const isActive = activeKey === sortKey; - return ( - - - - ); -} - -// ─── Main Component ─────────────────────────────────────────────────────────── - -export function CommandCenterClient({ - team, - players, - coachId: _coachId, - coachName, - calendarEvents = [], - riskFeed = [], - riskFeedError = null, - coachDailyContracts, - summary, - loadState, -}: CommandCenterClientProps) { - const router = useRouter(); - const reduceMotion = useReducedMotion(); - const [activeTab, setActiveTab] = useState('roster'); - const [searchQuery, setSearchQuery] = useState(''); - const [positionFilter, setPositionFilter] = useState('all'); - const [sortOption, setSortOption] = useState('name'); - const [peekPlayer, setPeekPlayer] = useState(null); - const [statTypeFilter, setStatTypeFilter] = useState('all'); - const [statSortKey, setStatSortKey] = useState('avg'); - - // ── Week calendar data ────────────────────────────────────────────────────── - const weekDays = useMemo(() => getWeekDays(), []); - - const eventsByDay = useMemo(() => { - const map = new Map(); - for (const ev of calendarEvents) { - const key = new Date(ev.start_time).toDateString(); - const arr = map.get(key) ?? []; - arr.push(ev); - map.set(key, arr); - } - return map; - }, [calendarEvents]); - - // ── Roster filtering ──────────────────────────────────────────────────────── - const filteredPlayers = useMemo(() => { - let result = [...players]; - - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (p) => - p.first_name?.toLowerCase().includes(q) || - p.last_name?.toLowerCase().includes(q) || - p.primary_position?.toLowerCase().includes(q) - ); - } - - if (positionFilter !== 'all') { - result = result.filter( - (p) => - p.primary_position === positionFilter || - p.secondary_position === positionFilter - ); - } - - result.sort((a, b) => { - switch (sortOption) { - case 'name': - return `${a.last_name}${a.first_name}`.localeCompare(`${b.last_name}${b.first_name}`); - case 'avg': - return (b.aggregates?.career_avg ?? 0) - (a.aggregates?.career_avg ?? 0); - case 'trend': { - const order = { improving: 3, stable: 2, declining: 1 }; - return (order[b.aggregates?.recent_trend ?? 'stable'] ?? 2) - (order[a.aggregates?.recent_trend ?? 'stable'] ?? 2); - } - default: - return 0; - } - }); - - return result; - }, [players, searchQuery, positionFilter, sortOption]); - - // ── Stats tab player table ────────────────────────────────────────────────── - const statsTablePlayers = useMemo(() => { - let result = [...players]; - - if (positionFilter !== 'all') { - result = result.filter( - (p) => - p.primary_position === positionFilter || - p.secondary_position === positionFilter - ); - } - - // Apply stat type filter (affects which avg to show) - result.sort((a, b) => { - switch (statSortKey) { - case 'name': - return `${a.last_name}${a.first_name}`.localeCompare(`${b.last_name}${b.first_name}`); - case 'avg': { - const aVal = statTypeFilter === 'game' - ? (a.aggregates?.game_avg ?? 0) - : statTypeFilter === 'scrimmage' - ? (a.aggregates?.practice_avg ?? 0) - : (a.aggregates?.career_avg ?? 0); - const bVal = statTypeFilter === 'game' - ? (b.aggregates?.game_avg ?? 0) - : statTypeFilter === 'scrimmage' - ? (b.aggregates?.practice_avg ?? 0) - : (b.aggregates?.career_avg ?? 0); - return bVal - aVal; - } - case 'ops': - return (b.aggregates?.career_ops ?? 0) - (a.aggregates?.career_ops ?? 0); - case 'trend': { - const order = { improving: 3, stable: 2, declining: 1 }; - return (order[b.aggregates?.recent_trend ?? 'stable'] ?? 2) - (order[a.aggregates?.recent_trend ?? 'stable'] ?? 2); - } - default: - return 0; - } - }); - - return result; - }, [players, positionFilter, statTypeFilter, statSortKey]); - - const positions: PositionFilter[] = ['all', 'P', 'C', '1B', '2B', 'SS', '3B', 'OF']; - - // ─── Render ────────────────────────────────────────────────────────────────── - return ( - <> -
    -
    - - {/* ── Header ─────────────────────────────────────────────────── */} -
    -
    -

    - {team.name} -

    -

    Command Center

    -

    - {loadState === 'error' || loadState === 'partial' - ? (riskFeedError ?? 'Some Command Center data could not be loaded.') - : loadState === 'unauthorized' - ? 'You do not have staff access to this team.' - : `${summary?.rosterSize ?? players.length} player${(summary?.rosterSize ?? players.length) !== 1 ? 's' : ''} on your roster`} - {summary && loadState !== 'unauthorized' && loadState !== 'error' && ( - - {' '} - · {summary.openRisks} open signal{summary.openRisks !== 1 ? 's' : ''} - {summary.eventsToday > 0 ? ` · ${summary.eventsToday} event${summary.eventsToday !== 1 ? 's' : ''} today` : ''} - - )} -

    -
    -
    - {team.id && ( - - )} - - - -
    -
    - - {/* ── AI brief + risk strip (getCommandCenter read model) ─────── */} -
    - router.push(`/baseball/dashboard/players/${playerId}`)} - /> - router.push(`/baseball/dashboard/players/${playerId}`)} - maxItems={6} - /> -
    - - {coachDailyContracts?.authorized && ( -
    - router.push(`/baseball/dashboard/players/${playerId}`)} - /> -
    - )} - - {/* ── Mini Week Calendar Strip ────────────────────────────────── */} -
    -
    -
    - - This Week -
    - - View Calendar → - -
    - -
    - {weekDays.map((day) => { - const dayEvents = eventsByDay.get(day.date.toDateString()) ?? []; - const eventCount = dayEvents.length; - const dateLabel = day.date.toLocaleDateString(undefined, { - weekday: 'long', - month: 'long', - day: 'numeric', - }); - return ( - - ); - })} -
    -
    - - {/* ── Tab Toggle ─────────────────────────────────────────────── */} -
    - {([ - { id: 'roster' as const, label: 'Roster', icon: }, - { id: 'stats' as const, label: 'Stats', icon: }, - ]).map(({ id, label, icon }) => ( - - ))} -
    - - {/* ══════════════════════════════════════════════════════════════ - TAB PANELS — AnimatePresence fades out old, fades in new - ══════════════════════════════════════════════════════════════ */} - - - {activeTab === 'roster' && ( - { - // release GPU hint once settled - const el = document.getElementById('cc-panel-roster'); - if (el) el.style.willChange = 'auto'; - }} - > -
    - - {/* Search + filters row */} -
    - {/* Search */} -
    - - - setSearchQuery(e.target.value)} - className="w-full pl-9 pr-4 py-2.5 bg-cream-100/82 border border-warm-200/80 rounded-xl - text-sm text-warm-900 placeholder:text-warm-400 - focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-100 transition-colors" - /> -
    - - {/* Position filter */} - - - - {/* Sort */} - - - - {/* Upload stats */} - - - -
    - - {/* Empty state */} - {players.length === 0 ? ( -
    -
    - -
    -

    No players yet

    -

    - Share your join code and players will appear here the moment they - sign up. Stats and trends populate automatically after that. -

    - {team.id && ( - - )} -
    - ) : filteredPlayers.length === 0 ? ( -
    -
    - -
    -

    No players match these filters

    -

    - Try a different search term or position, or clear everything to see - the full roster. -

    - -
    - ) : ( - - {filteredPlayers.map((player) => ( - - setPeekPlayer(player)} - /> - - ))} - - )} -
    -
    - )} - - {activeTab === 'stats' && ( - { - const el = document.getElementById('cc-panel-stats'); - if (el) el.style.willChange = 'auto'; - }} - > -
    - - -
    - - -
    - - {/* Compact filtered table for coach-controlled slices */} -
    -
    -

    Filtered Player Table

    -
    - {/* Stat type filter */} -
    - {(['all', 'game', 'scrimmage'] as const).map((t) => ( - - ))} -
    - {/* Position filter */} - - -
    -
    - - {players.length === 0 ? ( -
    -
    - -
    -

    No performance data yet

    -

    - Upload game and scrimmage stats and per-player batting numbers - will fill this table. -

    -
    - ) : ( -
    -
    - - - - - - - - - - - - - - {statsTablePlayers.map((player) => { - const fullName = `${player.first_name ?? ''} ${player.last_name ?? ''}`.trim(); - const avg = statTypeFilter === 'game' - ? player.aggregates?.game_avg - : statTypeFilter === 'scrimmage' - ? player.aggregates?.practice_avg - : player.aggregates?.career_avg; - const ops = player.aggregates?.career_ops; - return ( - setPeekPlayer(player)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setPeekPlayer(player); - } - }} - > - - - - - - - - ); - })} - -
    Player batting performance, sortable by column
    PosSessions
    -
    -
    - - {(player.first_name?.[0] ?? '') + (player.last_name?.[0] ?? '')} - -
    - - {fullName || 'Unknown'} - -
    -
    - - {player.primary_position ?? '—'} - - - {formatAvg(avg)} - - {formatAvg(ops)} - - {player.aggregates?.total_sessions ?? 0} - - -
    -
    -
    - )} -
    -
    -
    - )} - -
    -
    - -
    -
    - - {/* Peek panel */} - setPeekPlayer(null)} /> - - ); -} diff --git a/src/components/baseball/command-center/CommandCenterFairway.tsx b/src/components/baseball/command-center/CommandCenterFairway.tsx index 0bd9ecc41..fa5e80d59 100644 --- a/src/components/baseball/command-center/CommandCenterFairway.tsx +++ b/src/components/baseball/command-center/CommandCenterFairway.tsx @@ -2,39 +2,52 @@ /** * ============================================================================ - * CommandCenterFairway — Fairway (warm-premium) presentation of the coach - * Command Center. Phase B leaf migration (flag-gated behind isRedesignEnabled). + * CommandCenterFairway — the coach Command Center as a magazine COVER, composed + * from the "Living Annual" kit (spec: docs/baseball/design-system-living-annual.md + * §6 P0 #3 "The Command Center Cover"; map: docs/baseball/ui-migration-map.md + * command-center row). * ---------------------------------------------------------------------------- - * PRESENTATION ONLY. Takes the SAME props the legacy `CommandCenterClient` - * receives (assembled server-side) and re-composes them with Fairway - * primitives from `@/components/fairway`. No data path, action, read-model, or - * query is touched here — the heavy AI-brief / risk / contracts panels are - * REUSED verbatim (rendered inside the new frame per the migration playbook). + * PRESENTATION ONLY. Takes the SAME props the page assembles server-side and + * re-skins them with the Living-Annual kit — no data path, action, read-model, + * or query is touched here: * - * Roster + Stats are separate Wave-1 surfaces, so this curated overview links - * out to them rather than embedding the legacy tabs. + * • CoverHero — this week's next opponent as the dominant cover line; + * its honest STANDING-BY letter when there's no next game. + * • KPIContentsStrip — the real KPIs the read model already carries (ROSTER, + * ON THE RECORD, OPEN RISKS, TODAY) as a green-ruled + * table-of-contents. Omitted whole when no summary exists; + * never a fabricated zero card. (No W-L record data exists + * in the baseball read models, so RECORD is omitted.) + * • EditorsLetter — the CoachHelm daily brief's empty & degraded states, + * signed "From the desk of CoachHelm" (kills the yellow + * box). Real AI brief content still renders through the + * reused DailyBriefPanel verbatim. + * + * The heavy read-model panels (DailyBriefPanel, RiskFeedStrip, CoachDailyContracts) + * are REUSED verbatim; roster + stats are separate surfaces this cover links out + * to rather than embedding. * ========================================================================== */ import { useMemo } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { - ViewHeader, - MetricCard, - Surface, - SurfaceHeader, - SurfaceBody, - EmptyState, - StatusPill, - Button, - type FwStatusTone, -} from '@/components/fairway'; -import { IconUsers, IconCalendar, IconChartBar, IconTrendingUp } from '@/components/icons'; -import { Calendar as LucideCalendar } from 'lucide-react'; +import { Button } from '@/components/fairway'; +import { IconChartBar, IconUsers, IconCalendar, IconTrendingUp } from '@/components/icons'; import type { BaseballRosterPlayer } from '@/lib/types'; import type { RiskFeedItem } from '@/lib/baseball/read-models/command-center'; import type { CoachDailyContractsReadModel } from '@/lib/baseball/read-models/coach-daily-contracts'; import type { ReadModelLoadState } from '@/lib/product-trust/read-model-state'; +import { + CoverHero, + KPIContentsStrip, + EditorsLetter, + EmptyIssue, + PaperCard, + HairlineRule, + Eyebrow, + InkBadge, + type KPIContentsItem, +} from '@/components/baseball/living-annual'; import { BaseballInviteButton } from './BaseballInviteButton'; import { RiskFeedStrip } from './RiskFeedStrip'; import { DailyBriefPanel } from './DailyBriefPanel'; @@ -76,16 +89,15 @@ export interface CommandCenterFairwayProps { // ─── Helpers ──────────────────────────────────────────────────────────────────── -/** Map a calendar event type to a Fairway status tone for the upcoming list. */ -function eventTone(type: string): FwStatusTone { - const t = type.toLowerCase(); - if (t.includes('game')) return 'accent'; - if (t.includes('scrimmage')) return 'info'; - if (t.includes('tournament')) return 'warning'; - return 'neutral'; +/** Event types that carry an opponent and can headline the cover. */ +const OPPONENT_EVENT = /game|scrimmage|tournament/i; + +/** Ink for an event-type stamp — games/tournaments read green, the rest quiet. */ +function eventInk(type: string): 'team' | 'neutral' { + return /game|tournament/i.test(type) ? 'team' : 'neutral'; } -/** Friendly weekday + time label for an ISO timestamp. */ +/** Friendly weekday + time label for an ISO timestamp (this-week strip). */ function fmtEventWhen(iso: string): string { const d = new Date(iso); const day = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); @@ -93,6 +105,42 @@ function fmtEventWhen(iso: string): string { return `${day} · ${time}`; } +/** Compact cover kicker, e.g. `Sun 4:30 PM`. */ +function fmtCoverWhen(iso: string): string { + const d = new Date(iso); + const day = d.toLocaleDateString(undefined, { weekday: 'short' }); + const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return `${day} ${time}`; +} + +interface CoverLine { + opponent: string; + homeAway: 'home' | 'away'; + dateLabel: string; +} + +/** + * Derive the cover line from the soonest upcoming opponent event. Honest best- + * effort parse of the coach-authored event title: the opponent is whatever + * follows a `vs` / `@` / `at` token, and an explicit `@` / `at` / `away` marks an + * away game. Returns null when there's no upcoming game — CoverHero then renders + * its STANDING-BY letter rather than a blank cover. + */ +function deriveCover(events: CalendarEvent[]): CoverLine | null { + const now = Date.now(); + const next = events + .filter((e) => OPPONENT_EVENT.test(e.event_type) && Date.parse(e.start_time) >= now) + .sort((a, b) => a.start_time.localeCompare(b.start_time))[0]; + if (!next) return null; + + const raw = next.title.trim(); + const away = /(^|\s)(@|at|away)(\s|$)/i.test(raw); + const sep = raw.match(/(?:vs\.?|@|at)\s+(.+)$/i); + const opponent = sep?.[1]?.trim() || raw.replace(/^(game|scrimmage|tournament)\b[:\s-]*/i, '').trim() || raw; + + return { opponent, homeAway: away ? 'away' : 'home', dateLabel: fmtCoverWhen(next.start_time) }; +} + // ─── Component ──────────────────────────────────────────────────────────────── export function CommandCenterFairway({ @@ -109,12 +157,8 @@ export function CommandCenterFairway({ const openPlayer = (playerId: string) => router.push(`/baseball/dashboard/players/${playerId}`); - const hasSummary = Boolean(summary); - const rosterSize = summary?.rosterSize ?? 0; - const playersWithData = summary?.playersWithData ?? 0; - const openRisks = summary?.openRisks ?? 0; - const criticalRisks = summary?.criticalRisks ?? 0; - const eventsToday = summary?.eventsToday ?? 0; + // This week's cover story — the next opponent (null → CoverHero standing-by). + const cover = useMemo(() => deriveCover(calendarEvents), [calendarEvents]); // Upcoming events for the week strip: earliest first, capped for calm density. const upcoming = useMemo( @@ -125,135 +169,138 @@ export function CommandCenterFairway({ [calendarEvents], ); + // The daily brief has real CoachHelm (AI-sourced) content only when the engine + // has written some — otherwise the slot is the signed STANDING-BY letter. + const hasAiBrief = useMemo( + () => riskFeed.some((i) => i.sourceRef.source === 'ai'), + [riskFeed], + ); + + // Table-of-contents KPIs — real read-model counts only. Omitted entirely when + // there's no summary (no team yet), never shown as broken zero cards. + const kpis: KPIContentsItem[] = []; + if (summary) { + kpis.push({ label: 'Roster', value: summary.rosterSize }); + kpis.push({ + label: 'On the Record', + value: summary.playersWithData, + emphasis: summary.rosterSize > 0 && summary.playersWithData === summary.rosterSize, + }); + kpis.push({ label: 'Open Risks', value: summary.openRisks }); + kpis.push({ label: 'Today', value: summary.eventsToday }); + } + return (
    - + +
    + {team.id ? ( - ) : undefined - } - secondaryActions={ + ) : null} - } +
    +
    + + {/* ── The cover: this week's opponent (or an honest standing-by letter) ─ */} + - {/* ── KPI scoreboard ─────────────────────────────────────────────────── */} -
    - } - footnote="players" - empty={!hasSummary} - /> - } - footnote={hasSummary ? `of ${rosterSize}` : undefined} - empty={!hasSummary} - /> - 0 ? `${criticalRisks} critical` : 'none critical'} - empty={!hasSummary} - /> - } - footnote="events" - empty={!hasSummary} - /> -
    + {/* ── Masthead contents strip: the real KPIs on green rules ──────────── */} + {kpis.length > 0 ? ( + = 4 ? 4 : kpis.length} /> + ) : null} {/* ── Brief + risk (reused read-model panels) alongside the week strip ── */}
    - - - {coachDailyContracts?.authorized && ( - + {riskFeedError ? ( + + ) : hasAiBrief ? ( + + ) : ( + )} + + {!riskFeedError ? ( + + ) : null} + + {coachDailyContracts?.authorized ? ( + + ) : null}
    diff --git a/src/components/baseball/command-center/TeamPlayerPeekPanel.tsx b/src/components/baseball/command-center/TeamPlayerPeekPanel.tsx deleted file mode 100644 index 2337f420d..000000000 --- a/src/components/baseball/command-center/TeamPlayerPeekPanel.tsx +++ /dev/null @@ -1,293 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'; -import { IconX, IconTrendingUp, IconTrendingDown, IconMinus, IconArrowRight } from '@/components/icons'; -import type { BaseballRosterPlayer } from '@/lib/types'; -import { IconButton } from '@/components/ui/button'; - -interface TeamPlayerPeekPanelProps { - player: BaseballRosterPlayer | null; - onClose: () => void; -} - -function formatAvg(v: number | null | undefined): string { - if (v == null) return '—'; - return v.toFixed(3).replace(/^0\./, '.'); -} - -export function TeamPlayerPeekPanel({ player, onClose }: TeamPlayerPeekPanelProps) { - const prefersReducedMotion = useReducedMotion(); - - // Close on Escape + lock body scroll while the panel is open. - useEffect(() => { - if (!player) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', onKeyDown); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - return () => { - document.removeEventListener('keydown', onKeyDown); - document.body.style.overflow = prevOverflow; - }; - }, [player, onClose]); - - const fullName = player - ? `${player.first_name ?? ''} ${player.last_name ?? ''}`.trim() - : ''; - - const agg = player?.aggregates; - - const trend = agg?.recent_trend; - const trendConfig = { - improving: { - label: 'Improving', - icon: , - cls: 'bg-primary-100 text-primary-700', - }, - declining: { - label: 'Declining', - icon: , - cls: 'bg-red-100 text-red-700', - }, - stable: { - label: 'Stable', - icon: , - cls: 'bg-warm-100 text-warm-600', - }, - } as const; - - const trendInfo = trend ? trendConfig[trend] : trendConfig.stable; - - const height = - player?.height_feet && player.height_inches != null - ? `${player.height_feet}'${player.height_inches}"` - : null; - - const hometown = - player?.city && player.state - ? `${player.city}, ${player.state}` - : player?.city ?? player?.state ?? null; - - // OBP from career_obp if available, else compute roughly from career_avg as proxy - const obp = agg?.career_obp != null ? agg.career_obp : null; - - const insights = player?.insights?.slice(0, 2) ?? []; - - return ( - - - {player && ( - <> - {/* Backdrop */} - - - {/* Panel — slide-in from right on desktop, slide-up on mobile */} - - {/* Close button */} -
    - Player Details - - - -
    - - {/* Scrollable content */} -
    -
    - {/* Hero */} -
    - {/* Avatar */} -
    -
    - {player.avatar_url ? ( - {fullName} - ) : ( - - {(player.first_name?.[0] ?? '') + (player.last_name?.[0] ?? '')} - - )} -
    - {/* Jersey badge */} - {player.jersey_number != null && ( - - #{player.jersey_number} - - )} -
    - - {/* Name + badges */} -
    -

    - {fullName || 'Unknown Player'} -

    -
    - {player.primary_position && ( - - {player.primary_position} - - )} - {player.secondary_position && ( - - {player.secondary_position} - - )} - {player.grad_year && ( - - '{String(player.grad_year).slice(-2)} - - )} -
    - {hometown && ( -

    {hometown}

    - )} -
    -
    - - {/* Bio row */} - {(height ?? player.weight_lbs ?? player.bats ?? player.throws ?? player.gpa) != null && ( -
    - {height && ( -
    -

    Height

    -

    {height}

    -
    - )} - {player.weight_lbs && ( -
    -

    Weight

    -

    {player.weight_lbs} lbs

    -
    - )} - {player.bats && ( -
    -

    Bats/Throws

    -

    - {player.bats}/{player.throws ?? '?'} -

    -
    - )} - {player.gpa && ( -
    -

    GPA

    -

    {player.gpa.toFixed(2)}

    -
    - )} -
    - )} - - {/* Career Stats */} -
    -

    - Career Stats -

    -
    - {[ - { label: 'AVG', value: formatAvg(agg?.career_avg) }, - { label: 'OBP', value: formatAvg(obp) }, - { label: 'Game AVG', value: formatAvg(agg?.game_avg) }, - { label: 'Scrimmage AVG', value: formatAvg(agg?.practice_avg) }, - { label: 'Sessions', value: String(agg?.total_sessions ?? '—') }, - { label: 'Total AB', value: agg?.total_at_bats != null ? String(agg.total_at_bats) : '—' }, - ].map(({ label, value }) => ( -
    -

    {label}

    -

    {value}

    -
    - ))} -
    -
    - - {/* Trend badge */} -
    - {trendInfo.icon} - {trendInfo.label} -
    - - {/* Insights */} - {insights.length > 0 && ( -
    -

    - Insights -

    -
    - {insights.map((insight) => ( -
    -

    - {insight.title} -

    -

    - {insight.body ?? insight.description} -

    -
    - ))} -
    -
    - )} -
    -
    - - {/* Footer CTA */} -
    - - View Full Profile - - -
    -
    - - )} -
    -
    - ); -} diff --git a/src/components/baseball/stats-center/StatsCenterClient.tsx b/src/components/baseball/stats-center/StatsCenterClient.tsx index 9f9136b29..a5d6b6e94 100644 --- a/src/components/baseball/stats-center/StatsCenterClient.tsx +++ b/src/components/baseball/stats-center/StatsCenterClient.tsx @@ -4,53 +4,31 @@ // src/components/baseball/stats-center/StatsCenterClient.tsx // // Wave 7 / packet P7.1 — Stats Center hub, client surface. +// MIGRATED to "The Living Annual" kit (design-system-living-annual.md §6 P1 #5, +// the "Radar-Gun Stat Wall"): the old wall of identical per-player cards is now +// an editorial record-book ROSTER SPREAD — a `SectionMasthead`, a team +// `KPIContentsStrip`, and `PlayerRowPlate` rows under a `PlayerRowPlateHeader`. +// Each row is a serif name plate + `PositionChip` + a run of `StatReadout` +// figures for the active side; the team-leader in each column reads GREEN. // -// The staff-facing, team-wide stats browser rendered from the Wave-7 read model -// (StatsCenterReadModel). Responsibilities: +// This is a PRESENTATION migration only. The data source (getStatsCenter read +// model), the server action re-fetch (loadStatsCenter — auth + RLS enforced +// there), the batting/pitching side switch, the official/all (scrimmage) +// toggle, URL persistence, CSV export, and the honest authorized/error/empty +// envelopes are all preserved exactly. // -// - BROWSE: a player grid (grid-cols-1 sm:2 xl:3) of per-player batting + -// pitching cards, each showing the read-model-derived OFFICIAL vs ALL-games -// splits with a single honest toggle between them. -// - FILTER: by position (multi), by side of the ball (batting / pitching / -// both), and by a game-date window, plus a season selector. Filters are -// URL-PERSISTED (router.replace with searchParams) so a shared link -// reproduces the view, and re-applied server-side via the loadStatsCenter -// action (auth + can_manage_stats enforced there) — never client-trusted. -// - OFFICIAL vs SCRIMMAGE: the read model derives both splits from per-game -// box scores joined on game_type; this surface only chooses which split to -// show. The season-stat reconcile flag is surfaced honestly per player. -// - EXPORT: client-side CSV of the currently-filtered rows for the chosen -// split — no server round-trip, no fabricated columns. -// -// HONEST STATES: unauthorized (not staff) / error / empty / loading are all -// real, labeled, recoverable, and never use a black page background. Players -// with zero captured lines render a "no data yet" card rather than a fake .000. -// -// UI: reuses GolfHelm primitives (Card / EmptyState / Button) + cream/green -// tokens + glass/matte patterns. Motion via LazyMotion/domAnimation honoring -// prefers-reduced-motion. tabular-nums on every stat. +// HONEST STATES (spec §7, "no yellow warning boxes"): unauthorized (not staff), +// whole-team-no-data, and per-spread empties render composed editorial letters +// (`EditorsLetter` / `EmptyIssue`), never a fabricated .000 and never a yellow +// alert box. Players with `noData` sort last and render as quiet ghost rows. // ============================================================================= -import { useCallback, useState, useTransition } from 'react'; -import Link from 'next/link'; +import { useCallback, useMemo, useState, useTransition } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { LazyMotion, domAnimation, m, useReducedMotion } from 'framer-motion'; -import { Card } from '@/components/ui/card'; -import { EmptyState } from '@/components/ui/empty-state'; import { Button } from '@/components/ui/button'; -import { - IconChartBar, - IconFilter, - IconDownload, - IconUsers, - IconBaseball, - IconDatabase, - IconAlertCircle, - IconCheckCircle2, - IconChevronRight, - IconX, -} from '@/components/icons'; +import { IconDownload, IconFilter, 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 @@ -58,19 +36,29 @@ import { loadStatsCenter } from '@/app/baseball/actions/games'; // granular event inputs, so it is safe to ship before that wiring lands. import { StatVisualsSection, useStatVisualViews } from '@/components/baseball/stat-visuals'; import type { StatVisualsData } from '@/components/baseball/stat-visuals/StatVisualsSection'; +import { + SectionMasthead, + KPIContentsStrip, + PlayerRowPlate, + PlayerRowPlateHeader, + EmptyIssue, + EditorsLetter, + Eyebrow, + HairlineRule, + formatRate, + formatRatio, +} from '@/components/baseball/living-annual'; +import type { PlayerRowStat } from '@/components/baseball/living-annual'; import type { StatsCenterReadModel, StatsCenterRow, BattingSplit, PitchingSplit, - CatchingSplit, - FieldingSplit, - BaserunningSplit, StatSide, } from '@/lib/baseball/read-models/stats-center'; // ----------------------------------------------------------------------------- -// Props + local filter state +// Props + local view state // ----------------------------------------------------------------------------- interface InitialFilters { @@ -92,38 +80,15 @@ interface StatsCenterClientProps { statVisualsData?: StatVisualsData; } -/** Which game-set the grid currently shows. */ +/** Which game-set the wall currently shows. */ type GameSet = 'official' | 'all'; +const EM_DASH = '—'; + // ----------------------------------------------------------------------------- -// Formatting +// Formatting (CSV + labels) // ----------------------------------------------------------------------------- -/** Baseball rate display: .305 / 3.21. null -> em dash. */ -function rate(n: number | null, decimals = 3, dropLeadingZero = true): string { - if (n === null || Number.isNaN(n)) return '—'; - const fixed = n.toFixed(decimals); - if (dropLeadingZero && fixed.startsWith('0.')) return fixed.slice(1); - if (dropLeadingZero && fixed.startsWith('-0.')) return `-${fixed.slice(2)}`; - return fixed; -} - -function int(n: number | null | undefined): string { - if (n === null || n === undefined || Number.isNaN(n)) return '0'; - return String(n); -} - -/** Percent display from a 0..1 ratio: .182 -> "18.2%". null -> em dash. */ -function pct(n: number | null): string { - if (n === null || Number.isNaN(n)) return '—'; - return `${(n * 100).toFixed(1)}%`; -} - -function ip(n: number | null): string { - if (n === null || Number.isNaN(n)) return '0.0'; - return n.toFixed(1); -} - function playerName(row: StatsCenterRow): string { const name = [row.firstName, row.lastName].filter(Boolean).join(' ').trim(); return name || 'Unnamed player'; @@ -139,381 +104,139 @@ function prettyPosition(value: string | null): string { } // ----------------------------------------------------------------------------- -// Stat tiles +// Record-book column specs — the headline "run" of figures per side. Rate stats +// (AVG/OBP/SLG, ERA/WHIP) render pre-formatted the baseball way via +// formatRate/formatRatio (static string); counting stats roll on the odometer +// (numeric). Deep stats live on the per-player drill-in. // ----------------------------------------------------------------------------- -/** A compact label/value stat cell. `accent` highlights a headline rate. */ -function StatPair({ - label, - value, - accent, - title, -}: { - label: string; - value: string; - accent?: boolean; - title?: string; -}) { - return ( -
    - - {label} - - - {value} - -
    - ); -} - -/** The headline four-stat "hero" row for a family (e.g. the slash line). */ -function HeroLine({ items }: { items: { label: string; value: string }[] }) { - return ( -
    - {items.map((it) => ( -
    - - {it.label} - - - {it.value} - -
    - ))} -
    - ); -} - -/** Section header for a stat family inside a player card. */ -function FamilyHeading({ - title, - meta, -}: { - title: string; - meta?: string; -}) { - return ( -
    -

    - {title} -

    - {meta && ( -

    {meta}

    - )} -
    - ); -} +type LeaderDir = 'high' | 'low' | null; -function BattingBlock({ b }: { b: BattingSplit }) { - return ( -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - ); +interface StatColSpec { + label: string; + /** Leader metric — `null` value or `null` reader excludes the column from leaders. */ + read: (s: S) => number | null; + /** Which direction wins the column (`null` = no leader treatment). */ + dir: LeaderDir; + /** The display figure (string → static, number → odometer). */ + cell: (s: S) => number | string; } -function PitchingBlock({ p }: { p: PitchingSplit }) { - return ( -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - ); -} +const BATTING_COLS: ReadonlyArray> = [ + { label: 'AVG', read: (b) => b.avg, dir: 'high', cell: (b) => (b.avg === null ? EM_DASH : formatRate(b.avg, 3)) }, + { label: 'OBP', read: (b) => b.obp, dir: 'high', cell: (b) => (b.obp === null ? EM_DASH : formatRate(b.obp, 3)) }, + { label: 'SLG', read: (b) => b.slg, dir: 'high', cell: (b) => (b.slg === null ? EM_DASH : formatRate(b.slg, 3)) }, + { label: 'HR', read: (b) => b.hr, dir: 'high', cell: (b) => b.hr }, + { label: 'RBI', read: (b) => b.rbi, dir: 'high', cell: (b) => b.rbi }, + { label: 'SB', read: (b) => b.sb, dir: 'high', cell: (b) => b.sb }, +]; -function CatchingBlock({ c }: { c: CatchingSplit }) { - return ( -
    - - - - - - - - - -
    - ); -} +const PITCHING_COLS: ReadonlyArray> = [ + { label: 'ERA', read: (p) => p.era, dir: 'low', cell: (p) => (p.era === null ? EM_DASH : formatRatio(p.era, 2)) }, + { label: 'WHIP', read: (p) => p.whip, dir: 'low', cell: (p) => (p.whip === null ? EM_DASH : formatRatio(p.whip, 2)) }, + // IP is stored in the .1/.2 (outs) convention already — display verbatim, no odometer. + { label: 'IP', read: () => null, dir: null, cell: (p) => p.ip.toFixed(1) }, + { label: 'K', read: (p) => p.k, dir: 'high', cell: (p) => p.k }, + { label: 'W-L', read: () => null, dir: null, cell: (p) => `${p.w}-${p.l}` }, + { label: 'SV', read: (p) => p.sv, dir: 'high', cell: (p) => p.sv }, +]; -function FieldingBlock({ f }: { f: FieldingSplit }) { - return ( -
    - - - - - - - - -
    - ); +/** + * For each column, the set of player ids that lead it (green). A column needs at + * least two non-null values to have a meaningful "leader" — so a lone qualifier + * is never crowned. Ties are all marked. + */ +function computeLeaders( + rows: StatsCenterRow[], + split: (r: StatsCenterRow) => S, + cols: ReadonlyArray>, +): Array> { + return cols.map((col) => { + const ids = new Set(); + if (!col.dir) return ids; + const vals: Array<{ id: string; v: number }> = []; + for (const r of rows) { + const v = col.read(split(r)); + if (v !== null && Number.isFinite(v)) vals.push({ id: r.playerId, v }); + } + if (vals.length < 2) return ids; + let best: number | null = null; + for (const { v } of vals) best = best === null ? v : col.dir === 'high' ? Math.max(best, v) : Math.min(best, v); + if (best === null) return ids; + for (const { id, v } of vals) if (v === best) ids.add(id); + return ids; + }); } -function BaserunningBlock({ b }: { b: BaserunningSplit }) { - return ( -
    - - - - - - - - -
    - ); -} +const playerHref = (row: StatsCenterRow) => `/baseball/dashboard/players/${row.playerId}/stats`; // ----------------------------------------------------------------------------- -// Player card — re-composed as a scannable per-player stat sheet. Hierarchy: -// identity + reconcile badge → each stat FAMILY that has data, batting/pitching -// first (box-score truth), then catching/fielding/baserunning (official events). -// Families with no data are omitted, never shown as fake zeros. +// One record-book spread (a header + the roster rows for one side of the ball). // ----------------------------------------------------------------------------- -function PlayerStatCard({ - row, - gameSet, - side, +function StatSpread({ + heading, + columns, + realRows, + ghostRows, + buildStats, + emptyTitle, + emptyBody, }: { - row: StatsCenterRow; - gameSet: GameSet; - side: StatSide | null; + heading: string; + columns: string[]; + realRows: StatsCenterRow[]; + ghostRows: StatsCenterRow[]; + buildStats: (row: StatsCenterRow) => PlayerRowStat[]; + emptyTitle: string; + emptyBody: string; }) { - const batting = gameSet === 'official' ? row.battingOfficial : row.battingAll; - const pitching = gameSet === 'official' ? row.pitchingOfficial : row.pitchingAll; - const showBatting = side !== 'pitching'; - const showPitching = side !== 'batting'; - - const hasPitching = pitching.g > 0; - const hasBatting = batting.g > 0; - // Defensive/baserunning splits are OFFICIAL-only (event-derived); show them - // regardless of the official/all toggle and only when events were captured. - const hasCatching = row.catchingOfficial.events > 0; - const hasFielding = row.fieldingOfficial.events > 0; - const hasBaserunning = row.baserunningOfficial.events > 0; + const hasRows = realRows.length > 0 || ghostRows.length > 0; + const ghostStats: PlayerRowStat[] = columns.map(() => ({ value: EM_DASH })); return ( - -
    -
    - - {row.jerseyNumber !== null && ( - - {row.jerseyNumber} - - )} -

    - {playerName(row)} -

    - - -

    - {prettyPosition(row.primaryPosition)} -

    -
    - {row.reconcile.hasSeasonRow && ( - row.reconcile.reconciled ? ( - - - Reconciled - - ) : ( - - - Needs recalc - - ) - )} -
    - - {row.noData ? ( -
    -

    - No box-score lines or events captured yet this season. -

    -
    - ) : ( -
    - {showBatting && ( -
    - - {hasBatting ? ( - - ) : ( -

    No batting lines in this game set.

    - )} -
    - )} - {showPitching && ( -
    - - {hasPitching ? ( - - ) : ( -

    No pitching appearances in this game set.

    - )} -
    - )} - {hasCatching && ( -
    - + {heading} + {hasRows ? ( + // Mobile: the fixed stat-column grid scrolls horizontally as one plate. +
    +
    + + {realRows.map((row) => ( + - -
    - )} - {hasFielding && ( -
    - - -
    - )} - {hasBaserunning && ( -
    - - -
    - )} + ))} + {/* noData players — quiet ghost rows, honest em-dashes, sorted last. */} + {ghostRows.map((row) => ( +
    + +
    + ))} +
    + ) : ( + )} - - {/* Drill-in footer — pinned to the bottom so every card aligns. Profile - drill lives on the name above; this is the explicit deep-stats route, - matching the roster card's "View stats" affordance. */} -
    - - View profile - - - Full stats - - -
    - + ); } // ----------------------------------------------------------------------------- -// CSV export (client-side, current filtered rows + chosen split) +// CSV export (client-side, current filtered rows + chosen split) — UNCHANGED. // ----------------------------------------------------------------------------- function csvCell(value: string | number | null): string { @@ -604,79 +327,7 @@ function downloadCsv(filename: string, contents: string) { } // ----------------------------------------------------------------------------- -// Summary strip -// ----------------------------------------------------------------------------- - -function SummaryStrip({ model }: { model: StatsCenterReadModel }) { - const tiles = [ - { - label: 'Players', - value: model.summary.rosterSize, - icon: , - tone: 'text-warm-600', - bg: 'bg-warm-100', - }, - { - label: 'With Data', - value: model.summary.playersWithData, - icon: , - tone: 'text-primary-600', - bg: 'bg-primary-50', - }, - { - label: 'Official Games', - value: model.summary.officialGames, - icon: , - tone: 'text-warm-600', - bg: 'bg-warm-100', - }, - { - label: 'Scrimmages', - value: model.summary.scrimmages, - icon: , - tone: 'text-warm-600', - bg: 'bg-warm-100', - }, - { - label: 'Need Recalc', - value: model.summary.unreconciled, - icon: , - tone: model.summary.unreconciled > 0 ? 'text-amber-600' : 'text-warm-600', - bg: model.summary.unreconciled > 0 ? 'bg-amber-50' : 'bg-warm-100', - }, - ]; - - return ( -
    - {tiles.map((t) => ( - -
    -
    -

    - {t.label} -

    -

    - {t.value} -

    -
    - - {t.icon} - -
    -
    - ))} -
    - ); -} - -// ----------------------------------------------------------------------------- -// Filter controls +// Filter controls (chrome) — behavior preserved from the pre-migration surface. // ----------------------------------------------------------------------------- const SIDE_OPTIONS: { value: StatSide | 'both'; label: string }[] = [ @@ -685,9 +336,9 @@ const SIDE_OPTIONS: { value: StatSide | 'both'; label: string }[] = [ { value: 'pitching', label: 'Pitching' }, ]; -const GAME_SET_OPTIONS: { value: GameSet; label: string; hint: string }[] = [ - { value: 'official', label: 'Official', hint: 'Games only' }, - { value: 'all', label: 'All games', hint: 'Incl. scrimmages' }, +const GAME_SET_OPTIONS: { value: GameSet; label: string }[] = [ + { value: 'official', label: 'Official' }, + { value: 'all', label: 'All games' }, ]; function SegmentedControl({ @@ -705,7 +356,7 @@ function SegmentedControl({
    {options.map((opt) => { const active = opt.value === value; @@ -719,10 +370,10 @@ function SegmentedControl({ onClick={() => onChange(opt.value)} haptic="none" className={cn( - 'min-h-0 rounded-lg px-3 py-1.5 text-sm font-medium', + 'min-h-0 rounded-md px-3 py-1.5 text-sm font-medium', active - ? 'bg-primary-600 text-white shadow-sm hover:bg-primary-600' - : 'text-warm-600 hover:bg-warm-100', + ? 'bg-grade-plus text-white shadow-sm hover:bg-grade-plus' + : 'text-text-secondary hover:bg-[color:var(--paper-canvas)]', )} > {opt.label} @@ -740,7 +391,7 @@ function SegmentedControl({ export function StatsCenterClient({ model: initialModel, initialFilters, statVisualsData }: StatsCenterClientProps) { const router = useRouter(); const searchParams = useSearchParams(); - const prefersReducedMotion = useReducedMotion(); + const reducedMotion = useReducedMotion() ?? false; const [isPending, startTransition] = useTransition(); // The model is refreshed in place when server-side filters change. @@ -759,13 +410,11 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis // Client-only view state (no re-query needed). const [gameSet, setGameSet] = useState('official'); - // Position options come from the (always full-roster) server model. const positionOptions = model.positions; - // ---- URL persistence + server re-fetch ----------------------------------- + // ---- URL persistence + server re-fetch (auth + RLS enforced server-side) ---- const applyFilters = useCallback( (next: { seasonYear: number; positions: string[]; side: StatSide | 'both' }) => { - // 1. Persist to the URL so the view is shareable / bookmarkable. const params = new URLSearchParams(searchParams.toString()); params.set('season', String(next.seasonYear)); if (next.positions.length > 0) params.set('positions', next.positions.join(',')); @@ -774,7 +423,6 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis else params.set('side', next.side); router.replace(`?${params.toString()}`, { scroll: false }); - // 2. Re-query the read model server-side (auth + capability enforced there). startTransition(async () => { setRefetchError(null); try { @@ -787,7 +435,6 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis }); setModel(fresh); } catch { - // withBaseballAction sanitizes the error; show a recoverable message. setRefetchError('We could not refresh the stats. Please try again.'); } }); @@ -829,274 +476,288 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis applyFilters({ seasonYear, positions: [], side: 'both' }); }, [seasonYear, applyFilters]); - const sideForCards: StatSide | null = side === 'both' ? null : side; const hasActiveFilters = positions.length > 0 || side !== 'both'; const rows = model.rows; - // When EVERY rostered player has zero captured box-score lines, a grid of N - // identical "no data" cards reads as broken. Collapse to one honest, premium - // team-level empty state (the moment any player has a line, the grid returns). const allNoData = rows.length > 0 && rows.every((r) => r.noData); const handleExport = useCallback(() => { const csv = buildCsv(rows, gameSet); - downloadCsv( - `stats-center-${model.seasonYear}-${gameSet}.csv`, - csv, - ); + downloadCsv(`stats-center-${model.seasonYear}-${gameSet}.csv`, csv); }, [rows, gameSet, model.seasonYear]); - // ---- Unauthorized envelope (not staff) ----------------------------------- + // ---- Derived record-book lists + column leaders -------------------------- + const battingRows = useMemo( + () => rows.filter((r) => !r.noData && (gameSet === 'official' ? r.battingOfficial : r.battingAll).g > 0), + [rows, gameSet], + ); + const pitchingRows = useMemo( + () => rows.filter((r) => !r.noData && (gameSet === 'official' ? r.pitchingOfficial : r.pitchingAll).g > 0), + [rows, gameSet], + ); + const ghostRows = useMemo(() => rows.filter((r) => r.noData), [rows]); + + const battingLeaders = useMemo( + () => computeLeaders(battingRows, (r) => (gameSet === 'official' ? r.battingOfficial : r.battingAll), BATTING_COLS), + [battingRows, gameSet], + ); + const pitchingLeaders = useMemo( + () => computeLeaders(pitchingRows, (r) => (gameSet === 'official' ? r.pitchingOfficial : r.pitchingAll), PITCHING_COLS), + [pitchingRows, gameSet], + ); + + const buildBattingStats = useCallback( + (row: StatsCenterRow): PlayerRowStat[] => { + const b = gameSet === 'official' ? row.battingOfficial : row.battingAll; + return BATTING_COLS.map((col, i) => ({ value: col.cell(b), leader: battingLeaders[i]?.has(row.playerId) ?? false })); + }, + [gameSet, battingLeaders], + ); + const buildPitchingStats = useCallback( + (row: StatsCenterRow): PlayerRowStat[] => { + const p = gameSet === 'official' ? row.pitchingOfficial : row.pitchingAll; + return PITCHING_COLS.map((col, i) => ({ value: col.cell(p), leader: pitchingLeaders[i]?.has(row.playerId) ?? false })); + }, + [gameSet, pitchingLeaders], + ); + + const showBatting = side !== 'pitching'; + const showPitching = side !== 'batting'; + // Ghost (noData) tail hangs on the primary spread only (batting when shown). + const battingGhosts = showBatting ? ghostRows : []; + const pitchingGhosts = showBatting ? [] : ghostRows; + + const seasonContext = gameSet === 'official' ? 'OFFICIAL GAMES' : 'ALL GAMES · INCL. SCRIMMAGES'; + const eyebrow = [`${model.seasonYear} SEASON`, seasonContext, side !== 'both' ? side.toUpperCase() : null] + .filter(Boolean) + .join(' · '); + + const mastheadActions = ( +
    + + +
    + ); + + // ---- Unauthorized envelope (not staff) — honest editorial letter ---------- if (!model.authorized) { return ( -
    -
    - } - title="Stats Center is for coaching staff" - description="Your account isn't a staff member on this team, so team-wide stats aren't available here." - action={{ label: 'Back to dashboard', href: '/baseball/dashboard' }} + +
    + router.push('/baseball/dashboard')}> + Back to dashboard + + } />
    -
    + ); } - // ---- Authorized view ------------------------------------------------------ + // ---- Authorized record-book spread --------------------------------------- return ( -
    -
    - {/* Header */} -
    -
    -

    - {model.seasonYear} Season -

    -

    - Stats Center -

    -

    - Team-wide production. Official games vs. all games, reconciled - against stored season totals. -

    -
    -
    - ({ value: o.value, label: o.label }))} - value={gameSet} - onChange={setGameSet} - ariaLabel="Game set" - /> - -
    -
    +
    + + + {/* Team contents strip — the magazine table of contents. */} +
    + 0 }, + { label: 'Official Games', value: model.summary.officialGames }, + { label: 'Scrimmages', value: model.summary.scrimmages }, + { label: 'Needs Recalc', value: model.summary.unreconciled, emphasis: model.summary.unreconciled > 0 }, + ]} + /> +
    - {/* Summary */} -
    - -
    + {/* Filters — season / side / position (server-re-queried, URL-persisted). */} +
    +
    + + + Filters + - {/* Honest read-model error (degraded data) */} - {model.error && ( -
    - - {model.error} +
    + Season +
    + + + {seasonYear} + + +
    - )} - {refetchError && ( -
    - - {refetchError} + +
    + Side +
    - )} - {/* Filters */} - -
    -
    - - Filters -
    + {hasActiveFilters && ( + + )} +
    - {/* Season stepper */} -
    - - Season - -
    - - - {seasonYear} - + {positionOptions.length > 0 && ( +
    + Position + {positionOptions.map((pos) => { + const active = positions.includes(pos); + return ( -
    -
    - - {/* Side */} -
    - - Side - - -
    - - {/* Clear */} - {hasActiveFilters && ( - - )} + ); + })}
    + )} +
    - {/* Position chips */} - {positionOptions.length > 0 && ( -
    - - Position - - {positionOptions.map((pos) => { - const active = positions.includes(pos); - return ( - - ); - })} -
    - )} - + {/* Degraded-data / refresh notes — quiet editorial lines, never yellow boxes. */} + {(model.error || refetchError) && ( +
    + + {model.error &&

    {model.error}

    } + {refetchError &&

    {refetchError}

    } +
    + )} - {/* Grid */} + {/* The record-book wall. */} + {rows.length === 0 ? ( - } - title={ - hasActiveFilters - ? 'No players match these filters' - : 'No players on this roster yet' - } - description={ + + Clear filters + + ) : ( + + ) } /> ) : allNoData ? ( - } - title={ - hasActiveFilters - ? 'No box scores for this filter yet' - : `No box scores captured for ${model.seasonYear} yet` - } - description={ - hasActiveFilters - ? `All ${rows.length} matching players are without a captured line in this game set. Clear the filter or import a box score to populate production.` - : `Your ${rows.length}-player roster is set, but no box-score lines have been captured this season. Import a box score or enter one to light up team-wide production, splits, and the source-backed charts below.` - } - action={{ label: 'Import stats', href: '/baseball/dashboard/import' }} - secondaryAction={ - hasActiveFilters - ? { label: 'Clear filters', onClick: clearFilters } - : { label: 'Go to roster', href: '/baseball/dashboard/roster' } + router.push('/baseball/dashboard/import')}> + Import a box score + } /> ) : ( -
    + {showBatting && ( + c.label)} + realRows={battingRows} + ghostRows={battingGhosts} + buildStats={buildBattingStats} + emptyTitle="No batting lines yet." + emptyBody="Batting production prints here after your first captured box score in this game set." + /> + )} + {showPitching && ( + c.label)} + realRows={pitchingRows} + ghostRows={pitchingGhosts} + buildStats={buildPitchingStats} + emptyTitle="No pitching lines yet." + emptyBody="Pitching lines print here after your first captured appearance in this game set." + /> )} - aria-busy={isPending} - > - {rows.map((row, i) => ( - - - - ))}
    )} - - {/* V10 stat-visual chart gallery — team scope, fed by the elite - stat-event read model (chase/whiff/EV-LA/spray/pitch-shape/...). - Honest: empty/insufficient frames when no events are captured. */} -
    - -
    +
    + + {/* V10 stat-visual chart gallery — team scope, fed by the elite + stat-event read model. Honest empty/insufficient frames when no + events are captured. */} +
    +
    diff --git a/src/contracts/baseball/product-trust.contract.test.ts b/src/contracts/baseball/product-trust.contract.test.ts index caf4dcc23..26e4bc14d 100644 --- a/src/contracts/baseball/product-trust.contract.test.ts +++ b/src/contracts/baseball/product-trust.contract.test.ts @@ -6,12 +6,15 @@ const repo = process.cwd(); const read = (path: string) => readFileSync(join(repo, path), 'utf8'); describe('Baseball product-trust surface contracts', () => { - it('Command Center client accepts explicit load/error props', () => { - const src = read('src/components/baseball/command-center/CommandCenterClient.tsx'); + it('Command Center cover accepts explicit load/error props and renders honest states', () => { + const src = read('src/components/baseball/command-center/CommandCenterFairway.tsx'); expect(src).toContain('riskFeedError'); expect(src).toContain('loadState'); - expect(src).toMatch(/loadState === 'error'/); - expect(src).toMatch(/loadState === 'unauthorized'/); + // Honest degraded/empty states come from the Living-Annual kit (EditorsLetter), + // never a yellow warning box: an error branch and a signed STANDING-BY letter. + expect(src).toMatch(/riskFeedError \?/); + expect(src).toContain('EditorsLetter'); + expect(src).toMatch(/Standing by/i); }); it('DailyBriefPanel distinguishes error vs empty insights', () => { From f7ef4dcc521d0da72884cc649556f6182441885e Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 16:35:15 -0400 Subject: [PATCH 041/186] docs(baseball): long-tail surface migration execution plan (per-surface specs + conflict-free batches) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execution-ready plan for the remaining ~27 baseball surfaces: per-surface files, data source to preserve, kit composition, lane ink, legacy to delete, gotchas, effort, EmptyIssue variant — plus a parallel batch schedule (conflict-free file ownership) for the Sonnet fleet and the owner-coordinated shared-file freeze list. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017VUauUXsm9aXegShmpJi5n --- docs/baseball/ui-migration-execution-plan.md | 421 +++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 docs/baseball/ui-migration-execution-plan.md diff --git a/docs/baseball/ui-migration-execution-plan.md b/docs/baseball/ui-migration-execution-plan.md new file mode 100644 index 000000000..f35f194fb --- /dev/null +++ b/docs/baseball/ui-migration-execution-plan.md @@ -0,0 +1,421 @@ + + +# BaseballHelm — "Living Annual" UI Migration Execution Plan + +**Goal:** migrate EVERY remaining baseball surface onto the Living-Annual kit +(`src/components/baseball/living-annual/`) so the product reads as one publication. +A surface is **done** only when it (1) composes from the kit — no bespoke card/header/empty/stat +display remains, (2) collapses its `isRedesignEnabled` fork and **deletes** the legacy component it +replaces, (3) checks its box on `ui-migration-map.md` + updates `memory/context/baseballhelm-features.md`, +(4) is tsc + eslint clean. + +**Already done — do NOT touch:** `command-center` (reference: `src/components/baseball/command-center/CommandCenterFairway.tsx`). +**In progress concurrently — do NOT touch its files:** `stats-center` +(`src/app/baseball/(dashboard)/dashboard/stats-center/{page,loading,error}.tsx` + +`src/components/baseball/stats-center/{StatsCenterClient,StatsUploadClient,UploadHistory}.tsx`). + +--- + +## 0. How to read this plan + +- **Lanes = ink.** Pressbox (coach team-ops) + Passport (player dev) = **green** (`ink="team"`). + War Room (coach recruiting) = **clay** (`ink="pursuit"`). The active lane's ink is the wayfinding; + never mix inks within a lane's chrome. `--clay` dark canvas is quarantined to `` viz only. +- **Presentation only.** Never touch a read model, server action, hook, RLS, or query. You re-skin the + SAME props the surface already assembles. If you find yourself editing anything under + `src/lib/baseball/read-models/`, `**/actions/**`, or a `supabase/` file — STOP; that is out of scope. +- **One surface per change.** Each surface folds into `batch/baseball-fixes` on its own; keep the diff + scoped to the files listed in that surface's **Files owned** block. + +## 1. The fork-collapse recipe (apply on EVERY surface) + +The migration end-state matches `command-center/page.tsx`: **no fork, always render the kit version, +legacy deleted.** Two starting shapes: + +**Shape A — a `*Fairway.tsx` variant already exists and the surface forks on the flag** +(roster, calendar, announcements, tasks, documents, messages — these Fairway variants are shallow +Fairway-primitive reskins that do NOT yet import the kit): +1. Rebuild the `*Fairway.tsx` variant to compose the Living-Annual kit (SectionMasthead / PaperCard / + EmptyIssue / RuledStatLine / PlayerRowPlate / etc.) — replacing its bespoke Fairway cards/headers/empties. +2. In the page or client, **delete the `if (isRedesignEnabled()) { … }` branch** and the legacy + render path below it; always return the kit variant wrapped in `fairwayScope('min-h-full')` (or the + surface's existing shell class). +3. **Delete the legacy component** the old branch rendered, plus its now-orphaned imports. Run `tsc` + + `knip` mentally: no dangling import, no unused legacy file. + +**Shape B — pure legacy, no fork yet** (everything else): +1. Create a `*Fairway.tsx` (or rewrite the client in place) composing the kit. +2. Point `page.tsx` at it, wrapped in `fairwayScope('min-h-full')`, with **no** `isRedesignEnabled` + fork (the shell flag already gates the whole subtree; per the command-center precedent pages render + the kit unconditionally). +3. Delete the replaced legacy client/cards + orphaned imports. + +**Scope wrapper:** the shell (`BaseballFairwayShell`) already provides `.living-annual` (cream) around +the whole subtree; pages still add `.fairway-ds` themselves via `fairwayScope(...)` exactly as +`command-center/page.tsx` does. Do not remove `.living-annual` handling; do not add a second one. + +**Non-negotiables (a reviewer will reject a violation):** +- No yellow/amber warning boxes anywhere → every zero/empty/soft-error is `` or a + ghosted ``. Grep your diff for `bg-yellow` / `bg-amber` / `warning` and kill them. +- Every changeable number is a `` (tabular odometer). Never spring a number. +- `--clay` / `` only inside a viz frame — never a page/card/sidebar background. +- Numerals carry contrast (near-black graphite ≥7:1); labels stay quiet. Leaders/bests get green. +- `prefers-reduced-motion` is first-class (the kit atoms already honor it — don't re-animate around them). + +## 2. Kit cheat-sheet (confirmed prop signatures — copy these, don't re-derive) + +**Page header (every surface):** `{tabs?}` — green/clay 3px accent rule baked in. + +**Surfaces:** `` (flat cream, hairline, letterpress inset — the +ONLY card). `` (dark viz frame only). ``. + +**Empty/error:** `` +or ``. Presets already carry the copy + STANDING-BY dot. + +**Stat display:** +- `` — THE atom. +- `` — any changeable number. +- `` — passport measurable column. +- `` — `.341 / .420 / .611` (auto leading-zero drop). +- `` — masthead KPIs on green rules. +- `` + + `` — the record-book roster/stats row + aligned header. + +**Recruiting (clay):** +- `` — mini box-score pipeline chip (GradeStamps + AgingBar + stage InkBadge). +- `` · `` — 20-80 evaluation. +- `` · `` — 20-80 scale + Decision-Room compare overlay. +- `` — clay days-since-contact (darkens toward deadline; clay lane only). +- `` / `` — oxblood ceremony seals. +- `{viz}` — scout packet. + +**Player identity:** `` (two-line name block). +``. ``. ``. ``. + +**Viz (data→prop mapping):** +- `` — season/skill-over-time on PAPER; <2 points → honest empty. (dev-plan, timeline, practice-effectiveness, lift progress.) +- `` — pitch break on ClayCanvas; hbreak/vbreak in inches (±22 clamp). (pitcher analytics/passport.) +- `` — batted balls; x,y normalised [0,1], home plate bottom-centre. (hitter analytics/passport.) + +**Formatters:** `formatRate(v, decimals)` (drops leading zero <1 → `.341`), `formatRatio`, `formatInnings`. + +--- + +## 3. Per-surface specifications + +> Filled from the parallel surface-mapping pass. Each section states current shape (A/B), files owned, +> data to preserve, kit composition, ink + viz, legacy to delete, gotchas, effort, and EmptyIssue variant. + +**Legend per surface:** *Class* = starting shape — **(A)** shallow `*Fairway.tsx` reskin already forks on the flag (upgrade it to the kit + collapse fork); **(B)** pure legacy, no fork (build a Fairway variant, wire page unconditionally). *Owns* = the exact files that change (edited / **created** / ~~deleted~~) — an agent touches nothing outside this list. + +### 3.1 THE PRESSBOX — coach team-ops · GREEN (`ink="team"`) + +#### roster · `/baseball/dashboard/roster` · **Class A** · **L** +- **Files:** page `src/app/baseball/(dashboard)/dashboard/roster/page.tsx`; client `roster/RosterClient.tsx` (**fork @464**, legacy body 542–1072); variant `roster/RosterFairway.tsx` (Fairway primitives, no kit). +- **Preserve:** `getRoster(ctx.activeTeamId)` read-model (`members, aggregates, rosterError, aggregatesError`), `saveLineup` action, `useAuth`/`useTeamStore`. All props already flow into `RosterFairway` — keep verbatim. +- **Compose:** `ViewHeader`→`SectionMasthead`+`Eyebrow`; 4× `MetricCard`→`KPIContentsStrip`; `PlayerCard` grid + Position/Status/Development boards→`PlayerRowPlate`(+`PlayerRowPlateHeader`)+`SlashLine`+`PositionChip`; amber `aggregatesWarning` `InlineNotice`→`EditorsLetter` ("Signals are catching up"); `EmptyState`→`EmptyIssue variant="roster"`. +- **Empty/gotchas:** coach-only wall (`role!=='coach'`) + `aggregatesError` degraded state stay honest (EditorsLetter, never a zero card); mobile stat columns (`w-20`) must not overflow; two-error read-model kept. +- **Owns:** edit `RosterClient.tsx` (delete fork+legacy body), rewrite `RosterFairway.tsx` to kit; ~~roster/PlayerRow.tsx~~, ~~roster/RosterToolbar.tsx~~ if orphaned. + +#### calendar · `/baseball/dashboard/calendar` · **Class A** · **M** +- **Files:** page `.../calendar/page.tsx` (**forks @205 + @258**); variant `src/components/baseball/calendar/CalendarFairway.tsx`; grid `BaseballCalendarWrapper`→`PremiumCalendarClient` (reused verbatim, NOT migrated). +- **Preserve:** the `fromUntyped(supabase,'baseball_events')` **exact column list (LOAD-BEARING — `requires_rsvp` does NOT exist and breaks the query)**; props `events, teamMembers, teamId, isCoach, currentUserId, upcomingEvents, eventTypeCounts`; `all_day` local-midnight normalization. +- **Compose:** recruiting-empty `EmptyState`→`EmptyIssue variant="calendar"` (CTA to `/discover`); `StatusPill` summary→`InkBadge`/`LiveDot`; page chrome→`SectionMasthead`. Keep the full-height `SHELL` + `overflow-hidden` so `PremiumCalendarClient` `h-full` resolves. +- **Empty/gotchas:** two empties (college-coach recruiting vs no-events); role `both` (`isCoach` from session); `isCollegeCoach && !teamId` branch. +- **Owns:** edit `calendar/page.tsx` (collapse both forks), rewrite `CalendarFairway.tsx`. + +#### announcements · `/baseball/dashboard/announcements` · **Class A** · **M** +- **Files:** page `.../announcements/page.tsx` (`'use client'`, **fork @104**, legacy body 122–210); variant `announcements/AnnouncementsFairway.tsx`. +- **Preserve:** `getAnnouncementsWithMeta`, coach roster fetch; reuse `AnnouncementsCoachView`/`AnnouncementsPlayerView`/`CreateAnnouncementFlow` verbatim; `recentCount`. +- **Compose:** `ViewHeader`+Create→`SectionMasthead`+action; `StatusPill`→`InkBadge`/`LiveDot`; states→`EmptyIssue variant="announcements"` (NEW preset — Batch 0) + `EditorsLetter` for errors; delete legacy `Header` body. +- **Empty/gotchas:** role `both`; no dedicated preset today (add `announcements` in Batch 0). +- **Owns:** edit `announcements/page.tsx`, rewrite `AnnouncementsFairway.tsx`. + +#### tasks · `/baseball/dashboard/tasks` · **Class A** · **M** +- **Files:** page `.../tasks/page.tsx` (`'use client'`, **fork @162**, legacy body 183–261); variant `tasks/TasksFairway.tsx`. +- **Preserve:** `getTeamTasks`/`getPlayerTasks`/`getTaskAssignments`, roster fetch; reuse `TasksList`/`CreateTaskModal`. +- **Compose:** `ViewHeader`→`SectionMasthead`; overdue `InlineNotice tone="warning"` + `ReminderBanner`→`InkBadge`/`LiveDot` (**keep as a live alert, NOT an empty state**); `Segmented` filter kept; `EmptyState`→`EmptyIssue variant="tasks"`; delete legacy `Header` body. +- **Owns:** edit `tasks/page.tsx`, rewrite `TasksFairway.tsx`. + +#### documents · `/baseball/dashboard/documents` · **Class A** · **M** +- **Files:** page `.../documents/page.tsx` (walls); client `documents/documents-client.tsx` (**fork @138**, legacy body 193–311); variant `documents/DocumentsFairway.tsx`. +- **Preserve:** `getTeamDocuments(teamId, isCoach)` + `createBaseballDocument`/`deleteBaseballDocument`/`uploadBaseballDocument`/`uploadNewVersion`; **the hidden file-input / preview / version-modal slots stay in the client** (not the kit component); reuse `DocumentCard`. +- **Compose:** `ViewHeader`→`SectionMasthead`; `EmptyState`→`EmptyIssue variant="documents"`; page walls (no-team, error)→`EmptyIssue`/`EditorsLetter`. +- **Empty/gotchas:** two empties (`totalCount===0` vs filtered "no results"); role `both`. +- **Owns:** edit `documents-client.tsx` (collapse fork), rewrite `DocumentsFairway.tsx`, (opt) `documents/page.tsx` walls. + +#### travel · `/baseball/dashboard/travel` · **Class B** · **L** · ⚠ PR #555 +- **Files:** page `.../travel/page.tsx` (`'use client'`, role-detect + fetch); client `src/components/baseball/travel/TravelClient.tsx` (415, contains `ItineraryCard`); **no variant**. +- **Preserve:** `getTeamItineraries` + `deleteItinerary`/`getItineraryExpenses`/`getExpenseSummary` + `CreateItineraryModal`/`ExpenseForm`; inline role detection; `handleSaved`→`window.location.reload()`; local-noon date parse (TZ "Past" fix). +- **Compose:** `

    `→`SectionMasthead`; glass `IconMapPin` empty→`EmptyIssue variant="travel"` (NEW preset); `ItineraryCard` glass accordion→`PaperCard`+`HairlineRule`+`InkBadge` (transport); no-team/error walls→`EmptyIssue`/`EditorsLetter`. Do NOT reintroduce a global `
    ` (shell owns the top bar). +- **Gotchas:** **known merge collision with PR #555 on `TravelClient.tsx` — GATE this surface on #555's status.** Only pure-legacy team-ops surface. +- **Owns:** **create** `travel/TravelFairway.tsx`, edit `travel/page.tsx`, edit `TravelClient.tsx`. + +#### my-stats · `/baseball/dashboard/my-stats` · **Class B** · **M** (player) +- **Files:** page `.../my-stats/page.tsx`; client `my-stats/MyStatsClient.tsx` (262); subs `components/baseball/player-stats/{StatsOverviewCards,TrendChart,GameVsPracticeChart,SessionHistory}` + `season-stats/MySeasonStats.tsx` (**all exclusive to my-stats — no Stats-Center overlap**). +- **Preserve:** `getMyStats`/`getMyAggregates` + `getMySeasonStats`; `requireBaseballPlayerRoute()` + `force-dynamic`. +- **Compose:** `Header`→`SectionMasthead`+`Eyebrow`; avatar/name/jersey→`PlayerRowPlateHeader`+`PositionChip`+`InkBadge`(#); AVG/OBP/SLG/OPS grid→`SlashLine`+`StatReadout`(OPS); counting stats→`RuledStatLine`/`StatLineStack` (batting vs pitching); quick cards + `StatsOverviewCards`→`KPIContentsStrip`; error→`EditorsLetter`; empty→`EmptyIssue variant="stats"`. Swap hand-rolled leading-zero-drop for kit `formatRate`. +- **Viz:** `TrendChart` (recharts, avg over `session_date`)→**`ClimbArc`** (`{label:session_date, value:avg}`). `GameVsPracticeChart`→paired `StatReadout`/`SlashLine` (comparison, NOT ClimbArc). No SprayChart/BreakPlot (no coords). +- **Owns:** edit `my-stats/page.tsx`, `MyStatsClient.tsx`, `player-stats/*` (5), `season-stats/MySeasonStats.tsx`. + +#### practice · `/baseball/dashboard/practice` · **Class B** · **L** +- **Files:** page `.../practice/page.tsx`; client `components/baseball/practice-planner/PracticePlannerClient.tsx` (**1377**) + 7 subs (`TimeRailBuilder`, `PracticeIntelligenceBoard`, `ScrimmagePanel`, `BlockObjectiveEditor`, `PracticeRecapPanel`, `PracticePrintExport`, `ScrimmageLineupBuilder`). +- **Preserve:** `getTeamPractices`/`savePractice`/`publishPractice`/`recordPracticeAttendance`/`getClassConflictsForPractice` + `getPracticeIntelligence`/`convertSignalToBlock` + `getPracticeObjectives` + roster/staff reads. **Keep the coach-editor vs player-read-only branch (both paths).** +- **Compose:** `Header`→`SectionMasthead`; block/time-rail cards→`PaperCard`; `EmptyState`→`EmptyIssue`; validation warning badges→`InkBadge`(warning)+`HairlineRule`; `PracticeCard`→`PlayerRowPlate`/`PaperCard`. No viz. +- **Gotchas:** **remove the stray artifact `practice-planner/PracticeIntelligenceBoard.tsx.tmp.67976.7dc61ff02da0`**; overlap/owner/conflict validation + attendance + scrimmage-lineup are the regression risk → **split into several ≤15-file PRs.** +- **Owns:** edit `practice/page.tsx` + `practice-planner/` (8 files), ~~the .tmp artifact~~. + +#### practice-effectiveness · `/baseball/dashboard/practice-effectiveness` · **Class B** · **M** +- **Files:** page `.../practice-effectiveness/page.tsx` (62); client `components/baseball/practice-effectiveness/PracticeEffectivenessClient.tsx` (549). +- **Preserve:** `getPracticeEffectivenessData()` read-model (`authorized, reviews, focusRollup, summary`) + `runPracticeEffectiveness`/`setReviewDisposition`. **Honesty vocab `DIRECTION_META`/`TIER_LABEL`/`SCOPE_LABEL` survives verbatim — it is the product.** +- **Compose:** header→`SectionMasthead`+`Eyebrow`; honesty-note `Card`→`EditorsLetter`; `StatTile` strip→`KPIContentsStrip`/`StatReadout`; `ReviewCard`→`PaperCard`+`InkBadge`+`GradeStamp`(confidence tier); `FocusRollupItem`→`RuledStatLine`/`PlayerRowPlate`; `EmptyState`→`EmptyIssue variant="generic"`. +- **Empty/gotchas:** coach + `can_manage_practice`; two empties (no reviews vs filtered); "too early" tone→`GradeStamp`/`InkBadge`. Low viz (skip ClimbArc). +- **Owns:** edit `practice-effectiveness/page.tsx`, `PracticeEffectivenessClient.tsx`. + +#### postgame · `/baseball/dashboard/postgame` · **Class B** · **M** +- **Files:** page `.../postgame/page.tsx` (87); client `components/baseball/postgame/PostgameReviewClient.tsx` (611). +- **Preserve:** `getPostgameReview` read-model + `generatePostgameReview`/`convertPostgameItemToTimeline`/`convertPostgameItemToPractice`/`setPostgameItemDisposition`; **keep `SourceTrustBadge`**. +- **Compose:** header→`SectionMasthead`+`Eyebrow`; game-picker pills→`InkBadge`/`ToolRailStack` + `LiveDot` has-review dot; review-header→`PaperCard`+`InkBadge`+`GradeStamp`(confidencePct); section headers→`SectionMasthead`/`HairlineRule`; `ItemCard`→`PaperCard`+`InkBadge`(priority)+`SourceTrustBadge`; amber import-warnings→`InkBadge`. +- **Empty/gotchas:** coach + **`coach_type` must be college|juco (else redirect)** + `can_manage_stats` (3 layers); **SIX empty branches** (error/forbidden-lock/setup/no-games/no-review/zero-items)→`EmptyIssue variant="generic"` (lock for forbidden) — keep `unauthorizedReason` forbidden-vs-setup copy split. +- **Owns:** edit `postgame/page.tsx`, `PostgameReviewClient.tsx`. + +#### import · `/baseball/dashboard/import` · **Class B** · **L** +- **Files:** page `.../import/page.tsx` (200); shell `components/baseball/import-center/ImportCenterShell.tsx` (147) hosting `ImportWizardClient`+`EventImportWizard`; subs `import-center/{ManualMapPanel,SourceDetectionCard,ImportDiffViewer}`. +- **Preserve:** `getImportRuns`/`listImportSources` + adapter registries; **do NOT touch page.tsx `mergeRegisteredSources` / adapter wiring**; props `teamId, teamName, players, recentRuns, eventSources, registeredSources`. +- **Compose:** shell header→`SectionMasthead`; mode segmented→`InkBadge` tabs; `SourceDetectionCard bg-warning`→`InkBadge`/`PaperCard`; `ImportDiffViewer` before/after + `border-warning` boxes→`RuledStatLine` rows + `InkBadge`(warning); parse-notes→`PaperCard`. No viz. +- **Empty/gotchas:** coach + **`can_manage_imports` server redirect** (explicitly not nav-hiding); format variance XML/CSV/TSV/XLSX/PDF; source trust/required-review policy badges; multi-step wizard mobile → **split PRs.** +- **Owns:** edit `import/page.tsx` + `import-center/` (6 files). + +#### analytics · `/baseball/dashboard/analytics` · **Class B** · **S–M** (player, recruiting) +- **Files:** page `.../analytics/page.tsx` (11) + `analytics/layout.tsx`; client `analytics/AnalyticsClient.tsx` (224). **Audience = PLAYER recruiting-view analytics** (coaches redirected to command-center) — NOT batting/pitching stats. +- **Preserve:** `useAnalytics()` hook (`stats{profileViews,watchlistAdds,videoViews,messagesSent}, viewsOverTime[], topSchools[]`). +- **Compose:** `Header`→`SectionMasthead`+`Eyebrow`; 4 stat cards→`KPIContentsStrip`/`StatReadout`; `viewsOverTime` recharts `LineChart`→**`ClimbArc`** (`{label:date, value:views}`); top-schools→`PlayerRowPlate`/`RuledStatLine`; empties→`EmptyIssue variant="discover"` (NEW) or `generic`. +- **Ink ruling:** stays **green** chrome (it's in the player green-lane nav), but recruiting-heat accents may use `InkBadge tone="sodium"` for "a school viewed you"; **do NOT use full clay chrome** (clay chrome = War Room only, two-ink law). +- **Owns:** edit `analytics/page.tsx`, `analytics/layout.tsx`, `AnalyticsClient.tsx`. + +#### performance · `/baseball/dashboard/performance` · **Class B** · **L** (coach lift) +- **Files:** page `.../performance/page.tsx` (272); clients `components/baseball/performance/PerformanceCommandCenter.tsx` (569) + `PerformanceDashboardClient.tsx` (1082). Sub-routes (builder/groups/live/programs → own large clients; `players/[id]`=redirect-only, skip) — **defer sub-routes to a follow-up PR.** +- **Preserve:** `getPerformanceCommandData` + Lab resolvers/adapters + `helm_lifting_*` reads; props `kpis, board, readiness, readinessWithheld, playerNameById, canManageLifting`, and `roster, assignments, exercises, readiness, embedded`. +- **Compose:** CC header + 4 pill links→`SectionMasthead`+Buttons; 7 KPI glass cards (amber tones)→`KPIContentsStrip`+`StatReadout`; Today Weight-Room board→`PaperCard`+`StatLineStack`; readiness queue→`PlayerRowPlate`; `· stale`→`InkBadge`; Dashboard `Tabs` kept+reskinned, section cards→`PaperCard`, `EmptyState`→`EmptyIssue variant="today"/"generic"`. +- **Viz:** richest lift surface — PR/lift trends→**`ClimbArc`**/`StatReadout`. +- **Empty/gotchas:** coach + (`can_manage_lifting` OR `can_view_readiness`) redirect; `readinessWithheld` withholding UI stays honest; `helm_lifting_*` via adapters (don't touch). → **split PRs** (CC vs Dashboard). +- **Owns:** edit `performance/page.tsx`, `PerformanceCommandCenter.tsx`, `PerformanceDashboardClient.tsx`. + +#### messages/[id] · `/baseball/dashboard/messages/[id]` · **Class B** · **S** (M if converge) +- **Files:** page `.../messages/[id]/page.tsx` (87 — **the page IS the client**; no separate file). **Correction: does NOT share `MessagesFairway`** (that is the list page only). The shared thread UI it duplicates is `src/components/messages/ChatWindow.tsx`. +- **Preserve:** `useMessages(conversationId)`/`useConversations()` + `sendMessage` + auto-scroll `messagesEndRef`. Do not touch the send path. +- **Compose:** `Header`(backHref)→`SectionMasthead` with back affordance; hand-rolled bubbles→`PaperCard`/`InkBadge` kit bubble; composer container reskin. `EmptyIssue variant="messages"` for conversation-not-found. +- **Gotchas:** reconcile mobile `h-[calc(100dvh-4rem)]`. **Optional (M):** converge onto a kit-composed `ChatWindow` to delete ~35 lines of divergent bubbles — but that makes `ChatWindow.tsx` a shared edit (also used by the list page) → coordinate. Default = keep isolated (S). +- **Owns:** edit `messages/[id]/page.tsx` only (isolated path). + +#### settings/staff · `/baseball/dashboard/settings/staff` · **Class B** · **M** +- **Files:** page `.../settings/staff/page.tsx` (45); client `components/baseball/staff/StaffSettingsClient.tsx` (785). +- **Preserve:** `getStaffSettingsData()` + `inviteStaff`/`revokeStaffInvite`/`resendStaffInvite`/`updateStaffCapabilities`/`removeStaff`; **`CAPABILITY_DEFS` matrix + `ROLE_PRESETS` — reskin the container only, preserve the form + `role="switch"` ARIA**; keep `LazyMotion`/`useReducedMotion` + `ConfirmDialog`. +- **Compose:** header→`SectionMasthead`; read-only notice→`EditorsLetter`/`InkBadge`; invite `Card`→`PaperCard`; `StaffRow` `Card`s→`PaperCard`/`PlayerRowPlate` + `Badge`→`InkBadge`; "No staff yet"→`EmptyIssue variant="generic"`. +- **Empty/gotchas:** coach + `can_invite_staff`; read-only viewer sees roster, no edit. +- **Owns:** edit `settings/staff/page.tsx`, `StaffSettingsClient.tsx`. + +#### settings/program · `/baseball/dashboard/settings/program` · **Class B** · **M–L** +- **Files:** page `.../settings/program/page.tsx` (43); client `components/baseball/settings/ProgramSettingsClient.tsx` (1151) + `AiAuditLog` (reused). +- **Preserve:** `getProgramSettings()` + `updateProgramSettings`/`changeProgramType`/`updateProgramIdentity` + variant-engine terminology + dirty-tracking + brand-hex guard + **two independent save paths (settings doc vs `baseball_teams` identity)**; **PRESERVE form primitives (`ToggleRow`/`Field`/`Checkbox`/`Select`/`Input`) — reskin containers only.** +- **Compose:** refactor the `SectionCard` helper ONCE →`PaperCard`+`SectionMasthead`/`Eyebrow`/`HairlineRule` (**all 11 sections inherit**); `Header`+in-header Save→`SectionMasthead`+`Button` (keep sticky-save); lock notice→`EditorsLetter`; coach-access `EmptyState`→`EmptyIssue variant="generic"`. +- **Gotchas:** coach + `can_manage_settings`; **program-type variance is the defining trait** (keep variant-driven copy dynamic); **keep reveal-on-MOUNT (not `whileInView` — that left a ~6,900px blank);** preserve `#anchor` deep-links + `scroll-mt-24`. +- **Owns:** edit `settings/program/page.tsx`, `ProgramSettingsClient.tsx`. + +### 3.2 THE WAR ROOM — coach recruiting · CLAY (`ink="pursuit"`) + +#### pipeline · `/baseball/dashboard/pipeline` · **Class B** · **L** +- **Files:** page `.../pipeline/page.tsx` (7, guard); client `pipeline/PipelineClient.tsx` (1026); legacy board `src/components/features/pipeline-column.tsx` + `pipeline-card.tsx` (**baseball-only callers — safe to delete**). +- **Preserve:** `useWatchlist()` + **dnd-kit** (`DndContext`/`DragOverlay`/`PointerSensor` 8px/`closestCorners`) + `updateStage`→`updateWatchlistStatus` + the `closestCorners` over-resolution + `PIPELINE_STAGE_IDS` guard + revert-on-failure + keyboard nav (j/k/Enter/x); reuse `PlayerDetailModal`/`PlayerPeekPanel`/`PositionPlanner`. +- **Compose (clay):** `PipelineStatsSummary` (amber/blue/purple tiles)→`KPIContentsStrip` clay + `StatReadout flashOnChange` (odometer); `Header`→`SectionMasthead ink="pursuit"`; columns→`Eyebrow`/`HairlineRule ink="pursuit"`; draggable `PipelineCard`→**`RecruitCard`** (GradeStamps + `AgingBar` + stage `InkBadge`); empty→`EmptyIssue variant="pipeline"` (clay); list/filter chrome→clay `PaperCard`. +- **Ceremony:** **`CommitSeal`** on drop into `committed` (drag-success branch after `updateStage`); `StatReadout flashOnChange` on the moving counts. +- **Gotchas:** **stage enum is EXACTLY 5 (`watchlist|high_priority|offer_extended|committed|uninterested`) — the DB enum `baseball_pipeline_stage` rejects extras; never reintroduce `contacted`/`campus_visit`.** Preserve mobile `overflow-x-auto snap-x`. `requireRecruitingCoachRoute()`. → **split PRs.** +- **Owns:** **create** `src/components/baseball/pipeline/PipelineFairway.tsx`, edit `pipeline/page.tsx` + `PipelineClient.tsx`, ~~features/pipeline-column.tsx~~, ~~features/pipeline-card.tsx~~. + +#### discover · `/baseball/dashboard/discover` · **Class B** · **M–L** +- **Files:** page `.../discover/page.tsx` (7, guard); client `discover/DiscoverClient.tsx` (512) + `src/components/coach/discover/DiscoverView.tsx` (751) + `FilterPanel.tsx` (643). +- **Preserve:** `getDiscoverPlayers`/`getDiscoverTeams`/`getWatchlistIds`/`getStateCounts` + URL-param filters + 300ms debounce + **AbortController request cancellation** + 24/pg + `players|teams` mode; reuse `PlayerPeekPanel`/`TeamPeekPanel`. +- **Compose (clay):** `Header`→`SectionMasthead ink="pursuit"` + "N found" `StatReadout`; player cards→`RecruitCard` + `InkBadge`(watchlisted); `FilterPanel`→clay filters (keep `ToolRail` idiom for velo/exit ranges; **mobile filter drawer survives**); error→kit inline; empty→`EmptyIssue variant="discover"` (NEW) / `pipeline`. +- **Gotchas:** preserve abort-on-refilter (no re-fetch loops). `requireRecruitingCoachRoute()`. +- **Owns:** edit `discover/page.tsx`, `DiscoverClient.tsx`, `coach/discover/DiscoverView.tsx`, `coach/discover/FilterPanel.tsx`. + +#### watchlist · `/baseball/dashboard/watchlist` · **Class B** · **L** +- **Files:** page `.../watchlist/page.tsx` (7, guard) + `WatchlistPageClient.tsx` (17, Suspense); client `watchlist/WatchlistClient.tsx` (1015). +- **Preserve:** `useWatchlist()` + `removeFromWatchlist`/`updateWatchlistStatus`/`addWatchlistNote`/`addToWatchlist` + **direct client add-search query (`baseball_players` where `recruiting_activated=true` + `.not('id','in',…)` exclusion — untouched)** + CSV export; reuse `PlayerPeekPanel`. +- **Compose (clay):** `Header`→`SectionMasthead ink="pursuit"`; desktop table + mobile cards→`RecruitCard`/`PlayerRowPlate ink="pursuit"`; `Badge` stage pills→`InkBadge tone="pursuit"` (`sodium` for committed); empty glass→`EmptyIssue variant="pipeline"` (clay); no-match→clay filter-empty; modals reskinned. +- **Ceremony:** `CommitSeal` when a row's stage ` - - - - {uploadError && ( -
    - {uploadError} -
    - )} - -

    - Tip: If PDF parsing doesn't work, use the CSV tab — download the template and fill it in manually. -

    -

    - )}
    ); } From 9bc6cce99e2725afc809b7ffad87cafdf540b83b Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 20:01:11 -0400 Subject: [PATCH 059/186] fix(baseball): wire complete/uncomplete controls into dev plan detail The coach-facing /dev-plans/[id] detail page was a read-only shell: it fetched goals with a raw client select into a stale `completed` boolean shape, and PlanDetail never called the existing completeGoal /uncompleteGoal server actions. Goals had no way to be marked done from the detail view. - dev-plans.ts: add getDevPlanById (coach-ownership-checked read), extend DevelopmentalPlanWithGoals with the joined player, and revalidate the coach's dev-plans list + detail path from completeGoal/uncompleteGoal alongside the existing player path. - dev-plans/[id]/page.tsx: fetch via getDevPlanById, role-gate to coach, and wire useTransition-wrapped onComplete/onUncomplete handlers that call completeGoal/uncompleteGoal and refetch. - PlanDetail.tsx: render each goal from the real DevPlanGoal shape (status/progress) with a working complete/uncomplete toggle button instead of the dead `completed` boolean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CHFRrZkRsAHWTwvxhySkfe --- .../dashboard/dev-plans/[id]/page.tsx | 136 +++++++++++------- src/app/baseball/actions/dev-plans.ts | 63 ++++++++ .../baseball/dev-plans/PlanDetail.tsx | 122 ++++++++++------ 3 files changed, 220 insertions(+), 101 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx index 0d1a90349..9bb63af91 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx @@ -1,79 +1,87 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useTransition } from 'react'; import { useParams } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; import { Header } from '@/components/layout/header'; import { PageLoading } from '@/components/ui/loading'; +import { Card, CardContent } from '@/components/ui/card'; import { PlanDetail } from '@/components/baseball/dev-plans/PlanDetail'; - -interface PlanWithPlayer { - id: string; - title: string; - description: string | null; - start_date: string | null; - end_date: string | null; - status: string | null; - goals: unknown; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - } | null; -} +import { useAuth } from '@/hooks/use-auth'; +import { useToast } from '@/components/ui/sonner'; +import { + getDevPlanById, + completeGoal, + uncompleteGoal, + type DevelopmentalPlanWithGoals, +} from '@/app/baseball/actions/dev-plans'; export default function DevPlanDetailPage() { const params = useParams<{ id: string }>(); - const [plan, setPlan] = useState(null); + const { user, loading: authLoading } = useAuth(); + const { showToast } = useToast(); + const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); - const supabase = createClient(); - - useEffect(() => { - async function fetchPlan() { - if (!params?.id) return; - setLoading(true); + const [isPending, startTransition] = useTransition(); - const { data, error } = await supabase - .from('baseball_developmental_plans') - .select(` - id, - title, - description, - start_date, - end_date, - status, - goals, - player:baseball_players ( - id, - first_name, - last_name, - avatar_url, - primary_position, - grad_year - ) - `) - .eq('id', params.id) - .single(); + const fetchPlan = useCallback(async () => { + if (!params?.id) return; - if (!error && data) { - setPlan(data as PlanWithPlayer); + try { + const data = await getDevPlanById(params.id); + if (data) { + setPlan(data); setNotFound(false); } else { setPlan(null); setNotFound(true); } - + } catch { + setPlan(null); + setNotFound(true); + } finally { setLoading(false); } + }, [params?.id]); + useEffect(() => { + setLoading(true); fetchPlan(); - }, [params?.id, supabase]); + }, [fetchPlan]); - if (loading) { + const handleComplete = useCallback( + (goalId: string) => { + if (!plan) return; + + startTransition(async () => { + try { + await completeGoal(plan.id, goalId); + await fetchPlan(); + } catch { + showToast('Could not mark goal complete', 'error'); + } + }); + }, + [plan, fetchPlan, showToast] + ); + + const handleUncomplete = useCallback( + (goalId: string) => { + if (!plan) return; + + startTransition(async () => { + try { + await uncompleteGoal(plan.id, goalId); + await fetchPlan(); + } catch { + showToast('Could not update goal', 'error'); + } + }); + }, + [plan, fetchPlan, showToast] + ); + + if (authLoading || loading) { return ( <>
    @@ -84,6 +92,21 @@ export default function DevPlanDetailPage() { ); } + if (user?.role !== 'coach') { + return ( + <> +
    +
    + + +

    Only coaches can access development plans.

    +
    +
    +
    + + ); + } + if (notFound || !plan) { return ( <> @@ -101,7 +124,12 @@ export default function DevPlanDetailPage() { <>
    - +
    ); diff --git a/src/app/baseball/actions/dev-plans.ts b/src/app/baseball/actions/dev-plans.ts index b6104f321..94ce87cb9 100644 --- a/src/app/baseball/actions/dev-plans.ts +++ b/src/app/baseball/actions/dev-plans.ts @@ -65,6 +65,14 @@ export interface DevelopmentalPlanWithGoals { id: string; full_name: string | null; } | null; + player?: { + id: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + primary_position: string | null; + grad_year: number | null; + } | null; } /** @@ -125,6 +133,57 @@ export async function getActiveDevPlan(playerId: string): Promise { + const supabase = await createClient(); + + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); + + const { data: coachProfile } = await supabase + .from('baseball_coaches') + .select('id') + .eq('user_id', user.id) + .single(); + + if (!coachProfile) throw new Error('Coach profile not found'); + + const { data: plan, error } = await supabase + .from('baseball_developmental_plans') + .select(` + *, + player:baseball_players ( + id, + first_name, + last_name, + avatar_url, + primary_position, + grad_year + ) + `) + .eq('id', planId) + .maybeSingle(); + + if (error) { + await logServerError(`Error fetching dev plan by id: ${error instanceof Error ? error.message : String(error)}`, { action: 'dev_plans.getDevPlanById' }); + throw error; + } + + if (!plan || plan.coach_id !== coachProfile.id) { + return null; + } + + return { + ...plan, + goals: parseGoals(plan.goals), + }; +} + /** * Verify the authenticated user is the player who owns the given plan. * Shared by every player-scoped goal mutation below (progress updates, @@ -369,6 +428,8 @@ const completeGoalAction = withBaseballAction( } revalidatePath(DEV_PLAN_PATH); + revalidatePath('/baseball/dashboard/dev-plans'); + revalidatePath(`/baseball/dashboard/dev-plans/${planId}`); }, ); @@ -448,6 +509,8 @@ const uncompleteGoalAction = withBaseballAction( } revalidatePath(DEV_PLAN_PATH); + revalidatePath('/baseball/dashboard/dev-plans'); + revalidatePath(`/baseball/dashboard/dev-plans/${planId}`); }, ); diff --git a/src/components/baseball/dev-plans/PlanDetail.tsx b/src/components/baseball/dev-plans/PlanDetail.tsx index 36f80dc47..a7621c1be 100644 --- a/src/components/baseball/dev-plans/PlanDetail.tsx +++ b/src/components/baseball/dev-plans/PlanDetail.tsx @@ -3,35 +3,20 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { ProgressTracker } from '@/components/baseball/dev-plans/ProgressTracker'; import { IconCalendar, IconCheck, IconClock, IconNote } from '@/components/icons'; -import { getFullName } from '@/lib/utils'; - -interface Goal { - title: string; - description?: string; - target_date?: string; - completed?: boolean; -} +import { getFullName, cn } from '@/lib/utils'; +import type { DevelopmentalPlanWithGoals } from '@/app/baseball/actions/dev-plans'; interface PlanDetailProps { - plan: { - id: string; - title: string; - description: string | null; - start_date: string | null; - end_date: string | null; - status: string | null; - goals: unknown; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - } | null; - }; + plan: DevelopmentalPlanWithGoals; + /** Called when a coach marks a goal complete. Omit to render read-only. */ + onComplete?: (goalId: string) => void; + /** Called when a coach reopens a completed goal. Omit to render read-only. */ + onUncomplete?: (goalId: string) => void; + /** Disables the goal controls while a mutation is in flight. */ + isPending?: boolean; } const getStatusVariant = (status: string | null): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' => { @@ -46,10 +31,11 @@ const getStatusVariant = (status: string | null): 'default' | 'primary' | 'secon return variants[status] || 'secondary'; }; -export function PlanDetail({ plan }: PlanDetailProps) { - const goals = Array.isArray(plan.goals) ? (plan.goals as Goal[]) : []; - const completedGoals = goals.filter((goal) => goal.completed).length; +export function PlanDetail({ plan, onComplete, onUncomplete, isPending = false }: PlanDetailProps) { + const goals = plan.goals ?? []; + const completedGoals = goals.filter((goal) => goal.status === 'completed').length; const playerName = getFullName(plan.player?.first_name, plan.player?.last_name); + const canToggle = Boolean(onComplete && onUncomplete); return (
    @@ -106,29 +92,71 @@ export function PlanDetail({ plan }: PlanDetailProps) {

    No goals have been added to this plan yet.

    ) : (
    - {goals.map((goal, index) => ( -
    -
    -
    -

    {goal.title}

    - {goal.description && ( -

    {goal.description}

    + {goals.map((goal) => { + const isCompleted = goal.status === 'completed'; + return ( +
    +
    +
    + {canToggle && ( + + )} +
    +

    + {goal.title} +

    + {goal.description && ( +

    {goal.description}

    + )} +
    +
    + {isCompleted && ( + + + Done + )}
    - {goal.completed && ( - - - Done - + + {!isCompleted && goal.progress > 0 && ( +
    +
    + Progress + {goal.progress}% +
    +
    +
    +
    +
    + )} + + {goal.target_date && ( +

    + Target: {new Date(goal.target_date).toLocaleDateString()} +

    )}
    - {goal.target_date && ( -

    - Target: {new Date(goal.target_date).toLocaleDateString()} -

    - )} -
    - ))} + ); + })}
    )} From cc495eead6c37c12e4249d4409fde8d1a470d022 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 20:02:03 -0400 Subject: [PATCH 060/186] fix(baseball): reconcile Lift Lab session/set writes to the reader's table space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlayerLiftSessionClient reads sessions from helm_lifting_sessions (per the W2-G rewire) and hands out helm-space ids, but startLiftSession/logSetResult/ completeLiftSession still looked up baseball_lift_sessions by that id — so every player-self set log 404'd with "Session not found". The coach Live Weight Room still hands out legacy baseball_lift_sessions ids to the same actions, so a legacy-only fix would have broken that surface instead. Make the three write actions resolve which table space a given session id lives in (resolveLiftSessionSpace) and branch accordingly, instead of assuming legacy space. PR detection (which still targets the legacy baseball_strength_prs/maxes tables) is bridged via legacy_baseball_id on the helm session/session_exercise rows set by publishLiftDay's Helm bridge, and skips gracefully (no PR fabricated) when no legacy mirror exists. Also fixes a latent onConflict bug copied into this file from the src/app/lifting/actions/* "helm-native" actions: the real DB constraint on helm_lifting_set_results is UNIQUE(session_exercise_id, set_number), not (..., athlete_id, ...) — the 3-column form 400s on every upsert. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CHFRrZkRsAHWTwvxhySkfe --- src/app/baseball/actions/lifting-v11.ts | 455 ++++++++++++++++++------ 1 file changed, 345 insertions(+), 110 deletions(-) diff --git a/src/app/baseball/actions/lifting-v11.ts b/src/app/baseball/actions/lifting-v11.ts index 8c05bec93..031b67260 100644 --- a/src/app/baseball/actions/lifting-v11.ts +++ b/src/app/baseball/actions/lifting-v11.ts @@ -2021,8 +2021,96 @@ async function detectAndRecordPr( // ============================================================================ // SESSION LIFECYCLE + SET LOGGING (player-self + coach-observed) +// +// DUAL-SPACE RECONCILIATION: the player Lift surface (PlayerLiftSessionClient, +// via getPlayerLiftSession) reads sessions from helm_lifting_sessions and hands +// out helm-space ids (see player-lift.ts + baseball-view-adapter.ts). The coach +// Live Weight Room (LiveWeightRoom.tsx, via getLiveWeightRoomData) still reads +// the legacy baseball_lift_sessions materialization and hands out legacy ids. +// Both surfaces call the SAME actions below (startLiftSession / logSetResult / +// completeLiftSession) with whichever id space their read model produced, so +// these actions resolve the session's table space per-call (resolveLiftSessionSpace) +// instead of assuming one space — a legacy-only lookup 404'd every player-self +// set log with "Session not found" once the read side moved to helm_lifting_*. // ============================================================================ +interface HelmLiftSpace { + space: 'helm'; + sessionId: string; + organizationId: string; + sport: string; + teamId: string | null; + athleteId: string; + legacyBaseballId: string | null; +} + +interface LegacyLiftSpace { + space: 'legacy'; + sessionId: string; + teamId: string; + playerId: string; +} + +type LiftSessionSpace = HelmLiftSpace | LegacyLiftSpace; + +/** + * Resolve which table space a given lift-session id lives in. Tries + * helm_lifting_sessions first (the current player-facing read model), then + * falls back to the legacy baseball_lift_sessions materialization (the current + * coach Live Weight Room read model). Returns null when the id exists in + * neither — RLS on both tables backstops cross-tenant access either way. + */ +async function resolveLiftSessionSpace( + supabase: Db, + sessionId: string, +): Promise { + const { data: helmSession } = (await fromUntyped(supabase, 'helm_lifting_sessions') + .select('id, organization_id, sport, team_id, athlete_id, legacy_baseball_id') + .eq('id', sessionId) + .maybeSingle()) as { + data: { + id: string; + organization_id: string; + sport: string; + team_id: string | null; + athlete_id: string; + legacy_baseball_id: string | null; + } | null; + }; + if (helmSession) { + return { + space: 'helm', + sessionId: helmSession.id, + organizationId: helmSession.organization_id, + sport: helmSession.sport, + teamId: helmSession.team_id, + athleteId: helmSession.athlete_id, + legacyBaseballId: helmSession.legacy_baseball_id, + }; + } + + const { data: legacySession } = await supabase + .from('baseball_lift_sessions') + .select('id, team_id, player_id') + .eq('id', sessionId) + .maybeSingle(); + if (legacySession) { + const s = legacySession as { id: string; team_id: string; player_id: string }; + return { space: 'legacy', sessionId: s.id, teamId: s.team_id, playerId: s.player_id }; + } + + return null; +} + +/** Resolve a helm_lifting_athletes.id back to the baseball_players.id it was seeded from. */ +async function resolveAthletePlayerId(supabase: Db, athleteId: string): Promise { + const { data } = (await fromUntyped(supabase, 'helm_lifting_athletes') + .select('sport_player_id') + .eq('id', athleteId) + .maybeSingle()) as { data: { sport_player_id: string | null } | null }; + return data?.sport_player_id ?? null; +} + const startSessionSchema = z.object({ sessionId: uuid }); export const startLiftSession = withBaseballAction( @@ -2031,18 +2119,34 @@ export const startLiftSession = withBaseballAction( async (ctx, raw: z.input): Promise => { const input = startSessionSchema.parse(raw); const supabase = (await createClient()) as Db; - const { data, error } = await supabase - .from('baseball_lift_sessions') - .update({ status: 'started', started_at: new Date().toISOString(), updated_at: new Date().toISOString() }) - .eq('id', input.sessionId) - .eq('status', 'assigned') - .select('id') - .maybeSingle(); - if (error) throw error; void ctx; + + const resolved = await resolveLiftSessionSpace(supabase, input.sessionId); + let started = false; + if (resolved?.space === 'helm') { + const { data, error } = await fromUntyped(supabase, 'helm_lifting_sessions') + .update({ status: 'started', started_at: new Date().toISOString(), updated_at: new Date().toISOString() }) + .eq('id', input.sessionId) + .eq('status', 'assigned') + .select('id') + .maybeSingle(); + if (error) throw error; + started = Boolean(data); + } else if (resolved?.space === 'legacy') { + const { data, error } = await supabase + .from('baseball_lift_sessions') + .update({ status: 'started', started_at: new Date().toISOString(), updated_at: new Date().toISOString() }) + .eq('id', input.sessionId) + .eq('status', 'assigned') + .select('id') + .maybeSingle(); + if (error) throw error; + started = Boolean(data); + } + revalidatePath(`${PLAYER_LIFT}/${input.sessionId}`); revalidatePath(PLAYER_TODAY); - return { success: true, id: input.sessionId, count: data ? 1 : 0 }; + return { success: true, id: input.sessionId, count: started ? 1 : 0 }; }, ); @@ -2070,61 +2174,137 @@ export const logSetResult = withBaseballAction( const input = logSetSchema.parse(raw); const supabase = (await createClient()) as Db; - // Resolve the session's player + team so we set the row's owner correctly - // (RLS will reject a mismatch). A coach-entered set marks coach_observed. - const { data: sess, error: sErr } = await supabase - .from('baseball_lift_sessions') - .select('id, team_id, player_id') - .eq('id', input.sessionId) - .maybeSingle(); - if (sErr) throw sErr; - if (!sess) throw new BaseballActionError('Session not found or not accessible.'); + const resolved = await resolveLiftSessionSpace(supabase, input.sessionId); + if (!resolved) throw new BaseballActionError('Session not found or not accessible.'); const isCoach = ctx.activeRole === 'coach'; - const { data, error } = await supabase - .from('baseball_lift_set_results') - .upsert( - { - session_exercise_id: input.sessionExerciseId, - team_id: (sess as { team_id: string }).team_id, - player_id: (sess as { player_id: string }).player_id, - set_number: input.setNumber, - actual_reps: input.actualReps ?? null, - actual_load: input.actualLoad ?? null, - load_unit: input.loadUnit ?? null, - rpe: input.rpe ?? null, - velocity: input.velocity ?? null, - player_note: input.playerNote ?? null, - coach_observed: isCoach, - completed_at: new Date().toISOString(), - }, - { onConflict: 'session_exercise_id,set_number' }, - ) - .select('id') - .single(); - if (error) throw error; + + let setRowId: string; + let teamId: string; + let playerId: string; + // Legacy-space ids to feed detectAndRecordPr — PR/max tables still FK to + // baseball_lift_sessions / baseball_lift_exercises. In helm space these are + // resolved via legacy_baseball_id (set by the publishLiftDay bridge); a + // helm session with no legacy mirror simply skips PR detection (honest — + // no PR row is fabricated, matches detectAndRecordPr's own swallow-errors + // contract). + let prSessionId: string | null = null; + let prSessionExerciseId: string | null = null; + + if (resolved.space === 'helm') { + const resolvedPlayerId = await resolveAthletePlayerId(supabase, resolved.athleteId); + if (!resolvedPlayerId) throw new BaseballActionError('Session not found or not accessible.'); + playerId = resolvedPlayerId; + teamId = resolved.teamId ?? ctx.targetTeamId; + + const { data, error } = await fromUntyped(supabase, 'helm_lifting_set_results') + .upsert( + { + session_exercise_id: input.sessionExerciseId, + organization_id: resolved.organizationId, + sport: resolved.sport, + athlete_id: resolved.athleteId, + set_number: input.setNumber, + actual_reps: input.actualReps ?? null, + actual_load: input.actualLoad ?? null, + load_unit: input.loadUnit ?? null, + rpe: input.rpe ?? null, + velocity: input.velocity ?? null, + player_note: input.playerNote ?? null, + coach_observed: isCoach, + completed_at: new Date().toISOString(), + }, + // Matches the real DB constraint (uq_helm_lifting_set — see migration + // 20260625000020): UNIQUE (session_exercise_id, set_number). NOT + // (..., athlete_id, ...) — that 3-column form (used by the + // src/app/lifting/actions/* helm-native actions) targets a + // constraint that doesn't exist and would 400 on every upsert. + { onConflict: 'session_exercise_id,set_number' }, + ) + .select('id') + .single(); + if (error) throw error; + setRowId = (data as { id: string }).id; + + // Auto-advance assigned -> started (mirrors the helm-native + // player-sessions.ts logMySetResult behavior). + await fromUntyped(supabase, 'helm_lifting_sessions') + .update({ status: 'started', started_at: new Date().toISOString(), updated_at: new Date().toISOString() }) + .eq('id', input.sessionId) + .eq('status', 'assigned'); + + if (resolved.legacyBaseballId) { + const { data: legacySe } = (await fromUntyped(supabase, 'helm_lifting_session_exercises') + .select('legacy_baseball_id') + .eq('id', input.sessionExerciseId) + .maybeSingle()) as { data: { legacy_baseball_id: string | null } | null }; + prSessionId = resolved.legacyBaseballId; + prSessionExerciseId = legacySe?.legacy_baseball_id ?? null; + } + } else { + // Legacy space — coach Live Weight Room today. Unchanged behavior. + const { data: sess, error: sErr } = await supabase + .from('baseball_lift_sessions') + .select('id, team_id, player_id') + .eq('id', input.sessionId) + .maybeSingle(); + if (sErr) throw sErr; + if (!sess) throw new BaseballActionError('Session not found or not accessible.'); + + teamId = (sess as { team_id: string }).team_id; + playerId = (sess as { player_id: string }).player_id; + + const { data, error } = await supabase + .from('baseball_lift_set_results') + .upsert( + { + session_exercise_id: input.sessionExerciseId, + team_id: teamId, + player_id: playerId, + set_number: input.setNumber, + actual_reps: input.actualReps ?? null, + actual_load: input.actualLoad ?? null, + load_unit: input.loadUnit ?? null, + rpe: input.rpe ?? null, + velocity: input.velocity ?? null, + player_note: input.playerNote ?? null, + coach_observed: isCoach, + completed_at: new Date().toISOString(), + }, + { onConflict: 'session_exercise_id,set_number' }, + ) + .select('id') + .single(); + if (error) throw error; + setRowId = (data as { id: string }).id; + prSessionId = input.sessionId; + prSessionExerciseId = input.sessionExerciseId; + } // Close the loop to PRs: a freshly logged set is checked against the player's // prior best for this exercise. Side-effect only — never rolls back the set. - const prCount = await detectAndRecordPr(supabase, { - teamId: (sess as { team_id: string }).team_id, - playerId: (sess as { player_id: string }).player_id, - sessionId: input.sessionId, - sessionExerciseId: input.sessionExerciseId, - actualLoad: input.actualLoad ?? null, - actualReps: input.actualReps ?? null, - loadUnit: input.loadUnit ?? null, - actorIsCoach: isCoach, - activeCoachId: ctx.activeCoachId, - }); + const prCount = + prSessionId && prSessionExerciseId + ? await detectAndRecordPr(supabase, { + teamId, + playerId, + sessionId: prSessionId, + sessionExerciseId: prSessionExerciseId, + actualLoad: input.actualLoad ?? null, + actualReps: input.actualReps ?? null, + loadUnit: input.loadUnit ?? null, + actorIsCoach: isCoach, + activeCoachId: ctx.activeCoachId, + }) + : 0; revalidatePath(`${PLAYER_LIFT}/${input.sessionId}`); revalidatePath(`${PERFORMANCE}/live`); revalidatePath(PLAYER_TODAY); if (prCount > 0) { - revalidatePath(`${PERFORMANCE}/players/${(sess as { player_id: string }).player_id}`); + revalidatePath(`${PERFORMANCE}/players/${playerId}`); } - return { success: true, id: (data as { id: string }).id, count: prCount }; + return { success: true, id: setRowId, count: prCount }; }, ); @@ -2139,39 +2319,92 @@ export const completeLiftSession = withBaseballAction( async (ctx, raw: z.input): Promise => { const input = completeSessionSchema.parse(raw); const supabase = (await createClient()) as Db; - const { data, error } = await supabase - .from('baseball_lift_sessions') - .update({ - status: 'completed', - completed_at: new Date().toISOString(), - player_note: input.playerNote ?? null, - updated_at: new Date().toISOString(), - }) - .eq('id', input.sessionId) - .select('id, team_id, player_id, title') - .maybeSingle(); - if (error) throw error; - if (!data) throw new BaseballActionError('Session not found or not editable.'); - const session = data as { - id: string; - team_id: string; - player_id: string; - title: string | null; - }; + const resolved = await resolveLiftSessionSpace(supabase, input.sessionId); + if (!resolved) throw new BaseballActionError('Session not found or not editable.'); + const isCoach = ctx.activeRole === 'coach'; - // Gather this session's exercise ids + name snapshots (needed for both PR - // sweep and the H2 completion stats: top-set display and RPE average). - const { data: seRows } = await supabase - .from('baseball_lift_session_exercises') - .select('id, exercise_name_snapshot') - .eq('session_id', input.sessionId); - type SeRow = { id: string; exercise_name_snapshot: string | null }; - const seIds = (seRows ?? []).map((r: SeRow) => r.id); - const seNameMap = new Map( - (seRows ?? []).map((r: SeRow) => [r.id, r.exercise_name_snapshot ?? 'Exercise']), - ); + let teamId: string; + let playerId: string; + let title: string | null; + // Each session_exercise as { id (table-space-local), nameSnapshot, legacyId }. + // legacyId feeds the PR sweep below — baseball_strength_prs/maxes still FK to + // the legacy baseball_lift_* tables — and is null for a helm session_exercise + // with no legacy mirror (skipped, never fabricated). + let seList: Array<{ id: string; nameSnapshot: string; legacyId: string | null }>; + let legacySessionIdForPr: string | null; + + if (resolved.space === 'helm') { + const resolvedPlayerId = await resolveAthletePlayerId(supabase, resolved.athleteId); + if (!resolvedPlayerId) throw new BaseballActionError('Session not found or not editable.'); + playerId = resolvedPlayerId; + teamId = resolved.teamId ?? ctx.targetTeamId; + legacySessionIdForPr = resolved.legacyBaseballId; + + const { data, error } = await fromUntyped(supabase, 'helm_lifting_sessions') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + player_note: input.playerNote ?? null, + updated_at: new Date().toISOString(), + }) + .eq('id', input.sessionId) + .select('id, title') + .maybeSingle(); + if (error) throw error; + if (!data) throw new BaseballActionError('Session not found or not editable.'); + title = (data as { title: string | null }).title; + + const { data: seRows } = (await fromUntyped(supabase, 'helm_lifting_session_exercises') + .select('id, exercise_name_snapshot, legacy_baseball_id') + .eq('session_id', input.sessionId)) as { + data: Array<{ + id: string; + exercise_name_snapshot: string | null; + legacy_baseball_id: string | null; + }> | null; + }; + seList = (seRows ?? []).map((r) => ({ + id: r.id, + nameSnapshot: r.exercise_name_snapshot ?? 'Exercise', + legacyId: r.legacy_baseball_id, + })); + } else { + teamId = resolved.teamId; + playerId = resolved.playerId; + legacySessionIdForPr = input.sessionId; + + const { data, error } = await supabase + .from('baseball_lift_sessions') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + player_note: input.playerNote ?? null, + updated_at: new Date().toISOString(), + }) + .eq('id', input.sessionId) + .select('id, title') + .maybeSingle(); + if (error) throw error; + if (!data) throw new BaseballActionError('Session not found or not editable.'); + title = (data as { title: string | null }).title; + + const { data: seRows } = await supabase + .from('baseball_lift_session_exercises') + .select('id, exercise_name_snapshot') + .eq('session_id', input.sessionId); + type SeRow = { id: string; exercise_name_snapshot: string | null }; + seList = ((seRows ?? []) as SeRow[]).map((r) => ({ + id: r.id, + nameSnapshot: r.exercise_name_snapshot ?? 'Exercise', + legacyId: r.id, + })); + } + + const seIds = seList.map((s) => s.id); + const seNameMap = new Map(seList.map((s) => [s.id, s.nameSnapshot])); + const seLegacyMap = new Map(seList.map((s) => [s.id, s.legacyId])); // All logged sets for this session — no load/reps filter so bodyweight sets // contribute to the RPE average and the top-set scan covers everything. @@ -2184,36 +2417,38 @@ export const completeLiftSession = withBaseballAction( }; const allSets: SetStatRow[] = []; if (seIds.length > 0) { - const { data: setData } = await supabase - .from('baseball_lift_set_results') - .select('session_exercise_id, actual_load, actual_reps, load_unit, rpe') - .in('session_exercise_id', seIds); + const { data: setData } = + resolved.space === 'helm' + ? await fromUntyped(supabase, 'helm_lifting_set_results') + .select('session_exercise_id, actual_load, actual_reps, load_unit, rpe') + .in('session_exercise_id', seIds) + : await supabase + .from('baseball_lift_set_results') + .select('session_exercise_id, actual_load, actual_reps, load_unit, rpe') + .in('session_exercise_id', seIds); if (setData) allSets.push(...(setData as SetStatRow[])); } // Final PR sweep: catch any best from a logged set in this session that // hasn't already produced a PR (idempotent — detectAndRecordPr re-reads the // current best each time, so an already-recorded PR is not re-emitted). + // Sourced from `allSets` (already fetched from the correct table space) + // instead of a second query, so this works identically in both spaces; a + // session_exercise with no legacy mirror is skipped (no PR/max FK target + // exists for it yet). let prCount = 0; - if (seIds.length > 0) { - const { data: sweepSets } = await supabase - .from('baseball_lift_set_results') - .select('session_exercise_id, actual_load, actual_reps, load_unit') - .eq('player_id', session.player_id) - .gt('actual_load', 0) - .gt('actual_reps', 0) - .in('session_exercise_id', seIds); - for (const s of (sweepSets ?? []) as Array<{ - session_exercise_id: string; - actual_load: number | null; - actual_reps: number | null; - load_unit: string | null; - }>) { + if (legacySessionIdForPr) { + for (const s of allSets) { + const load = Number(s.actual_load ?? 0); + const reps = Number(s.actual_reps ?? 0); + if (load <= 0 || reps <= 0) continue; + const legacySeId = seLegacyMap.get(s.session_exercise_id); + if (!legacySeId) continue; prCount += await detectAndRecordPr(supabase, { - teamId: session.team_id, - playerId: session.player_id, - sessionId: input.sessionId, - sessionExerciseId: s.session_exercise_id, + teamId, + playerId, + sessionId: legacySessionIdForPr, + sessionExerciseId: legacySeId, actualLoad: s.actual_load, actualReps: s.actual_reps, loadUnit: s.load_unit, @@ -2249,12 +2484,12 @@ export const completeLiftSession = withBaseballAction( // Always close the loop to the timeline: even with no PR, a completed lift is // a real player_only event so the lifting loop reaches the player timeline. await appendLiftTimelineEvent({ - teamId: session.team_id, - playerId: session.player_id, + teamId, + playerId, title: prCount > 0 - ? `Completed ${session.title ?? 'lift'} — ${prCount} new PR${prCount === 1 ? '' : 's'}` - : `Completed ${session.title ?? 'lift'}`, + ? `Completed ${title ?? 'lift'} — ${prCount} new PR${prCount === 1 ? '' : 's'}` + : `Completed ${title ?? 'lift'}`, sourceId: input.sessionId, actorIsCoach: isCoach, createdBy: isCoach ? ctx.activeCoachId : null, @@ -2263,7 +2498,7 @@ export const completeLiftSession = withBaseballAction( revalidatePath(`${PLAYER_LIFT}/${input.sessionId}`); revalidatePath(PERFORMANCE); revalidatePath(PLAYER_TODAY); - revalidatePath(`${PERFORMANCE}/players/${session.player_id}`); + revalidatePath(`${PERFORMANCE}/players/${playerId}`); return { success: true, id: input.sessionId, count: prCount, prCount, topSet, rpeAverage }; }, ); From a82cafbbc99b24b56aaae6ca17308d8d71377a73 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 20:03:21 -0400 Subject: [PATCH 061/186] fix(baseball): stable goal ids so coach-created dev-plan goals can be completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CreateDevPlanModal generated a crypto.randomUUID() per goal for React keys but never persisted it on insert (wrote only title/description/target_date/ completed). parseGoals() then minted a brand-new random id on every fetch, so any goalId captured by the UI was stale on the next request — coach- created plan goals could never be completed ("Goal not found", always). - CreateDevPlanModal now persists the full DevPlanGoal shape (stable id, status: 'not_started', progress: 0, created_at) on insert. - Moved DevPlanGoal/DevelopmentalPlanWithGoals types out of the 'use server' actions file into a plain src/lib/baseball/dev-plan-types.ts module so client components import them directly; dev-plans.ts re-exports the same names for backward compatibility. - Added getDevPlanForCoach(planId) — coach-ownership-checked action that returns parseGoals-normalized goals for the /dev-plans/[id] detail route. - PlanDetail.tsx now reads the real status/progress schema (was a dead `completed` boolean nothing ever wrote) and exposes coach complete/ uncomplete controls wired to the existing completeGoal/uncompleteGoal actions. - dev-plans/[id]/page.tsx fetches via getDevPlanForCoach instead of a raw client select, with useTransition-wrapped mutate + refetch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CHFRrZkRsAHWTwvxhySkfe --- .../dashboard/dev-plans/[id]/page.tsx | 127 ++++++++++-------- src/app/baseball/actions/dev-plans.ts | 108 ++++++++++----- .../baseball/dev-plans/PlanDetail.tsx | 125 ++++++++++------- src/components/coach/CreateDevPlanModal.tsx | 23 +++- src/lib/baseball/dev-plan-types.ts | 68 ++++++++++ 5 files changed, 302 insertions(+), 149 deletions(-) create mode 100644 src/lib/baseball/dev-plan-types.ts diff --git a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx index 0d1a90349..733f1f590 100644 --- a/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx +++ b/src/app/baseball/(dashboard)/dashboard/dev-plans/[id]/page.tsx @@ -1,77 +1,83 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useTransition } from 'react'; import { useParams } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; import { Header } from '@/components/layout/header'; import { PageLoading } from '@/components/ui/loading'; import { PlanDetail } from '@/components/baseball/dev-plans/PlanDetail'; - -interface PlanWithPlayer { - id: string; - title: string; - description: string | null; - start_date: string | null; - end_date: string | null; - status: string | null; - goals: unknown; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - } | null; -} +import { useToast } from '@/components/ui/sonner'; +import { + getDevPlanForCoach, + completeGoal, + uncompleteGoal, +} from '@/app/baseball/actions/dev-plans'; +import type { DevPlanWithPlayer } from '@/lib/baseball/dev-plan-types'; export default function DevPlanDetailPage() { const params = useParams<{ id: string }>(); - const [plan, setPlan] = useState(null); + const { showToast } = useToast(); + const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); - const supabase = createClient(); - - useEffect(() => { - async function fetchPlan() { - if (!params?.id) return; - setLoading(true); - - const { data, error } = await supabase - .from('baseball_developmental_plans') - .select(` - id, - title, - description, - start_date, - end_date, - status, - goals, - player:baseball_players ( - id, - first_name, - last_name, - avatar_url, - primary_position, - grad_year - ) - `) - .eq('id', params.id) - .single(); + const [isPending, startTransition] = useTransition(); + const [pendingGoalId, setPendingGoalId] = useState(null); - if (!error && data) { - setPlan(data as PlanWithPlayer); - setNotFound(false); - } else { - setPlan(null); - setNotFound(true); - } + const fetchPlan = useCallback(async () => { + if (!params?.id) return; + setLoading(true); + try { + const data = await getDevPlanForCoach(params.id); + setPlan(data); + setNotFound(false); + } catch (error) { + console.error('Error fetching dev plan:', error); + setPlan(null); + setNotFound(true); + } finally { setLoading(false); } + }, [params?.id]); + useEffect(() => { fetchPlan(); - }, [params?.id, supabase]); + }, [fetchPlan]); + + const handleComplete = useCallback( + (goalId: string) => { + if (!plan) return; + setPendingGoalId(goalId); + startTransition(async () => { + try { + await completeGoal(plan.id, goalId); + await fetchPlan(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not mark goal complete', 'error'); + } finally { + setPendingGoalId(null); + } + }); + }, + [plan, fetchPlan, showToast] + ); + + const handleUncomplete = useCallback( + (goalId: string) => { + if (!plan) return; + setPendingGoalId(goalId); + startTransition(async () => { + try { + await uncompleteGoal(plan.id, goalId); + await fetchPlan(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Could not update goal', 'error'); + } finally { + setPendingGoalId(null); + } + }); + }, + [plan, fetchPlan, showToast] + ); if (loading) { return ( @@ -101,7 +107,12 @@ export default function DevPlanDetailPage() { <>
    - +
    ); diff --git a/src/app/baseball/actions/dev-plans.ts b/src/app/baseball/actions/dev-plans.ts index b6104f321..504a94f6d 100644 --- a/src/app/baseball/actions/dev-plans.ts +++ b/src/app/baseball/actions/dev-plans.ts @@ -11,6 +11,17 @@ import { BaseballNoActiveTeamError, BaseballActionError, } from '@/lib/baseball/with-baseball-action'; +import type { + GoalStatus, + DevPlanGoal, + DevelopmentalPlanWithGoals, + DevPlanWithPlayer, +} from '@/lib/baseball/dev-plan-types'; + +// Re-exported for backward compatibility — the canonical definitions now +// live in the plain (non-'use server') `dev-plan-types` module so client +// components can import them directly. +export type { GoalStatus, DevPlanGoal, DevelopmentalPlanWithGoals, DevPlanWithPlayer }; const DEV_PLAN_PATH = '/baseball/dashboard/dev-plan'; @@ -30,43 +41,6 @@ function mapDevPlanCoachError(error: unknown): never { throw error; } -// Goal status types -export type GoalStatus = 'not_started' | 'in_progress' | 'completed'; - -// Goal structure within the JSON field -export interface DevPlanGoal { - id: string; - title: string; - description?: string; - category?: string; - progress: number; // 0-100 - status: GoalStatus; - target_date?: string; - coach_notes?: string; - completed_at?: string; - created_at: string; -} - -// Full plan with typed goals -export interface DevelopmentalPlanWithGoals { - id: string; - player_id: string; - team_id: string | null; - coach_id: string; - title: string; - description: string | null; - status: string | null; - start_date: string | null; - end_date: string | null; - goals: DevPlanGoal[]; - created_at: string | null; - updated_at: string | null; - coach?: { - id: string; - full_name: string | null; - } | null; -} - /** * Get a player's developmental plan(s) */ @@ -125,6 +99,66 @@ export async function getActiveDevPlan(playerId: string): Promise { + try { + return await getDevPlanForCoachAction(planId); + } catch (error) { + await logServerError( + `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + { action: 'dev_plans.getDevPlanForCoach', featureArea: 'baseball-dev-plans' }, + ); + mapDevPlanCoachError(error); + } +} + +const getDevPlanForCoachAction = withBaseballAction( + 'getDevPlanForCoach', + { featureArea: 'baseball-dev-plans', requiredCapability: 'can_manage_settings' }, + async (ctx, planId: string): Promise => { + const supabase = await createClient(); + const coachId = ctx.activeCoachId; + if (!coachId) throw new Error('Coach profile not found'); + + const { data: plan, error: fetchError } = await supabase + .from('baseball_developmental_plans') + .select(` + *, + player:baseball_players ( + id, + first_name, + last_name, + avatar_url, + primary_position, + grad_year + ) + `) + .eq('id', planId) + .single(); + + if (fetchError) { + await logServerError(`Error fetching plan: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`, { action: 'dev_plans.getDevPlanForCoach' }); + throw fetchError; + } + + if (!plan || plan.coach_id !== coachId) { + throw new Error('You do not have permission to view this plan'); + } + + return { + ...plan, + goals: parseGoals(plan.goals), + }; + }, +); + /** * Verify the authenticated user is the player who owns the given plan. * Shared by every player-scoped goal mutation below (progress updates, diff --git a/src/components/baseball/dev-plans/PlanDetail.tsx b/src/components/baseball/dev-plans/PlanDetail.tsx index 36f80dc47..6c4dff9ea 100644 --- a/src/components/baseball/dev-plans/PlanDetail.tsx +++ b/src/components/baseball/dev-plans/PlanDetail.tsx @@ -3,35 +3,20 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { ProgressTracker } from '@/components/baseball/dev-plans/ProgressTracker'; -import { IconCalendar, IconCheck, IconClock, IconNote } from '@/components/icons'; +import { IconCalendar, IconCheck, IconClock, IconNote, IconRotateCcw } from '@/components/icons'; import { getFullName } from '@/lib/utils'; - -interface Goal { - title: string; - description?: string; - target_date?: string; - completed?: boolean; -} +import type { DevPlanGoal, DevPlanWithPlayer, GoalStatus } from '@/lib/baseball/dev-plan-types'; interface PlanDetailProps { - plan: { - id: string; - title: string; - description: string | null; - start_date: string | null; - end_date: string | null; - status: string | null; - goals: unknown; - player: { - id: string; - first_name: string | null; - last_name: string | null; - avatar_url: string | null; - primary_position: string | null; - grad_year: number | null; - } | null; - }; + plan: DevPlanWithPlayer; + /** Coach marks a goal complete. Omit to render the detail read-only. */ + onComplete?: (goalId: string) => void; + /** Coach reverts a completed goal back to in-progress. */ + onUncomplete?: (goalId: string) => void; + /** goalId currently mutating (disables its buttons + shows a pending state). */ + pendingGoalId?: string | null; } const getStatusVariant = (status: string | null): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' => { @@ -46,10 +31,23 @@ const getStatusVariant = (status: string | null): 'default' | 'primary' | 'secon return variants[status] || 'secondary'; }; -export function PlanDetail({ plan }: PlanDetailProps) { - const goals = Array.isArray(plan.goals) ? (plan.goals as Goal[]) : []; - const completedGoals = goals.filter((goal) => goal.completed).length; +const GOAL_STATUS_LABEL: Record = { + not_started: 'Not Started', + in_progress: 'In Progress', + completed: 'Completed', +}; + +const GOAL_STATUS_VARIANT: Record = { + not_started: 'secondary', + in_progress: 'primary', + completed: 'success', +}; + +export function PlanDetail({ plan, onComplete, onUncomplete, pendingGoalId }: PlanDetailProps) { + const goals: DevPlanGoal[] = Array.isArray(plan.goals) ? plan.goals : []; + const completedGoals = goals.filter((goal) => goal.status === 'completed').length; const playerName = getFullName(plan.player?.first_name, plan.player?.last_name); + const canManage = Boolean(onComplete && onUncomplete); return (
    @@ -106,29 +104,60 @@ export function PlanDetail({ plan }: PlanDetailProps) {

    No goals have been added to this plan yet.

    ) : (
    - {goals.map((goal, index) => ( -
    -
    -
    -

    {goal.title}

    - {goal.description && ( -

    {goal.description}

    + {goals.map((goal) => { + const isPending = pendingGoalId === goal.id; + const isCompleted = goal.status === 'completed'; + return ( +
    +
    +
    +

    {goal.title}

    + {goal.description && ( +

    {goal.description}

    + )} +
    + + {isCompleted && } + {GOAL_STATUS_LABEL[goal.status]} + +
    +
    + {goal.target_date ? ( +

    + Target: {new Date(goal.target_date).toLocaleDateString()} +

    + ) : ( + + )} + {canManage && ( + isCompleted ? ( + + ) : ( + + ) )}
    - {goal.completed && ( - - - Done - - )}
    - {goal.target_date && ( -

    - Target: {new Date(goal.target_date).toLocaleDateString()} -

    - )} -
    - ))} + ); + })}
    )} diff --git a/src/components/coach/CreateDevPlanModal.tsx b/src/components/coach/CreateDevPlanModal.tsx index 4fa5f0776..ccd3203c4 100644 --- a/src/components/coach/CreateDevPlanModal.tsx +++ b/src/components/coach/CreateDevPlanModal.tsx @@ -9,6 +9,8 @@ import { IconX, IconPlus, IconTrash } from '@/components/icons'; import { useAuth } from '@/hooks/use-auth'; import { getFullName } from '@/lib/utils'; import { useToast } from '@/components/ui/sonner'; +import type { DevPlanGoal } from '@/lib/baseball/dev-plan-types'; +import type { Json } from '@/lib/types/database'; interface CreateDevPlanModalProps { open: boolean; @@ -115,6 +117,12 @@ export function CreateDevPlanModal({ open, onClose, teamId }: CreateDevPlanModal // Filter out empty goals const validGoals = goals.filter(g => g.title.trim()); + // Persist the FULL goal shape (id/progress/status/created_at) — not just + // title/description/target_date. Dropping `id` here means every fetch + // mints a brand-new random id (see parseGoals in actions/dev-plans.ts), + // so any goalId captured by the UI is stale on the next request and + // completing a goal always fails with "Goal not found". + const nowIso = new Date().toISOString(); const { error } = await supabase.from('baseball_developmental_plans').insert({ coach_id: coach.id, player_id: selectedPlayerId, @@ -123,14 +131,17 @@ export function CreateDevPlanModal({ open, onClose, teamId }: CreateDevPlanModal description: formData.description || null, start_date: formData.start_date || null, end_date: formData.end_date || null, - goals: validGoals.map(g => ({ + goals: validGoals.map((g): DevPlanGoal => ({ + id: g.id, title: g.title, - description: g.description, - target_date: g.target_date, - completed: false, - })), + description: g.description || undefined, + target_date: g.target_date || undefined, + progress: 0, + status: 'not_started', + created_at: nowIso, + })) as unknown as Json, status: 'sent', - created_at: new Date().toISOString(), + created_at: nowIso, }); setLoading(false); diff --git a/src/lib/baseball/dev-plan-types.ts b/src/lib/baseball/dev-plan-types.ts new file mode 100644 index 000000000..677363826 --- /dev/null +++ b/src/lib/baseball/dev-plan-types.ts @@ -0,0 +1,68 @@ +/** + * Shared development-plan goal types. + * + * Lives in a plain (non-'use server') module so client components — e.g. + * `CreateDevPlanModal.tsx`, `PlanDetail.tsx` — can import these types + * directly without going through a server-action file. `src/app/baseball/ + * actions/dev-plans.ts` re-exports the same names for backward + * compatibility with existing server-action callers. + * + * Goal `id` MUST be a stable UUID persisted at creation time (see + * `CreateDevPlanModal.handleSubmit`). Historically the create modal + * generated a client-side id per goal for React `key`s but never + * persisted it, so `parseGoals()` minted a brand-new random id on every + * fetch — any id captured by the UI was stale by the next request, + * making coach-created goals permanently uncompletable ("Goal not + * found"). Every write path must round-trip `id`/`status`/`progress`. + */ + +export type GoalStatus = 'not_started' | 'in_progress' | 'completed'; + +/** A goal as stored in the `goals` JSONB column of `baseball_developmental_plans`. */ +export interface DevPlanGoal { + id: string; + title: string; + description?: string; + category?: string; + progress: number; // 0-100 + status: GoalStatus; + target_date?: string; + coach_notes?: string; + completed_at?: string; + created_at: string; +} + +/** Minimal player summary joined onto a plan for display purposes. */ +export interface DevPlanPlayerSummary { + id: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; + primary_position: string | null; + grad_year: number | null; +} + +/** Full plan with typed, parsed goals. */ +export interface DevelopmentalPlanWithGoals { + id: string; + player_id: string; + team_id: string | null; + coach_id: string; + title: string; + description: string | null; + status: string | null; + start_date: string | null; + end_date: string | null; + goals: DevPlanGoal[]; + created_at: string | null; + updated_at: string | null; + coach?: { + id: string; + full_name: string | null; + } | null; +} + +/** Plan with the owning player joined — used by the coach-facing detail view. */ +export interface DevPlanWithPlayer extends DevelopmentalPlanWithGoals { + player: DevPlanPlayerSummary | null; +} From 944c5e9fa27624ead673bcbc63d40c5baf9ade6c Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 1 Jul 2026 20:08:38 -0400 Subject: [PATCH 062/186] feat(baseball): add team edit/delete/leave + invite revoke, harden invite codes The Showcase "Teams" management page could only create teams and generate invites via raw client-side inserts with swallowed errors and Math.random() invite codes. Adds withBaseballAction-wrapped server actions (updateTeam, deleteTeam, leaveTeamAsCoach, createTeamInvitation, revokeTeamInvitation) plus a hardened createTeam, and wires the UI: Edit modal, Delete confirm (blocked while roster is non-empty), Leave Team for non-primary coaches, and a Revoke control on the invite chip. Every failure path now surfaces a toast instead of failing silently. generateInviteCode() is hardened to crypto.randomBytes (CSPRNG) instead of Math.random(), paired with insert/update collision retry. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CHFRrZkRsAHWTwvxhySkfe --- .../dashboard/teams/TeamsClient.tsx | 690 ++++++++++++------ src/app/baseball/actions/teams.ts | 496 ++++++++++++- src/lib/validation/action-schemas.ts | 15 + 3 files changed, 980 insertions(+), 221 deletions(-) diff --git a/src/app/baseball/(dashboard)/dashboard/teams/TeamsClient.tsx b/src/app/baseball/(dashboard)/dashboard/teams/TeamsClient.tsx index a3b015e42..e72a95599 100644 --- a/src/app/baseball/(dashboard)/dashboard/teams/TeamsClient.tsx +++ b/src/app/baseball/(dashboard)/dashboard/teams/TeamsClient.tsx @@ -1,13 +1,16 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { Header } from '@/components/layout/header'; import { PageLoading } from '@/components/ui/loading'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useToast } from '@/components/ui/sonner'; import { useAuth } from '@/hooks/use-auth'; import Image from 'next/image'; import { @@ -17,7 +20,12 @@ import { IconVideo, IconCopy, IconCheck, + IconEdit, + IconTrash, + IconLogOut, + IconX, } from '@/components/icons'; +import { createTeam, updateTeam, deleteTeam, leaveTeamAsCoach, createTeamInvitation, revokeTeamInvitation } from '@/app/baseball/actions/teams'; interface Team { id: string; @@ -48,103 +56,248 @@ interface TeamInvite { updated_at: string | null; } +interface TeamFormState { + name: string; + description: string; + primary_color: string; + secondary_color: string; +} + +const EMPTY_FORM: TeamFormState = { + name: '', + description: '', + primary_color: '#16A34A', + secondary_color: '#FFFFFF', +}; + +/** Native color swatch + hex text field, defined once and shared by both the + * create and edit team forms. */ +function ColorField({ + id, + label, + value, + onChange, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
    + +
    + onChange(e.target.value)} + className="w-10 h-10 rounded-lg border border-warm-200 cursor-pointer" + /> + onChange(e.target.value)} className="flex-1" /> +
    +
    + ); +} + +/** Shared Create/Edit team modal — one implementation backs both flows so the + * form markup (and its design-token compliance) only has to be right once. */ +function TeamFormModal({ + title, + submitLabel, + isSubmitting, + form, + onChange, + onSubmit, + onCancel, +}: { + title: string; + submitLabel: string; + isSubmitting: boolean; + form: TeamFormState; + onChange: (form: TeamFormState) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; +}) { + return ( +
    + +
    +
    +

    {title}

    +
    +
    + onChange({ ...form, name: e.target.value })} + required + /> +