From 49771b09ea0dbee480c78966e7a89a34b6f4133e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 03:05:07 +0000 Subject: [PATCH 1/2] feat(shared): one expected-empty-state registry for UI copy + telemetry classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-audit item SH-002 (§12 view-state contract): the empty-state codes introduced in #804/#807 were telemetry-only — EXPECTED_EMPTY_STATE_CODES lived in the soft-failure observer while UI surfaces rendered hardcoded copy with no knowledge of WHY data was absent, so user-facing copy and observability classification could silently drift. New leaf module src/lib/view-state/expected-empty-states.ts owns the codes, the global-classification CONTRACT, and user-facing copy (title + next-action description, optional unit/required gap-meter hints) for all four registered codes. Consumers: - observe-action-result.ts imports the code set (behavior unchanged — full 64-test suite green). - The player CoachHelm route preserves the failure codes instead of collapsing to bare null, and FairwayPlayerCoachHelm's InsufficientData surfaces render registry copy for the ACTUAL absence reason (brand-new player vs quiet period vs <3 rounds), falling back to the existing generic lines when no code is present. A registry test locks the invariant: observer classification and registry codes can never disagree. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WJ1Gzjj9MT3UsxhMoMzSV --- .../(dashboard)/dashboard/coachhelm/page.tsx | 10 ++- .../coachhelm/FairwayPlayerCoachHelm.tsx | 35 ++++++-- src/lib/admin/observe-action-result.ts | 37 +++----- .../__tests__/expected-empty-states.test.ts | 42 +++++++++ src/lib/view-state/expected-empty-states.ts | 90 +++++++++++++++++++ 5 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 src/lib/view-state/__tests__/expected-empty-states.test.ts create mode 100644 src/lib/view-state/expected-empty-states.ts 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..a73d98c98 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 = {}, @@ -622,8 +631,15 @@ export function FairwayPlayerCoachHelm({ trendData == null ? ( @@ -658,9 +677,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; +} From 0c72c1d97f7898509d4da77b1a19ec66a19604e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 03:09:12 +0000 Subject: [PATCH 2/2] fix(golf): overview empty banner prefers all-time code over period-scoped Greptile P2: a brand-new player gets no_completed_rounds (all-time) from the profile call but no_rounds_in_period (period-scoped) from shot context; the banner's shots-first priority showed 'widen the date range' to players who should be told to record their first round. Profile's all-time verdict now wins. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019WJ1Gzjj9MT3UsxhMoMzSV --- .../fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx b/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx index a73d98c98..0b859e8ea 100644 --- a/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx +++ b/src/components/fairway/pages/coachhelm/FairwayPlayerCoachHelm.tsx @@ -630,13 +630,17 @@ 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". */}