feat: live OB / stroke-and-distance (#839) - #871
Conversation
Preserve the live ob/penalty flags at end-of-hole save instead of hardcoding false. The remote fallback path (used when a shot's local row was purged mid-hole) previously selected neither column, so a restart-mid-hole save would still wipe ob even with the local-path fix in place; both select() calls and the remoteByNum map now carry ob and penalty alongside id/aim.
…ticker OB-accurate live (#839)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Review — feat: live OB / stroke-and-distance (#839)Concerns
Nits
Suggestions (out of scope for this PR)
I did not line-diff Looked good: Generated by Claude Code |
Closes #839. Also fixes #797.
Makes out-of-bounds work as a live action. Before this,
shots.obwas true on 2 rows out of 886 in production — not because players didn't hit it OB, but because there was no way to say so: the only control lived in the end-of-hole review sheet behind a "+ result" chip, andsaveHoleSummaryhardcoded the flag back tofalseon every save (#797). The flag wasn't merely unset — it was actively erased.The live affordance
A chip in the
PLACE_BALLchrome, beside "⛳ On the green":One tap. It sets
obon the last logged shot, snaps the ball back to that shot's origin, and adds the penalty stroke.This is cheap because of a property of the existing capture flow:
persistShotwrites the shot row onconfirmAim/skipAim— before the ball is struck — so when a shot flies OB the player is still standing at that shot's origin. Stroke-and-distance placement costs nothing. It also stays correct for a lost ball: walk up, fail to find it, walk back, and the chip is still valid because no new shot was logged in between.Online-first, like its siblings (
delete_shot,projectShotMove): flush pending →shots.update→ fail visibly on a 0-row response.Strokes gained: the rule, not an inference
Two earlier designs derived the −2 from the next row happening to share coordinates with the OB shot's origin. That's a coincidence, not a rule, and it produced a sign-inverted result: with no following row,
isLastShotis positional,endExpectedfell to 0, and an OB booked a large positive SG. Reachable by normal use — "Skip all, just track location" is always on screen, so a player who drops without logging the recovery hits exactly that shape.Stating the rule makes the terms cancel, so
−2is exact and table-independent, and it closes three things at once: the sign flip, web's inexact −2 (two taps never match to the yard), and a silent coupling onoff_tee/approachsharing a baseline table.Numbering: struck shots + a badge
Markers stay
1, 2, 3, 4. The OB shot wears a red badge; the re-hit is the next number from the same origin; the scorecard shows 5.The issue asks for the re-hit "marked as 4th" — this deliberately differs. Two reasons. A marker in this app is a shot's START, and a penalty stroke has no start→end vector, so numbering it is a category error in our own model. And renumbering would render
1, 2, 4with a visible gap unless a penalty pip filled it — which is the abandoned design's visual, reintroduced. The stroke count is right everywhere it matters; only the marker label differs.Score:
struck rows + OB rowsOne
obCounthelper in@oga/core, applied at every site where a row count becomes a score — 8 on mobile, 2 on web. One of the mobile sites was missed by the plan and found only because the implementer searched by derivation shape (hs.score = shotCount) rather than write shape (score:).Why this isn't the trap that killed the first design. That one added a phantom row ~10 consumers had to include in some pipelines and exclude from others; getting it wrong deleted the wrong shot or erased SG silently. This adds one additive term from a boolean every row already carries — every consumer still sees exactly the struck rows it always saw, and the failure mode is a visible off-by-one on a score, not silent structural corruption.
Rejected: a DB trigger recomputing score from rows. It would own the invariant in one place but destroy the end-of-hole ticker's manual override and break the 767 production holes that carry a score with no shot rows at all.
Also fixed
hole_scores.girwas inflated by the hole's OB count.inferGirreadsshot_numberas a stroke count, and preserving struck-shot numbering makes those differ. This is persisted from three sites, so it was corrupting GIR, not just displaying it wrong.approachByDistanceinstats.tsis a hand-rolled duplicate of the SG inner loop powering the Stats approach chart. It hadpenaltyAdjustforobbut not the stroke-and-distance rule, so a no-recovery OB booked ≈ +1 there whilecalculateRoundSGreported −2 — two surfaces disagreeing by ~3 strokes, the wrong one positive. Caught only by the whole-branch review; no task-scoped reviewer had reason to open that file.saveReviewedHolehardcodedob: false, so editing a round on the web wiped it. Web's save is delete-then-recreate, so preservation uses a snapshot guarded onactiveHoleShots.length === rows.length— positional keying is only sound when the position space is unchanged, and on a mismatch it deliberately drops the flag rather than moving it to a different shot.Migration
0054_delete_shot_ob.sql— deleting anobrow drops 2 strokes, not 1, since the penalty has no row of its own. A faithfulcreate or replaceof0046: three deltas total, every auth/ownership guard, the re-tally, and the deferrable-constraint renumbering preserved verbatim.Sequencing:
0053belongs to open PR #868 and is not on this branch. Production is still at0046, and0047–0052are ondevawaiting the next release — apply in order.Unapplied and unexecuted. No local Postgres and
oga-devis paused, so this SQL has never been parsed by a Postgres. Apply to dev first.Known follow-ups (not fixed here)
getShotCategorygatesoff_teeonshotNumber === 1, so an OB re-tee is categorizedapproach. Pre-existing, but this feature is what makes it reachable.ShotEntryModalsets/clearsobwithout touchinghole_scores.score, so "score = struck + OB" is not an invariant on the web scorecard path.Verification
pnpm test658 core (+7 new) · web 27 · supabase 7 — all green.pnpm typecheck,pnpm --filter @oga/web build,apps/mobile npm run typecheckclean.Every task got an independent spec + quality review; the branch then got a whole-branch review that returned one Critical and three Important, all fixed and re-verified. Migration
0054is the one file whose task-scoped reviewer died mid-run — it was checked by hand and then given a genuine pass in the whole-branch review.Behaviour is device/browser-only and unverified. Nothing here has run on a phone. The load-bearing QA checks: the ball snapping and staying put under GPS, double-tap charging exactly one stroke, delete dropping 2 through the RPC, GIR on an OB hole, and a mobile→web round-trip preserving the flag.