Skip to content
Merged
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
62 changes: 58 additions & 4 deletions src/app/golf/actions/coachhelm-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ interface ActionSuccess<T> {
interface ActionError {
success: false;
error: string;
/**
* Stable machine code for the soft-failure observer (observe-action-result):
* expected empty states carry a code from EXPECTED_EMPTY_STATE_CODES so a
* brand-new player browsing CoachHelm doesn't register as a prod error.
*/
code?: string;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

type ActionResult<T> = ActionSuccess<T> | ActionError;
Expand Down Expand Up @@ -291,7 +297,24 @@ async function getPlayerProfileImpl(
}

if (!roundsData || roundsData.length === 0) {
return { success: false, error: 'No completed rounds found for this player' };
// The main query filters out completed rounds with a NULL total_score,
// so an empty result is ambiguous: brand-new player (expected empty
// state) vs. completed rounds that all lack scores (data-quality
// problem). Only the former may carry the globally-silenced
// `no_completed_rounds` code — see EXPECTED_EMPTY_STATE_CODES contract.
const { count: completedCount, error: countError } = await supabase
.from('golf_rounds')
.select('id', { count: 'exact', head: true })
.eq('player_id', playerId)
.eq('status', 'completed');
// A failed count must not fall through to the globally-silenced code.
if (countError) {
return { success: false, error: 'Failed to fetch round data' };
}
if ((completedCount ?? 0) > 0) {
return { success: false, error: 'Completed rounds exist but are missing score data' };
}
return { success: false, error: 'No completed rounds found for this player', code: 'no_completed_rounds' };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Build baseline from round data
Expand Down Expand Up @@ -591,7 +614,23 @@ async function getPlayerTrendAnalysisImpl(
}

Comment on lines 614 to 615

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing Score Guard

insufficient_rounds is now treated as an expected empty state for every observed action. This branch counts only completed rounds where score_to_par is already populated, so a player with 3+ completed rounds whose score-to-par values failed to populate can still return insufficient_rounds. That logs as info with Sentry skipped instead of surfacing the scoring-data failure. The same check is needed before the what-if branch returns the same code.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/golf/actions/coachhelm-data.ts
Line: 610-611

Comment:
**Missing Score Guard**

`insufficient_rounds` is now treated as an expected empty state for every observed action. This branch counts only completed rounds where `score_to_par` is already populated, so a player with 3+ completed rounds whose score-to-par values failed to populate can still return `insufficient_rounds`. That logs as info with Sentry skipped instead of surfacing the scoring-data failure. The same check is needed before the what-if branch returns the same code.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a35da6f — both insufficient_rounds sites (trend analysis and the what-if scenario path) now run the same unfiltered completed-round head-count as the profile fix: the globally-silenced code only attaches when the player truly has fewer than 3 completed rounds; 3+ completed rounds lacking score data returns an uncoded data-quality error that stays visible to Sentry. Follow-up 8864372 also guards all three count queries' own error, so a failed count can't fall through to the silenced path either.


Generated by Claude Code

if (!roundsData || roundsData.length < 3) {
return { success: false, error: 'Need at least 3 completed rounds for trend analysis' };
// Same ambiguity as getPlayerProfile: the main query drops completed
// rounds with a NULL score_to_par, so "fewer than 3 usable rounds" may
// mean 3+ completed rounds whose scores never populated — a data-quality
// failure that must not carry the globally-silenced code.
const { count: completedCount, error: countError } = await supabase
.from('golf_rounds')
.select('id', { count: 'exact', head: true })
.eq('player_id', playerId)
.eq('status', 'completed');
// A failed count must not fall through to the globally-silenced code.
if (countError) {
return { success: false, error: 'Failed to fetch round data' };
}
if ((completedCount ?? 0) >= 3) {
return { success: false, error: 'Completed rounds exist but lack score data for trend analysis' };
}
return { success: false, error: 'Need at least 3 completed rounds for trend analysis', code: 'insufficient_rounds' };
}

// Extract score_to_par values (chronological, oldest first)
Expand Down Expand Up @@ -714,7 +753,7 @@ async function getPlayerShotContextImpl(
}

if (!roundsData || roundsData.length === 0) {
return { success: false, error: 'No completed rounds found in the specified period' };
return { success: false, error: 'No completed rounds found in the specified period', code: 'no_rounds_in_period' };
}

const roundIds = roundsData.map((r) => r.id);
Expand Down Expand Up @@ -1027,7 +1066,22 @@ async function getPlayerWhatIfImpl(
const scores = (roundsData ?? []).map((r) => r.score_to_par ?? 0);

if (scores.length < 3) {
return { success: false, error: 'Need at least 3 completed rounds for scenario analysis' };
// Mirror the trend-analysis guard: the query above drops NULL
// score_to_par rounds, so only a true shortage of completed rounds may
// carry the globally-silenced code.
const { count: completedCount, error: countError } = await supabase
.from('golf_rounds')
.select('id', { count: 'exact', head: true })
.eq('player_id', playerId)
.eq('status', 'completed');
// A failed count must not fall through to the globally-silenced code.
if (countError) {
return { success: false, error: 'Failed to fetch scoring data' };
}
if ((completedCount ?? 0) >= 3) {
return { success: false, error: 'Completed rounds exist but lack score data for scenario analysis' };
}
return { success: false, error: 'Need at least 3 completed rounds for scenario analysis', code: 'insufficient_rounds' };
}

const mean = scores.reduce((s, v) => s + v, 0) / scores.length;
Expand Down
25 changes: 25 additions & 0 deletions src/lib/admin/__tests__/observe-action-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ describe('observe-action-result', () => {
expect(isExpectedEmptyStateCode(null)).toBe(false);
});

it('classifies the coachhelm-data empty-state codes as expected empty states', () => {
expect(isExpectedEmptyStateCode('no_rounds_in_period')).toBe(true);
expect(isExpectedEmptyStateCode('no_completed_rounds')).toBe(true);
expect(isExpectedEmptyStateCode('insufficient_rounds')).toBe(true);
});

it.each([
['no_rounds_in_period', 'No completed rounds found in the specified period'],
['no_completed_rounds', 'No completed rounds found for this player'],
['insufficient_rounds', 'Need at least 3 completed rounds for trend analysis'],
])('logs %s via logServerEvent at info severity with skipSentry', (code, error) => {
observeActionSoftFailure(
{ success: false, error, code },
{ action: 'getPlayerProfile', sport: 'golf', feature: 'intelligence_dashboard', source: 'server_action' },
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
);

expect(mocks.logServerError).not.toHaveBeenCalled();
expect(mocks.logServerEvent).toHaveBeenCalledTimes(1);
const infoCall = mocks.logServerEvent.mock.calls[0] as
| [string, Record<string, unknown> | undefined, 'info' | 'warning' | 'error' | 'critical']
| undefined;
expect(infoCall?.[2]).toBe('info');
expect(infoCall?.[1]).toMatchObject({ skipSentry: true });
});

it('logs unexpected soft failures at error severity', () => {
observeActionSoftFailure(
{ success: false, error: 'Could not save document' },
Expand Down
15 changes: 13 additions & 2 deletions src/lib/admin/observe-action-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,23 @@ const EXPECTED_SOFT_FAILURE_CODES: ReadonlySet<string> = new Set([
* 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.
*/
const EXPECTED_EMPTY_STATE_CODES: ReadonlySet<string> = new Set([
'engine_no_recent_rounds',
// getPlayerShotAnalytics: player has no completed rounds in the selected
// lookback window — the analytics page's normal brand-new-player state.
// 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',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]);

export function extractActionSoftFailure(
Expand Down
Loading