diff --git a/src/app/baseball/actions/stat-visual-views.ts b/src/app/baseball/actions/stat-visual-views.ts index e838cc3fd..23103178c 100644 --- a/src/app/baseball/actions/stat-visual-views.ts +++ b/src/app/baseball/actions/stat-visual-views.ts @@ -82,7 +82,12 @@ function normalizeVisualKey(key: string): string | null { export const getStatVisualViews = withBaseballAction( 'getStatVisualViews', - FEATURE, + // Pure SELECT — no write a demo visitor could use to alter state another + // visitor would then see, so it's safe (and correct) for the shared + // Baseball demo coach session. Without this, the demo account hit the + // fail-closed default and every load threw BaseballDemoReadOnlyError, + // surfacing as a permanent "Saved views unavailable" toast in the gallery. + { ...FEATURE, demoSafe: true }, async ( ctx, input?: { playerId?: string | null }, diff --git a/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx b/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx index a6b771a6e..9e146db63 100644 --- a/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx +++ b/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx @@ -105,7 +105,18 @@ export default async function PlayerCoachHelmPage() { const { coach, player } = session; if (!player) { - if (coach) return ; + if (coach) { + // A coach landing on the PLAYER CoachHelm dashboard has their own + // coach-facing Brief — send them straight there instead of an + // interstitial that just points at "the roster page". The interstitial + // stays as a fallback for the genuine players-only edge case: a coach + // record with no resolved organization yet (mid-onboarding), where the + // Brief route can't resolve a team either. + if (coach.organization_id) { + redirect('/golf/dashboard/intelligence'); + } + return ; + } return redirect('/golf/player'); } diff --git a/src/components/baseball/stat-visuals/use-stat-visual-views.ts b/src/components/baseball/stat-visuals/use-stat-visual-views.ts index 67621563c..75dbc988f 100644 --- a/src/components/baseball/stat-visuals/use-stat-visual-views.ts +++ b/src/components/baseball/stat-visuals/use-stat-visual-views.ts @@ -36,6 +36,21 @@ import { } from '@/app/baseball/actions/stat-visual-views'; import type { StatVisualSavedView } from './StatVisualsSection'; +/** + * A load failure is expected/non-actionable — not "your saved views are + * broken" — when it's a permission denial (sanitizeDbError's RLS-denial + * message) or the shared demo-account read-only guard. Neither is something + * the viewer can act on, so it stays silent; a genuine load failure (network, + * unexpected DB error) still toasts for the owner. Matched case-insensitively + * against a substring since a thrown server-action error crossing the RSC + * boundary can lose its exact message in production. + */ +function isSilentLoadFailure(message: string | null | undefined): boolean { + if (!message) return false; + const m = message.toLowerCase(); + return m.includes('permission') || m.includes('live demo'); +} + export interface UseStatVisualViewsResult { savedViews: StatVisualSavedView[]; onSaveView: (input: { @@ -67,14 +82,23 @@ export function useStatVisualViews(playerId?: string | null): UseStatVisualViews useEffect(() => { let cancelled = false; + // Toast id of any "load failed" warning this effect fired, so the + // cleanup below can dismiss it — a route change unmounts this hook's + // owner but sonner's Toaster is mounted once at the app root, so a + // toast fired on the way out would otherwise sit on screen through the + // navigation instead of disappearing with the surface that raised it. + let pendingToastId: string | number | null = null; + void getStatVisualViews({ playerId: playerId ?? null }) .then((res) => { if (cancelled) return; if (!res.success || !res.data) { - toast.warning( - 'Saved views unavailable', - res.error ?? 'Could not load your saved chart settings.', - ); + if (!isSilentLoadFailure(res.error)) { + pendingToastId = toast.warning( + 'Saved views unavailable', + res.error ?? 'Could not load your saved chart settings.', + ); + } return; } const loaded: StatVisualSavedView[] = res.data.map((v) => ({ @@ -85,13 +109,20 @@ export function useStatVisualViews(playerId?: string | null): UseStatVisualViews setSavedViews(loaded); savedViewsRef.current = loaded; }) - .catch(() => { + .catch((err: unknown) => { if (cancelled) return; // Network-level or unexpected throw — non-fatal, gallery still renders. - toast.warning('Saved views unavailable', 'Could not load your saved chart settings.'); + const message = err instanceof Error ? err.message : null; + if (!isSilentLoadFailure(message)) { + pendingToastId = toast.warning( + 'Saved views unavailable', + 'Could not load your saved chart settings.', + ); + } }); return () => { cancelled = true; + if (pendingToastId !== null) toast.dismiss(pendingToastId); }; }, [playerId]); diff --git a/src/components/baseball/stats-center/StatsCenterClient.tsx b/src/components/baseball/stats-center/StatsCenterClient.tsx index b11a2b3b9..238de432d 100644 --- a/src/components/baseball/stats-center/StatsCenterClient.tsx +++ b/src/components/baseball/stats-center/StatsCenterClient.tsx @@ -760,7 +760,7 @@ export function StatsCenterClient({ { label: 'On the Record', value: model.summary.playersWithData, emphasis: model.summary.playersWithData > 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 }, + { label: 'Pending Updates', value: model.summary.unreconciled, emphasis: model.summary.unreconciled > 0 }, ]} /> diff --git a/src/components/fairway/pages/coachhelm/AskConversationRail.tsx b/src/components/fairway/pages/coachhelm/AskConversationRail.tsx index 19c19de56..458e5b6f1 100644 --- a/src/components/fairway/pages/coachhelm/AskConversationRail.tsx +++ b/src/components/fairway/pages/coachhelm/AskConversationRail.tsx @@ -82,6 +82,22 @@ const ORIGIN_ICON: Record + Threads + +); + /** Relative date — only ever called with a real ISO string (never fabricated). */ function formatRelativeDate(iso: string): string { const then = new Date(iso).getTime(); @@ -149,7 +165,7 @@ export function AskConversationRail({ @@ -173,7 +189,7 @@ export function AskConversationRail({ @@ -192,7 +208,7 @@ export function AskConversationRail({ as="nav" depth="base" padding="md" - header="Threads" + header={THREADS_HEADER} readout={countReadout} aria-label="Conversations" className={cn('flex flex-col', className)} diff --git a/src/components/fairway/pages/coachhelm/AskWorkspace.tsx b/src/components/fairway/pages/coachhelm/AskWorkspace.tsx index ff1e2843f..98f283e59 100644 --- a/src/components/fairway/pages/coachhelm/AskWorkspace.tsx +++ b/src/components/fairway/pages/coachhelm/AskWorkspace.tsx @@ -38,10 +38,7 @@ import * as React from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { cn } from '@/lib/utils'; import type { ChatConversation, ChatMessage } from '@/lib/coachhelm/v3/chat/types'; -import { - CoachHelmShell, - type CoachHelmCrumb, -} from './CoachHelmShell'; +import { CoachHelmShell } from './CoachHelmShell'; import { useCoachChatSend } from './useCoachChatSend'; import { AskConversationRail, @@ -169,13 +166,14 @@ export function AskWorkspace({ // Resolve the open thread's origin for the thread-pane context header. const activeOrigin = conversationId ? originById?.[conversationId] : undefined; - // Shell breadcrumb leaf: CoachHelm > Ask > (when one is open). - const activeTitle = conversationId - ? conversations.find((c) => c.id === conversationId)?.title?.trim() - : undefined; - const breadcrumbs: CoachHelmCrumb[] | undefined = activeTitle - ? [{ label: activeTitle }] - : undefined; + // NOTE: this used to also pass a single-crumb `breadcrumbs={[{ label: activeTitle }]}` + // leaf (the open thread's title) to CoachHelmShell. With only one crumb and no + // href, CoachHelmShell's breadcrumb nav renders it with none of its usual + // affordances (no separator chevron, no linked ancestor) — just the thread's + // title as a bare span sitting between the masthead and the sub-nav tab bar, + // reading as a stray unstyled echo of whatever question opened the thread + // rather than a real breadcrumb trail. The rail already highlights the open + // thread, so it carried no navigational value — dropped rather than restyled. const description = conversations.length === 0 @@ -192,7 +190,6 @@ export function AskWorkspace({ signalCount={signalCount} title="Ask CoachHelm" description={description} - breadcrumbs={breadcrumbs} className={className} > {/* ── The two-pane inbox: a conversation rail + a thread pane, both flat diff --git a/src/components/fairway/pages/rounds/FairwayRoundCard.tsx b/src/components/fairway/pages/rounds/FairwayRoundCard.tsx index a32c0410f..d476d4929 100644 --- a/src/components/fairway/pages/rounds/FairwayRoundCard.tsx +++ b/src/components/fairway/pages/rounds/FairwayRoundCard.tsx @@ -102,7 +102,12 @@ export function FairwayRoundCard({ round, isBestOfPeriod, userRole }: FairwayRou const hasPutts = round.total_putts !== null; const hasAnyMicroStat = hasPutts || fir !== null || gir !== null; - const city = [round.course_city, round.course_state].filter(Boolean).join(', '); + // A bare state code with no city ("Va") reads as a stray, unlabeled + // fragment — only render a location when there's an actual city to anchor + // it (course_state alone is dropped, not shown bare). + const city = round.course_city + ? [round.course_city, round.course_state].filter(Boolean).join(', ') + : null; return ( - {holesPlayed}h + {holesPlayed} {holesPlayed === 1 ? 'hole' : 'holes'} diff --git a/src/components/fairway/pages/rounds/FairwayRoundDetail.tsx b/src/components/fairway/pages/rounds/FairwayRoundDetail.tsx index a27fede5d..6dc529f31 100644 --- a/src/components/fairway/pages/rounds/FairwayRoundDetail.tsx +++ b/src/components/fairway/pages/rounds/FairwayRoundDetail.tsx @@ -605,6 +605,9 @@ function ScorecardNine({ const scoreTotal = total ?? holes.reduce((s, h) => s + (finite(h.score) ?? 0), 0); const puttTotal = holes.reduce((s, h) => s + (finite(h.putts) ?? 0), 0); + // Golf convention: the front nine's total column reads "Out", the back + // nine's reads "In" — both nines were previously hard-coded to "Out". + const totalColumnLabel = label === 'Front' ? 'Out' : 'In'; return (
@@ -650,7 +653,7 @@ function ScorecardNine({ {holes.map((h) => ( {h.hole_number} ))} - Out + {totalColumnLabel} diff --git a/src/components/fairway/pages/rounds/FairwayRoundRow.tsx b/src/components/fairway/pages/rounds/FairwayRoundRow.tsx index f891f8626..a29536ce4 100644 --- a/src/components/fairway/pages/rounds/FairwayRoundRow.tsx +++ b/src/components/fairway/pages/rounds/FairwayRoundRow.tsx @@ -67,7 +67,12 @@ export function FairwayRoundRow({ round, isBestOfPeriod, userRole }: FairwayRoun const putts = round.total_putts; const hasAnyMicroStat = putts !== null || fir !== null || gir !== null; - const city = [round.course_city, round.course_state].filter(Boolean).join(', '); + // A bare state code with no city ("Va") reads as a stray, unlabeled + // fragment next to the type chip — only render a location when there's an + // actual city to anchor it (course_state alone is dropped, not shown bare). + const city = round.course_city + ? [round.course_city, round.course_state].filter(Boolean).join(', ') + : null; return ( {city && {city}} - · {holesPlayed}h + + · {holesPlayed} {holesPlayed === 1 ? 'hole' : 'holes'} +
{/* Mobile-only condensed stat line — the quick stats are hidden on phones diff --git a/src/components/fairway/pages/rounds/FairwayUnfinishedBanner.tsx b/src/components/fairway/pages/rounds/FairwayUnfinishedBanner.tsx index fba7cbce4..338898399 100644 --- a/src/components/fairway/pages/rounds/FairwayUnfinishedBanner.tsx +++ b/src/components/fairway/pages/rounds/FairwayUnfinishedBanner.tsx @@ -103,7 +103,12 @@ function UnfinishedRow({ const holesTarget = round.holes_played ?? 18; const currentHole = round.current_hole ?? 0; const isSetup = !currentHole; - const city = [round.course_city, round.course_state].filter(Boolean).join(', '); + // A bare state code with no city ("Va") reads as a stray, unlabeled + // fragment — only render a location when there's an actual city to anchor + // it (course_state alone is dropped, not shown bare). + const city = round.course_city + ? [round.course_city, round.course_state].filter(Boolean).join(', ') + : null; const timeAgo = relativeTime(round); const handleContinue = () => { diff --git a/src/lib/baseball/read-models/__tests__/command-center.test.ts b/src/lib/baseball/read-models/__tests__/command-center.test.ts index 9fc757524..f9fe6d648 100644 --- a/src/lib/baseball/read-models/__tests__/command-center.test.ts +++ b/src/lib/baseball/read-models/__tests__/command-center.test.ts @@ -318,6 +318,14 @@ describe('getCommandCenter — game-context via the #379 shared legacy adapter', expect(pulse!.careerAvg).toBeCloseTo(0.318, 3); expect(pulse!.recentTrend).toBe('improving'); expect(pulse!.totalSessions).toBe(12); + // "On the Record" (summary.playersWithData) is the OFFICIAL-record count + // (Stats Center's definition), not the broader box-score+practice + // `rosterPulse` — this player has legacy/practice sessions only (no + // box-score games), so `pulse.noData` is false but the KPI is honestly + // 0. Before this fix, summary.playersWithData was derived from + // rosterPulse and would have read 1 here, drifting from Stats Center's + // own "0 players with data" for the identical roster. + expect(result.summary.playersWithData).toBe(0); }); it('no-data: a player with neither legacy nor box-score rows is honestly no-data, never a fabricated average', async () => { @@ -366,6 +374,9 @@ describe('getCommandCenter — game-context via the #379 shared legacy adapter', // recentTrend has no canonical replacement yet — the permanent legacy // carve-out still surfaces it even for a box-score-sourced player. expect(pulse!.recentTrend).toBe('stable'); + // This player DOES have an official box-score game, so "On the Record" + // agrees with Stats Center's own count for the identical roster. + expect(result.summary.playersWithData).toBe(1); }); it('pitching-only box-score data this season must NOT mask a real legacy career_avg fallback', async () => { diff --git a/src/lib/baseball/read-models/command-center.ts b/src/lib/baseball/read-models/command-center.ts index 68f201ce4..7d3d5370f 100644 --- a/src/lib/baseball/read-models/command-center.ts +++ b/src/lib/baseball/read-models/command-center.ts @@ -612,7 +612,19 @@ export async function getCommandCenter( } } - const playersWithData = rosterPulse.filter((p) => !p.noData).length; + // "On the Record" is the SAME figure as Stats Center's own KPI — both now + // read getStatsCenter()'s summary rather than each deriving its own count. + // Previously this counted `rosterPulse`'s broader box-score+practice + // `totalSessions`, so a team with only practice sessions logged (zero + // official games) showed a nonzero Command Center figure against Stats + // Center's honest 0 — the two surfaces drifted on what "on the record" + // means. `getStatsCenter()`'s `noData` is officially-recorded games/ + // box-score events only (batting/pitching/catching/fielding/baserunning), + // which is what "on the record" is meant to convey; that's the one + // definition both surfaces now share. + const playersWithData = statsCenterModel.authorized + ? statsCenterModel.summary.playersWithData + : 0; const criticalRisks = riskFeed.filter((r) => r.severity === 'critical').length; return {