Challenges

Summary

Implement challenge creation, completion, and results comparison on mobile. Challenges are social motivators — pick a drill, invite friends, compare best scores.

Context

A challenge references a drill via drill_id. Scores also reference drills via drill_id. Participant scores are queried by joining on the shared drill_id + shooter_id — no schema changes needed. “Best score” is determined by the drill’s calculation method (e.g., highest hit factor, lowest time).

Scoring semantics (MVP): post-challenge derived best. A participant’s standing is their best non-voided score for the drill recorded after the challenge existed for them — scores recorded before the challenge are ignored. This is a deliberate MVP choice that differs from the legacy “dedicated one-off run with a deadline” model; see Challenge Scoring Semantics for the decision and the deferred Option A.

Participants are added immediately on challenge creation (no pending/accept state in the schema). The mobile UI reflects this — challenges appear in your list when invited, no accept/decline flow.

Requirements

  • Create challenge (name, select drill, invite friends)
  • Challenge list with tabs for active (open/in-progress) and completed
  • Challenge detail view with participants, display names, and best scores
  • Complete challenge (creator only, idempotent)

Details

New API work: GetChallenge RPC

Added to ChallengeService proto. Returns hydrated challenge detail in a single round-trip.

Request: challenge_id Response: challenge + list of ChallengeParticipantDetail ChallengeParticipantDetail fields: user_id, display_name, joined_at, optional score_id, points, time, hit_factor

Authorization: caller must be a participant or creator. Returns PermissionDenied otherwise.

Best score selection: Server joins drills to get calculation method, then orders the LATERAL score query:

  • HitFactor: highest hit_factor
  • TimePlus/TimeOnly: lowest time
  • PointsOnly: highest points
  • PassFail: pass = true first, then highest points
  • Voided scores excluded
  • Challenge window only: only scores with timestamp >= GREATEST(challenge.created_at, participant.joined_at) count; once the challenge is completed, also timestamp <= challenge.completed_at (standings freeze at completion). MVP post-challenge derived best — see Challenge Scoring Semantics

Participant ordering: ORDER BY joined_at ASC, user_id ASC for deterministic rendering.

Integration tests (minimum):

  • Participant can fetch detail
  • Non-participant forbidden
  • Best-score selection ignores voided scores
  • Best-score selection ignores pre-challenge scores (recorded before GREATEST(created_at, joined_at))
  • Completed challenge freezes standings: scores recorded after completed_at don’t change the result
  • No-score participant still returned
  • Completed challenge still fetchable

Challenge flow

  1. Create: User picks a drill, names the challenge, selects friends to invite. Invited users become participants immediately (no pending state). Push notification sent.
  2. List: Tabs for “Active” (open + in_progress) and “Completed”. Paginated.
  3. Detail: Challenge name, drill name, status, participant list with display names and best scores. Creator sees “Complete” button.
  4. Complete: Creator-only. Marks challenge as completed. Sends push notification.

Accessible from Training tab (toolbar entry point).

Architecture

  • New ChallengeService (iOS/Android) — RPCs: ListChallenges, GetChallenge, CreateChallenge, CompleteChallenge
  • New ChallengeViewModel — challenge list state, detail state, create flow
  • Drill picker reuses existing drill list from ScoringViewModel
  • Friend picker queries FriendService for invitation selection
  • Proto changes: new GetChallenge RPC, GetChallengeRequest, GetChallengeResponse, ChallengeParticipantDetail messages

Not in scope

  • Accept/decline UI (participants are immediate on creation, AcceptChallenge RPC is idempotent no-op)
  • Score entry within challenge context (users score drills normally)
  • Challenge media (deferred to mobile-media)
  • Challenge expiration/auto-complete

Open Questions

  • Challenge drill types All drills in the user’s list are valid. API validates server-side.
  • Completed challenge detail view GetChallenge RPC returns participants + best scores via drill_id join.
  • Personal completed challenges ListChallenges with status filter. Tabs in UI.
  • Challenge media Deferred to mobile-media (cross-reference added).