Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,14 @@ export default async function PlayerCoachHelmPage() {
return <ErrorState error={err instanceof Error ? err.message : 'Failed to load dashboard data'} />;
}

// 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([
Expand 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
Expand Down Expand Up @@ -190,6 +197,7 @@ export default async function PlayerCoachHelmPage() {
profileData={profileData}
trendData={trendData}
shotData={shotData}
v3EmptyCodes={v3EmptyCodes}
topInsight={topInsight}
secondaryInsights={secondaryInsights}
standingByMetric={standingByMetric}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -160,6 +161,13 @@ export interface FairwayPlayerCoachHelmProps {
trendData?: Record<string, any> | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
shotData?: Record<string, any> | 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). */
Expand Down Expand Up @@ -267,6 +275,7 @@ export function FairwayPlayerCoachHelm({
profileData,
trendData,
shotData,
v3EmptyCodes = {},
topInsight = null,
secondaryInsights = [],
standingByMetric = {},
Expand Down Expand Up @@ -621,9 +630,20 @@ export function FairwayPlayerCoachHelm({
profileData == null &&
trendData == null ? (
<Surface padding="md" className="mb-6">
{/* 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". */}
<InsufficientData
title="Not enough rounds yet"
description="Log a round and your driving, greens, and putting will fill in here."
title={
expectedEmptyStateCopy(v3EmptyCodes.profile ?? v3EmptyCodes.shots)?.title ??
'Not enough rounds yet'
}
description={
expectedEmptyStateCopy(v3EmptyCodes.profile ?? v3EmptyCodes.shots)
?.description ??
'Log a round and your driving, greens, and putting will fill in here.'
}
unit="rounds"
current={shot?.roundsAnalyzed ?? 0}
required={OVERVIEW_MIN_ROUNDS}
Expand All @@ -643,8 +663,11 @@ export function FairwayPlayerCoachHelm({
) : (
<Surface padding="md">
<InsufficientData
title="Game profile warming up"
description="Your composite rating builds off the rounds you log — a few more and it fills in."
title={expectedEmptyStateCopy(v3EmptyCodes.profile)?.title ?? 'Game profile warming up'}
description={
expectedEmptyStateCopy(v3EmptyCodes.profile)?.description ??
'Your composite rating builds off the rounds you log — a few more and it fills in.'
}
unit="rounds"
/>
</Surface>
Expand All @@ -658,9 +681,13 @@ export function FairwayPlayerCoachHelm({
) : (
<Surface padding="md">
<InsufficientData
title="Trends warming up"
description="Log a couple more rounds and your performance trend lines fill in."
title={expectedEmptyStateCopy(v3EmptyCodes.trend)?.title ?? 'Trends warming up'}
description={
expectedEmptyStateCopy(v3EmptyCodes.trend)?.description ??
'Log a couple more rounds and your performance trend lines fill in.'
}
unit="rounds"
required={expectedEmptyStateCopy(v3EmptyCodes.trend)?.required}
/>
</Surface>
)}
Expand Down
37 changes: 11 additions & 26 deletions src/lib/admin/observe-action-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof logServerError>[1]> & {
Expand Down Expand Up @@ -39,33 +40,17 @@ const EXPECTED_SOFT_FAILURE_CODES: ReadonlySet<string> = 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<string> = 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,
Expand Down
42 changes: 42 additions & 0 deletions src/lib/view-state/__tests__/expected-empty-states.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
90 changes: 90 additions & 0 deletions src/lib/view-state/expected-empty-states.ts
Original file line number Diff line number Diff line change
@@ -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 <InsufficientData />) 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<string, ExpectedEmptyStateDef>;

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<string> = 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;
}
Loading