Skip to content

fix(coachhelm): close insight-staleness engine races (#920) - #925

Closed
njrini99-code wants to merge 1 commit into
mainfrom
fix/insight-staleness-engine
Closed

fix(coachhelm): close insight-staleness engine races (#920)#925
njrini99-code wants to merge 1 commit into
mainfrom
fix/insight-staleness-engine

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

Problem

Four independent bugs in the round-submit → CoachHelm insight pipeline were letting insights go silently stale or misleading:

  1. Race: submitGolfRoundComprehensive (src/app/golf/actions/golf.ts) registered TWO independent after() callbacks — one for the stats-cache refresh (invalidateOnRoundComplete) and one for the CoachHelm trigger (postRoundTrigger). after() callbacks run concurrently, not in registration order, so the engine could read golf_player_stats_cache before the cache refresh finished writing it, producing insights/predictions off stale data.
  2. Roster-sweep skip: src/app/api/cron/coachhelm-roster-sweep/route.ts skipped a player whenever ANY of their rounds had coachhelm_analyzed_at set within a 12h window — even if that was an OLDER round and their genuinely most-recent completed round was still unanalyzed (e.g. an earlier round got re-analyzed by the safety-net cron inside that same 12h). A player who played again shortly after such a re-analysis would be silently skipped by the nightly sweep.
  3. Silent posture invisibility: alert_posture='silent' (src/app/golf/actions/insights.ts) resolves the confidence gate to Infinity, silently blocking every insight for that player — with zero signal anywhere (log or UI) that this happened.
  4. Fake timestamp: the Brief's "updated" freshness label (FairwayBrief.tsx) rendered golf_rounds.round_date (a DATE column with no time component) through new Date(dateOnly).toISOString() (parses as UTC midnight) then a viewer-timezone toLocaleString with hour/minute — fabricating a specific clock time ("Jun 8, 8:00 PM") the round never had, and for viewers west of UTC, silently shifting the calendar date itself back a day.

Fix

  1. Chained the two after() callbacks into one: invalidateOnRoundComplete is fully awaited before postRoundTrigger runs, inside a single after() registration.
  2. Replaced the 12h-window bulk query with a per-player "most recent completed round" check (order('round_date', desc).order('created_at', desc).limit(1), fail-open on lookup error). Skip only when THAT round's coachhelm_analyzed_at is set.
  3. (a) Added a logServerEvent('...alert_posture=silent...', {..., skipSentry: true}, 'info') call, once per run, right where the confidence threshold goes infinite. (b) Threaded a silentPostureByPlayer map (from the same loadCoachIntents(coach.id) the Roster page already loads — no new query pattern) into PlayersGridView's roster row (nameAddon slot on PlayerIdentity), showing an "Insights muted" badge for players with silent posture on the CoachHelm Players tab.
  4. team-category-insights.ts now passes the plain 'YYYY-MM-DD' round_date straight through (no fabricated .toISOString()); FairwayBrief.formatAnalyzed anchors both the parse (${dateOnly}T00:00:00Z) and the format (timeZone: 'UTC') in UTC and drops hour/minute — renders "Jun 8", never a clock time, and is immune to the viewer's local timezone.

Tests

Added 3 regression test files, each verified to fail against the pre-fix code and pass against the fix (confirmed by manually reverting each fix locally, re-running, and restoring):

  • src/app/golf/actions/__tests__/golf-round-submit-after-chain.test.ts — drives submitGolfRoundComprehensive through a fake Supabase client + mocked RPC, and asserts invalidateOnRoundComplete always completes before postRoundTrigger runs (order array + real macrotask delay to make the race deterministic to reproduce under the old code).
  • src/test/api/cron/coachhelm-roster-sweep.test.ts — three-player fixture reproducing the exact bug shape: a player whose latest round is unanalyzed but an EARLIER round was recently analyzed must NOT be skipped; a player whose only round is analyzed (regardless of age) must be skipped.
  • src/components/fairway/pages/coachhelm/FairwayBrief.formatAnalyzed.test.ts — asserts date-only output, no time-of-day, and TZ-stubbed (vi.stubEnv('TZ', ...)) tests proving immunity to viewer timezone in both directions.

Fix 3 (log line + UI badge) doesn't have a natural narrow unit-test seam in the existing layout (deeply embedded in triggerPlayerInsightsAfterRoundImpl's engine call and a presentational roster-row prop) — verified via tsc/eslint and manual code read instead, per the task's "where the repo's test layout allows" scoping.

Gates

  • npx tsc --noEmit -p tsconfig.json — clean
  • npx eslint <all changed files> — clean
  • npx vitest run --project=unit across src/app/golf/actions/__tests__, src/test/api/cron, src/components/fairway/pages/coachhelm55 files / 492 tests passed

Scope notes

  • Did NOT touch vercel.json cron schedules (separate PR per task).
  • Did NOT touch any CRM code.
  • Diff: 10 files (7 modified, 3 new test files).

Fixes #920

🤖 Generated with Claude Code

Four fixes in the post-round-to-insight pipeline that were letting
CoachHelm insights go stale silently:

1. RACE — round submit registered two independent after() callbacks
   (stats-cache refresh + postRoundTrigger). after() callbacks run
   concurrently, not in registration order, so the engine could read
   golf_player_stats_cache before the refresh finished writing it.
   Chained into a single after() that awaits the cache refresh THEN
   runs postRoundTrigger.

2. ROSTER-SWEEP SKIP — the nightly sweep skipped a player whenever ANY
   of their rounds was analyzed within a 12h window, even if their
   genuinely most-recent round was still unanalyzed (e.g. an older
   round got re-analyzed by the safety-net cron inside that window).
   Now skips a player only when their MOST RECENT completed round has
   coachhelm_analyzed_at set — sweeps whenever the latest round is
   unanalyzed, regardless of when some earlier round was touched.

3. SILENT POSTURE VISIBILITY — alert_posture='silent' maps to an
   infinite confidence threshold, silently blocking every insight for
   that player with no signal anywhere. Kept the behavior, surfaced it:
   (a) logServerEvent (info, skipSentry) once per run when the gate
   blocks; (b) the CoachHelm Players-tab roster row now shows an
   "Insights muted" indicator for players with silent posture (reads
   the same golf_coach_player_intent the Roster page already loads via
   loadCoachIntents — no new query pattern).

4. FAKE TIMESTAMP — the Brief's "updated" label rendered
   golf_rounds.round_date (a DATE column, no time component) through
   `new Date(dateOnly).toISOString()` then a viewer-timezone
   toLocaleString with hour/minute — fabricating a specific clock time
   ("Jun 8, 8:00 PM") the round never had, and for viewers west of UTC,
   silently shifting the calendar date itself back a day. Now formats
   as a DATE ONLY ("Jun 8"), anchoring both parse and format in UTC so
   every viewer sees the same calendar day.

Gates: tsc clean, eslint clean on all changed files, and the full
unit-test slate under the touched directories (55 files / 492 tests)
passes. Added regression tests for 1, 2 and 4 — each one verified to
FAIL against the pre-fix code and PASS against the fix.

Fixes #920

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdviLDsAg2YYJ8adsM6fg
@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@supabase

supabase Bot commented Jul 17, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project qmnssrrolpinvwjjnufo because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
helmv3 Ignored Ignored Jul 17, 2026 9:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@njrini99-code, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 38d48159-12b8-4275-abae-daecbe4e39fd

📥 Commits

Reviewing files that changed from the base of the PR and between 6d25e44 and 38a33a9.

📒 Files selected for processing (10)
  • src/app/api/cron/coachhelm-roster-sweep/route.ts
  • src/app/golf/(dashboard)/dashboard/development/page.tsx
  • src/app/golf/actions/__tests__/golf-round-submit-after-chain.test.ts
  • src/app/golf/actions/golf.ts
  • src/app/golf/actions/insights.ts
  • src/app/golf/actions/team-category-insights.ts
  • src/components/fairway/pages/coachhelm/FairwayBrief.formatAnalyzed.test.ts
  • src/components/fairway/pages/coachhelm/FairwayBrief.tsx
  • src/components/fairway/pages/coachhelm/PlayersGridView.tsx
  • src/test/api/cron/coachhelm-roster-sweep.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/insight-staleness-engine
  • 🛠️ helm safety pass
  • 🛠️ dashboard ux pass
  • 🛠️ rls test pass

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@njrini99-code

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@njrini99-code

Copy link
Copy Markdown
Owner Author

🤖 Mission Control — PR summary

What it changes: Closes four races in the post-round → insight pipeline that let CoachHelm insights go stale silently (#920):

  1. Race — round submit registered two independent after() callbacks (stats-cache refresh + postRoundTrigger) which run concurrently, so the engine could read golf_player_stats_cache before the refresh wrote it. Now chained into one after() that awaits the refresh then runs the trigger.
  2. Roster-sweep skip — the nightly sweep skipped a player if any round was analyzed within 12h, even when their genuinely most-recent round was still unanalyzed. Now keys on the most-recent completed round's coachhelm_analyzed_at.
    3–4. Silent position/analysis gaps in the same path.

Risk / areas: golf CoachHelm engine, roster-sweep cron, after() ordering semantics.

Watch: after() await behavior under Fluid Compute; the sweep now does strictly more work — confirm no runaway; idempotency of re-analysis.

CI: ✅ green so far — 34 checks passing, 4 pending, 0 failing; mergeable state BLOCKED on required review (no CI failure). Awaiting review.

@njrini99-code

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

njrini99-code pushed a commit that referenced this pull request Jul 17, 2026
…opment page import conflict

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMdviLDsAg2YYJ8adsM6fg
@njrini99-code

Copy link
Copy Markdown
Owner Author

Superseded — landed on main inside merge train #938 (commit 6ecede6). Branch kept.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QA] Why insights go stale — five concrete causes, ranked (safety-net cron weekly, silent posture, legacy dedup, cache race, sweep skip)

1 participant