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 {