Skip to content

Sync Engine v2 — Phase 0 Reader Map (read-path rewrite, Strategy A)

Read-only discovery. Goal: everything that reads the legacy activity leaderboard, what DTO it emits, and the gap between what the frontend consumes and what sync_totals provides. Scope = activity/distance leaderboard only. Donations stay on their own pipeline/tables.

1. Endpoints that serve the activity leaderboard (WL API)

LeaderboardController (routes/api.php):

  • GET events/{id}/leaderboarddata() (individual + team + group tabs)
  • GET events/{id}/leaderboard/toptop()
  • GET events/{id}/leaderboard/team/{teamId}/membersteamMembers()
  • GET events/{id}/leaderboard/group/{groupName}/membersgroupMembers()
  • GET events/{id}/leaderboard/highlightshighlights()
  • GET events/{id}/highlight-tokensviewerTokens()

Secondary activity-distance readers:

  • EventController — event summary / totals
  • EventCommunityStats — community distance aggregates
  • ParticipantsApiController — participant list w/ per-user distance (20 refs)
  • MyProfileController — "my" stats
  • EligibilityResolver (Domain/Automation) — reads distance for automation gates (6 refs)

2. Reader classification

ReaderActivity (in scope)Donation (leave alone)
LeaderboardController✅ distance/rank rowsqualified comes from challenge_donation_leaderboard
EventController✅ distance totalsraised_fund
EventCommunityStats✅ distanceraised_fund
ParticipantsApiController✅ per-user distancedonation cols
EligibilityResolver✅ distance gatesfundraising gates
*DonationLeaderboardService, FundraisingMessageService, TeamDonationRecalc, AdminDonation* *❌ entirely donation, DO NOT TOUCH

3. Response DTO the adapter MUST emit (locked contract → frontend unchanged)

Individual row:

{ rank:int, distance:{...km...}, user:{name,avatar,...}, qualified:int, target_distance:float,
  running_distance, cycling_distance, (walking_distance) }

Team row: { rank, name, distance, total_users, target_distance, qualified } Keep these keys byte-identical; only the source of the numbers changes when engine=v2.

4. GAP REGISTER — frontend needs vs sync_totals provides ⚠️ the crux

sync_totals cols: total_distance, running_distance, cycling_distance, total_calories, moving_time_total, activities_count, last_activity_at, rank.

Field frontend consumesrefsIn sync_totals?Decision needed
total_distance19map directly
rank (overall)23map directly
total_running_distance7running_distance (rename)map
total_cycling_distance7cycling_distance (rename)map
total_caloriesmap
last_activity_datelast_activity_atmap
total_walking_distance4❌ no walking splitextend tally (add walking_distance) or fold into total only
rank_run / rank_cycle / rank_swim14❌ only overall rankextend RankStep (per-sport ranks) or compute in adapter
target_distance26❌ not an activity outputsource from event/user goal config (registration target), not from sync
max_speed / average_speedsome❌ not aggregatedcompute in adapter from sync_activities, or extend tally
last_activity_typesome❌ (have date only)extend tally
qualified23➡️ donation tableOUT of scope — leave as-is
swimming_*rare❌ not in allowed_typesdrop for v3 (Swim not synced)

Conclusion: Strategy A cannot be a pure source-swap. Each ❌ row is a decision: (a) extend the engine (tally/rank) to compute it, (b) compute it in the adapter on read from sync_activities, (c) source it elsewhere (target_distance = a goal, not a sync output), or (d) drop it from the v3 UI. Recommended split:

  • Extend engine: walking_distance, per-sport ranks, last_activity_type (cheap, belongs in tally/rank).
  • Adapter-compute: max/avg speed (from sync_activities, only when a view needs it).
  • Source elsewhere: target_distance (per-user goal — find its origin in reg/challenge config).
  • Drop: swimming.

5. Realtime delivery (per user's note: secure, fast, accurate, near-realtime)

Hard truth: no transport makes data fresher than the tally cadence. The engine ticks every 1 min, so "almost realtime" is bounded at ~1 min regardless of sockets vs polling. Given that:

  • Recommended: polling the existing /leaderboard endpoints every 15–30s (already auth-guarded = secure; accurate because the adapter reads only counted+ranked totals with dirty=0, never half-computed rows). Simplest, least infra, matches the 1-min data reality.
  • SSE (one-way push) if we want to eliminate poll chatter — lighter than websockets, still read from the same ranked snapshot.
  • WebSockets (Reverb/Pusher) = over-engineering here; full-duplex buys nothing when data only changes once a minute. Revisit only if tick cadence drops well below a minute.
  • Accuracy guard: adapter must read totals only after RankStep clears dirty (avoid mid-tally reads).

6. Open decisions before Phase 1

  1. Per gap-register ❌ row: extend engine vs adapter vs source-elsewhere vs drop (recommended split above).
  2. target_distance true origin (confirm in registration/challenge config).
  3. Realtime transport: polling (recommended) vs SSE vs websockets.

Organiser guide and developer documentation for the TogoActive platform.