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; +}