Appearance
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}/leaderboard→data()(individual + team + group tabs)GET events/{id}/leaderboard/top→top()GET events/{id}/leaderboard/team/{teamId}/members→teamMembers()GET events/{id}/leaderboard/group/{groupName}/members→groupMembers()GET events/{id}/leaderboard/highlights→highlights()GET events/{id}/highlight-tokens→viewerTokens()
Secondary activity-distance readers:
EventController— event summary / totalsEventCommunityStats— community distance aggregatesParticipantsApiController— participant list w/ per-user distance (20 refs)MyProfileController— "my" statsEligibilityResolver(Domain/Automation) — reads distance for automation gates (6 refs)
2. Reader classification
| Reader | Activity (in scope) | Donation (leave alone) |
|---|---|---|
| LeaderboardController | ✅ distance/rank rows | qualified comes from challenge_donation_leaderboard |
| EventController | ✅ distance totals | raised_fund |
| EventCommunityStats | ✅ distance | raised_fund |
| ParticipantsApiController | ✅ per-user distance | donation cols |
| EligibilityResolver | ✅ distance gates | fundraising 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 consumes | refs | In sync_totals? | Decision needed |
|---|---|---|---|
| total_distance | 19 | ✅ | map directly |
| rank (overall) | 23 | ✅ | map directly |
| total_running_distance | 7 | ✅ running_distance (rename) | map |
| total_cycling_distance | 7 | ✅ cycling_distance (rename) | map |
| total_calories | — | ✅ | map |
| last_activity_date | — | ✅ last_activity_at | map |
| total_walking_distance | 4 | ❌ no walking split | extend tally (add walking_distance) or fold into total only |
| rank_run / rank_cycle / rank_swim | 14 | ❌ only overall rank | extend RankStep (per-sport ranks) or compute in adapter |
| target_distance | 26 | ❌ not an activity output | source from event/user goal config (registration target), not from sync |
| max_speed / average_speed | some | ❌ not aggregated | compute in adapter from sync_activities, or extend tally |
| last_activity_type | some | ❌ (have date only) | extend tally |
| qualified | 23 | ➡️ donation table | OUT of scope — leave as-is |
| swimming_* | rare | ❌ not in allowed_types | drop 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
/leaderboardendpoints every 15–30s (already auth-guarded = secure; accurate because the adapter reads onlycounted+ranked totals withdirty=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
- Per gap-register ❌ row: extend engine vs adapter vs source-elsewhere vs drop (recommended split above).
target_distancetrue origin (confirm in registration/challenge config).- Realtime transport: polling (recommended) vs SSE vs websockets.