Challenge Scoring Semantics

Status

Decided (MVP): Option B — post-challenge derived best. Option A (dedicated challenge attempts) is recorded as an explicit future product decision, not in MVP scope.

Context

A challenge pits invited participants against each other on a single drill. The question is which of a participant’s scores counts toward their standing.

The legacy FlutterFlow app modeled a challenge as a dedicated one-off run: the user_challenges table stored a purpose-built result per participant (total_time, total_points, hit_factor, per-zone counts, notes, photo/video, accepted_at, completed_at), and the challenges row had an expires_at deadline and a denormalized drill_name. You explicitly “ran” the challenge once, before the deadline. See Legacy App Analysis.

The new server (apps/server/src/challenge/provider.rs, get_challenge_detail) instead derives each participant’s standing on read, from the generic scores table by drill_id + shooter_id. As originally written it had no time bound, so all-time scores counted — including runs recorded before the challenge was created. Scores carry no challenge_id; the challenge proto has no expires_at, participant completion state, or dedicated score linkage.

This silently diverged from legacy and produced a surprising result: an invited friend who happened to shoot the drill last month would already have a standing without participating, and a participant’s historical PR could “win” a challenge.

Options considered

OptionBehaviorCost
A. Dedicated attempt (legacy)A challenge is an explicit run with its own submitted result, completion state, deadline, and media.Schema + proto + RPC + UI. Score gains challenge_id (or a dedicated submit RPC); challenge gains expires_at, participant completion.
B. Post-challenge derived best (chosen for MVP)Standing = best non-voided score for the drill recorded after the challenge existed for that participant.One server query change + tests. No proto/schema change.
C. All-time derived best (original, rejected)Best-ever score on the drill counts, including pre-challenge runs.None — but semantically surprising.

Decision

MVP uses Option B. Challenge standings use the best non-voided score for the challenge’s drill recorded within the challenge window:

  • Lower bound: GREATEST(challenge.created_at, participant.joined_at) — scores recorded before the challenge are ignored.
  • Upper bound (freeze): once the challenge is completed, only scores with timestamp <= challenge.completed_at count. A finished challenge’s result is frozen — a later (even better) run does not change it. While the challenge is active (completed_at is NULL) there is no upper bound.

The filter uses the score’s recorded timestamp (run time), not server insert time, so an old run synced late still does not count.

Implemented in ChallengeProvider::get_challenge_detail (the LATERAL subquery’s AND timestamp >= GREATEST($3, cp.joined_at) AND ($4 IS NULL OR timestamp <= $4)). completed_at was added to challenges (migration 0015_challenge_completed_at) and is stamped by complete_challenge. Covered by get_challenge_ignores_pre_challenge_scores and get_challenge_freezes_standings_at_completion in apps/server/tests/challenge_integration.rs.

Deferred — Option A (future product decision)

If we choose to match the legacy “dedicated run” model later, it is a deliberate scope expansion, not a patch. It would entail:

  • A challenge linkage on scores: challenge_id on Score / CreateScoreRequest, or a dedicated submit-challenge-attempt RPC.
  • expires_at / deadline on the challenge, and participant completion state (accepted / completed).
  • Challenge-specific media and results (the legacy per-attempt photo/video/notes).
  • UI to “run” a challenge as a distinct flow rather than recording a normal drill score.

Legacy expiry detail (an Option A element)

Legacy challenges had a 30-day expiry window: public.challenges.expires_at defaulted to now() + 720h, and a daily pg_cron job (daily_expire_challenges, 0 3 * * *) invoked a check-challenge-expiration edge function to close out expired challenges (it marks state — expired rows are retained, not deleted). A deadline like this belongs to Option A and is not part of the MVP Option B model.

Usage evidence (legacy Supabase backup, ~12 months: 2025-05 → 2026-05)

Parsed from the production range_day_supabase.backup. Challenge usage is marginal, which is why locking MVP to Option B (and deferring Option A indefinitely) is low-risk:

MetricCount% of users
Total users71,357
Challenges ever created150
Distinct creators550.077%
Distinct participants (any involvement)1190.17%
Participant rows (user_challenges)335
Participant rows actually run (score submitted)150
Challenges marked completed56 / 150
  • ~12 months of steady-but-low volume (4–29 challenges/month), not a recent spike.
  • Of 335 invitations, only ~45% (150) were ever run to a score — most invitees never participated.
  • TTL check: expires_at is a logical window, not a row-dropping TTL — 145/150 challenges have expires_at in the past yet survive in the backup, spanning the full year. So 150 is the true cumulative total, not a survivors-only snapshot.

Implication: Faithfully replicating the legacy dedicated-attempt model (Option A) for a feature ~0.17% of users have ever touched is not justified for MVP. Migration blast radius is also tiny (150 challenges / 335 participations). If challenges gain traction post-launch, revisit Option A.