diff --git a/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx b/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx
index 1ea2a01e7..a6b771a6e 100644
--- a/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx
+++ b/src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx
@@ -137,10 +137,14 @@ export default async function PlayerCoachHelmPage() {
return ;
}
- // Fetch additional V3 data (optional — new components)
+ // Fetch additional V3 data (optional — new components). Expected empty-state
+ // codes (see src/lib/view-state/expected-empty-states.ts) are preserved so
+ // the client empty surfaces can render the registry's copy for the ACTUAL
+ // reason data is absent, instead of a generic "warming up" line.
let profileData = null;
let trendData = null;
let shotData = null;
+ const v3EmptyCodes: { profile?: string | null; trend?: string | null; shots?: string | null } = {};
try {
const { getPlayerProfile, getPlayerTrendAnalysis, getPlayerShotContext } = await import('@/app/golf/actions/coachhelm-data');
const [profileResult, trendResult, shotResult] = await Promise.all([
@@ -151,6 +155,9 @@ export default async function PlayerCoachHelmPage() {
profileData = profileResult.success ? profileResult.data : null;
trendData = trendResult.success ? trendResult.data : null;
shotData = shotResult.success ? shotResult.data : null;
+ v3EmptyCodes.profile = profileResult.success ? null : profileResult.code ?? null;
+ v3EmptyCodes.trend = trendResult.success ? null : trendResult.code ?? null;
+ v3EmptyCodes.shots = shotResult.success ? null : shotResult.code ?? null;
} catch { /* V3 actions not yet available — degrade gracefully */ }
// Handle CoachHelm disabled or other errors
@@ -190,6 +197,7 @@ export default async function PlayerCoachHelmPage() {
profileData={profileData}
trendData={trendData}
shotData={shotData}
+ v3EmptyCodes={v3EmptyCodes}
topInsight={topInsight}
secondaryInsights={secondaryInsights}
standingByMetric={standingByMetric}
diff --git a/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx b/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx
index bd7060cb3..0b859e8ea 100644
--- a/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx
+++ b/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx
@@ -65,6 +65,7 @@ import {
} from 'lucide-react';
import { fairwayScope, isThemesEnabled } from '@/lib/redesign/flag';
+import { expectedEmptyStateCopy } from '@/lib/view-state/expected-empty-states';
import {
Button,
InsightCard,
@@ -160,6 +161,13 @@ export interface FairwayPlayerCoachHelmProps {
trendData?: Record | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shotData?: Record | null;
+ /**
+ * Expected empty-state codes for the V3 panels (null/absent = real failure
+ * or no failure). When a code is present, the InsufficientData surfaces
+ * render the shared registry copy for the ACTUAL reason data is absent —
+ * see src/lib/view-state/expected-empty-states.ts.
+ */
+ v3EmptyCodes?: { profile?: string | null; trend?: string | null; shots?: string | null };
/** The single highest-impact evidence-backed insight — the hero. */
topInsight?: EvidenceInsight | null;
/** The rest of the evidence feed (deduped against the hero below). */
@@ -267,6 +275,7 @@ export function FairwayPlayerCoachHelm({
profileData,
trendData,
shotData,
+ v3EmptyCodes = {},
topInsight = null,
secondaryInsights = [],
standingByMetric = {},
@@ -621,9 +630,20 @@ export function FairwayPlayerCoachHelm({
profileData == null &&
trendData == null ? (
+ {/* Priority: the profile code is ALL-TIME ("no rounds yet")
+ while the shot-context code is period-scoped — for a
+ brand-new player the all-time verdict must win, or the
+ first thing they read is "widen the date range". */}
@@ -658,9 +681,13 @@ export function FairwayPlayerCoachHelm({
) : (
)}
diff --git a/src/lib/admin/observe-action-result.ts b/src/lib/admin/observe-action-result.ts
index cc1844cce..e37f4ee92 100644
--- a/src/lib/admin/observe-action-result.ts
+++ b/src/lib/admin/observe-action-result.ts
@@ -2,6 +2,7 @@ import 'server-only';
import { isExpectedAuthNoise } from '@/lib/admin/data/triage';
import { drainCollapsedCount, shouldEmit } from '@/lib/admin/emit-throttle';
+import { EXPECTED_EMPTY_STATE_CODES } from '@/lib/view-state/expected-empty-states';
import { logServerError, logServerEvent } from '@/lib/server-error-logger';
export type ActionSoftFailureContext = NonNullable[1]> & {
@@ -39,33 +40,17 @@ const EXPECTED_SOFT_FAILURE_CODES: ReadonlySet = new Set([
'engine_session_expired',
]);
-/**
- * Genuinely empty, nothing-failed outcomes — there is simply no data yet
- * (e.g. a player with no completed rounds in the lookback window). Distinct
- * from EXPECTED_SOFT_FAILURE_CODES: those are still benign soft *failures*;
- * these were never a failure to begin with. Classified one tier quieter
- * ('info', skipSentry) so they land in job telemetry without counting
- * toward the Errors tab / Sentry — see admin/data/incident-feed.ts (default
- * view excludes only 'info') and admin/data/overview.ts (KPI counts key off
- * severity IN ('error','critical')).
- *
- * CONTRACT: classification is by code GLOBALLY, not per action — a code in
- * this set silences Sentry for every envelope that carries it. Only attach
- * one of these codes to a genuinely-empty outcome; a real failure must never
- * reuse them.
+/*
+ * Genuinely empty, nothing-failed outcomes ('info', skipSentry — one tier
+ * quieter than EXPECTED_SOFT_FAILURE_CODES: those are still benign soft
+ * *failures*; these were never a failure to begin with) are classified via
+ * EXPECTED_EMPTY_STATE_CODES imported from
+ * src/lib/view-state/expected-empty-states.ts — ONE registry shared with the
+ * UI empty-state surfaces, so user-facing copy and telemetry classification
+ * key off the same codes and cannot drift apart. The global-classification
+ * CONTRACT lives there too. Severity plumbing: incident-feed's default view
+ * excludes only 'info'; overview KPIs count severity IN ('error','critical').
*/
-const EXPECTED_EMPTY_STATE_CODES: ReadonlySet = new Set([
- 'engine_no_recent_rounds',
- // getPlayerShotAnalytics / getPlayerShotContext: player has no completed
- // rounds in the selected lookback window — the analytics/CoachHelm pages'
- // normal brand-new-player state.
- 'no_rounds_in_period',
- // getPlayerProfile (coachhelm-data): player has no completed rounds at all.
- 'no_completed_rounds',
- // getPlayerTrendAnalysis / what-if scenarios: fewer than the 3 completed
- // rounds those analyses mathematically require.
- 'insufficient_rounds',
-]);
export function extractActionSoftFailure(
result: unknown,
diff --git a/src/lib/view-state/__tests__/expected-empty-states.test.ts b/src/lib/view-state/__tests__/expected-empty-states.test.ts
new file mode 100644
index 000000000..07e1cbe9c
--- /dev/null
+++ b/src/lib/view-state/__tests__/expected-empty-states.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ EXPECTED_EMPTY_STATES,
+ EXPECTED_EMPTY_STATE_CODES,
+ expectedEmptyStateCopy,
+ isExpectedEmptyStateCode,
+} from '@/lib/view-state/expected-empty-states';
+import { isExpectedEmptyStateCode as observerIsExpectedEmptyStateCode } from '@/lib/admin/observe-action-result';
+
+describe('expected-empty-states registry', () => {
+ it('exposes copy with a next-action description for every code', () => {
+ for (const [code, def] of Object.entries(EXPECTED_EMPTY_STATES)) {
+ expect(def.title.length, `${code} title`).toBeGreaterThan(0);
+ expect(def.description.length, `${code} description`).toBeGreaterThan(0);
+ }
+ });
+
+ it('code set matches the copy map exactly', () => {
+ expect([...EXPECTED_EMPTY_STATE_CODES].sort()).toEqual(
+ Object.keys(EXPECTED_EMPTY_STATES).sort(),
+ );
+ });
+
+ it('guards and copy lookup agree', () => {
+ expect(isExpectedEmptyStateCode('no_completed_rounds')).toBe(true);
+ expect(expectedEmptyStateCopy('no_completed_rounds')?.title).toBe('No rounds yet');
+ expect(isExpectedEmptyStateCode('engine_session_expired')).toBe(false);
+ expect(expectedEmptyStateCopy('engine_session_expired')).toBeNull();
+ expect(expectedEmptyStateCopy(null)).toBeNull();
+ expect(expectedEmptyStateCopy(undefined)).toBeNull();
+ });
+
+ // The whole point of the registry: the observer's telemetry classification
+ // and the UI's copy source key off the SAME codes and cannot drift.
+ it('observer classification agrees with the registry for every code', () => {
+ for (const code of EXPECTED_EMPTY_STATE_CODES) {
+ expect(observerIsExpectedEmptyStateCode(code), code).toBe(true);
+ }
+ expect(observerIsExpectedEmptyStateCode('not_a_registered_code')).toBe(false);
+ });
+});
diff --git a/src/lib/view-state/expected-empty-states.ts b/src/lib/view-state/expected-empty-states.ts
new file mode 100644
index 000000000..46d9a73e7
--- /dev/null
+++ b/src/lib/view-state/expected-empty-states.ts
@@ -0,0 +1,90 @@
+/**
+ * Expected empty-state registry — the ONE source of truth for outcomes that
+ * are "genuinely empty, nothing failed" (a brand-new player, a quiet date
+ * range), shared by BOTH sides of the product:
+ *
+ * • Observability: `observe-action-result.ts` classifies any failure
+ * envelope carrying one of these codes as 'info' + skipSentry, so routine
+ * empty states never reach the Errors tab or Sentry (the #804/#807 class).
+ * • UI: empty-state surfaces (e.g. Fairway ) source
+ * their user-facing copy from the same code, so what the user reads and
+ * what telemetry records can never drift apart.
+ *
+ * CONTRACT (moved here from observe-action-result.ts): classification is by
+ * code GLOBALLY, not per action — a code in this registry silences Sentry for
+ * every envelope that carries it. Only attach one of these codes to a
+ * genuinely-empty outcome; a real failure must never reuse them. Guard the
+ * classification too: a failed count/query must return an UNCODED error, not
+ * fall through to a registry code.
+ *
+ * This module is a dependency LEAF: no imports, safe from both server code
+ * ('server-only' modules may import it) and client components.
+ */
+
+export interface ExpectedEmptyStateDef {
+ /** Calm, honest headline for the empty surface. */
+ title: string;
+ /** Plain-language copy that names the next meaningful action. */
+ description: string;
+ /** The metric/units being counted, e.g. "rounds" — for gap meters. */
+ unit?: string;
+ /** Data points needed before the analysis is trustworthy, if fixed. */
+ required?: number;
+}
+
+export const EXPECTED_EMPTY_STATES = {
+ /**
+ * Engine-side (triggerPlayerInsightsAfterRound roster sweep): player has no
+ * completed rounds in the engine's 90-day lookback.
+ */
+ engine_no_recent_rounds: {
+ title: 'No recent rounds',
+ description: 'Insights populate after the next completed round.',
+ unit: 'rounds',
+ },
+ /**
+ * getPlayerShotAnalytics / getPlayerShotContext: no completed rounds in the
+ * SELECTED lookback window — the normal state for a quiet stretch.
+ */
+ no_rounds_in_period: {
+ title: 'No rounds in this period',
+ description: 'No rounds were played in the selected period — widen the range or log a new round.',
+ unit: 'rounds',
+ },
+ /** getPlayerProfile: player has no completed rounds at all (brand-new). */
+ no_completed_rounds: {
+ title: 'No rounds yet',
+ description: 'Record or import a round to begin performance analysis.',
+ unit: 'rounds',
+ },
+ /**
+ * getPlayerTrendAnalysis / what-if scenarios: fewer than the 3 completed
+ * rounds those analyses mathematically require.
+ */
+ insufficient_rounds: {
+ title: 'Not enough rounds yet',
+ description: 'Trend analysis unlocks at 3 completed rounds — log a couple more and it fills in.',
+ unit: 'rounds',
+ required: 3,
+ },
+} as const satisfies Record;
+
+export type ExpectedEmptyStateCode = keyof typeof EXPECTED_EMPTY_STATES;
+
+/** Stable code set consumed by the soft-failure observer's classification. */
+export const EXPECTED_EMPTY_STATE_CODES: ReadonlySet = new Set(
+ Object.keys(EXPECTED_EMPTY_STATES),
+);
+
+export function isExpectedEmptyStateCode(
+ code: string | null | undefined,
+): code is ExpectedEmptyStateCode {
+ return code != null && EXPECTED_EMPTY_STATE_CODES.has(code);
+}
+
+/** Copy for a code, or null when the code is unknown / not an empty state. */
+export function expectedEmptyStateCopy(
+ code: string | null | undefined,
+): ExpectedEmptyStateDef | null {
+ return isExpectedEmptyStateCode(code) ? EXPECTED_EMPTY_STATES[code] : null;
+}