Appearance
V3 Leaderboard — Master Plan (Activity + Donation, End-to-End)
The definitive plan. Covers the full v3 leaderboard: the new activity sync engine, the (existing) donation pipeline, how they merge, and how data reaches the Next.js frontend. Supersedes the readmap / sport-model notes. Companion visual: the flow-diagram artifact.
The two databases (spine of everything)
mysql(TGA / TogoActive) — source of truth for money & identity:payments,payment_details,donations,tax_deduction_details,event_users,teams,team_users,users(users.tgp_useridis the join key),configuration, and the NEWsync_*engine tables.mysql_tgp(TGP / Togoparts) — cached rollups the leaderboard reads:challenge_donation_leaderboard,challenge_team_leaderboard,challenge_leaderboard(legacy activity),challenge_activities_log(Strava webhook feed). Linked to an event bycid(TgpChallenge::cid(eventId)).
Current state (what's built vs not)
| Side | State | Where |
|---|---|---|
| Donation | ✅ Fully v3, working | Payment → recalc → challenge_donation_leaderboard (TGP). Cron donations:recalc for events ≥49. NOT changing. |
| Activity (legacy ≤50) | Legacy Stage 1/2/3 crons → challenge_leaderboard (TGP) | Old prod /var/www/togoactive. Stays for id≤50 forever. |
| Activity (v2, id>50) | 🔨 Being built | admin-backend/app/SyncEngine/* → sync_* (TGA). Wired, engine='off', shadow. |
| Group config | 🔨 orphan scaffold to wire | event_activities→event_activity_map_strava→strava_activity (TGA). |
| Leaderboard merge (read) | Reads BOTH from TGP today | WL LeaderboardController::buildIndividualDataset merges donation+distance per tgp_userid. |
FLOW 1 — Donation (already v3; reference only, DO NOT rebuild)
Donor form (Next.js DonationFormBlock)
└─ POST events/{id}/donations/checkout → DonationCheckoutController::initiate [mysql]
payments(pending) + payment_details + donations(+team_split) + tax_deduction_details
└─ Stripe checkout → StripeWebhookController::markPaymentSuccessful (or verify) [mysql]
payments.status='successful'
└─ DonationLeaderboardService::recalculateForPayment [reads mysql → writes mysql_tgp]
challenge_donation_leaderboard : raised_fund, qualified, qualified_at, target_fund,
target_distance, rank (per user, key=tgp_userid)
challenge_team_leaderboard : raised_fund, donors, qualified, target (key=tga_team_id)
└─ donations:recalc cron (~10m, events id≥49) → money+qualified sweep
+ CALL UpdateDonationRanks / UpdateTeamDonationRanks (TGP stored procs → rank columns)qualified=raised_fund ≥ min_qualification_amount(configevent_donation.min_qualification_amount;0= everyone qualifies; default 100). Manual override viausers_meta.target_distanceandtarget_fundlive HERE (donation table), NOT on the activity side.- Config: admin Donation Config (
event_donation) + Fundraiser Config (event_fundraiser).
FLOW 2 — Activity (the build: sync engine v2, id>50)
Strava → challenge_activities_log (TGP, webhook feed, READ-ONLY)
└─ sync:tick cron (1 min) [SyncEngine, writes TGA sync_*]
IntakeStep : new log rows → sync_activities(received) (key: event_id, activity_id)
FetchStep : Strava proxy → raw JSON + promoted cols (quota-budgeted, 120/15min)
EvaluateStep : window → allow-list → dedup → suspicious → accepted | rejected | flagged
TallyStep : accepted → sync_totals (overall) + sync_group_totals (per group) [KM]
RankStep : rank per group + overall (metric: distance|time)
└─ sync:gallery /15m · sync:alerts /1m · sync:reconcile 03:05 · sync:prune 03:40- Groups = the wired-up scaffold
event_activities(name,tab) →event_activity_map_strava(types, outdoor/indoor, manual_allow) →strava_activity(slug=Strava type). Group = chip; union of enabled groups = allow-list; Overall = sync_totals (intrinsic). sync_group_totals(event_id, tgp_userid, event_activity_id, distance, moving_time, activities_count, rank, dirty)— overlap-aware (one activity → every group it maps to).- v2 ALREADY does local dedup + suspicious (replaces legacy external
filter_duplicates.php).
FLOW 3 — The merge (read path) — where activity meets donation
WL LeaderboardController::buildIndividualDataset builds ONE row per participant, keyed users.tgp_userid (teams: teams.id→tga_team_id):
event_users ⨝ users (host excluded)
├─ getTgpDonationMap(cid) → challenge_donation_leaderboard : raised, qualified, rank_donation, target ── UNCHANGED
└─ getTgp**Distance**Map(cid) → challenge_leaderboard : total_distance, rank_overall/cycle/run ◄── SWAP for v2
replace with getSyncDistanceMap → sync_totals + sync_group_totals (key tgp_userid, group=event_activity_id)
merge in-memory on tgp_userid → row carries BOTH donation_rank.completed (raised) AND rank_cycle.completed
(filter-selected distance) AND qualified- Chip collapses the row:
fundraising→donation rank+raised;overall/cycle/run_walk→ that group's distance+rank.qualified≠1⇒ frontend swaps distance cell for a "Not qualified" badge (server pre-zeroes non-qualified distance in team/group member drawers). - The entire "donation merge" = swapping ONE function's source for engine=v2 events. Donation half stays byte-identical.
FLOW 4 — Frontend (Next.js) data delivery
| UI | Endpoint | Source |
|---|---|---|
| Leaderboard table (indiv/team/group/gallery), chips, search, sort, paging | GET /events/{id}/leaderboard | merge (Flow 3) |
| Top-N card, gallery carousel | GET /events/{id}/leaderboard/top, ?view=gallery | merge |
| Team / group member drawers | /leaderboard/{team|group}/…/members | merge (server zeroes non-qualified distance) |
| Fundraising bar | GET /events/{id}/stats | event_donation.fundraising.target_amount + live SUM |
| Recent donors | GET /events/{id}/recent-donations | donations (mysql) |
| Fundraiser pages, donation success/share | fundraiser-dashboard / donations/success | donation config + challenge_donation_leaderboard |
- Row DTO kept byte-identical across the swap ⇒ frontend needs no change for the engine cutover.
- Realtime: poll
/leaderboardevery 15–30s (data changes at the 1-min tick; websockets = overkill).
WHAT WE HAVE TO DO — build breakdown
Phase A — Wire the group scaffold (additive, zero live impact)
- Seed
strava_activitymaster to the full type catalogue (fromleaderboard-activities.json). - Add models
EventActivity,EventActivityMapStrava,StravaActivity+ relations (group→tab, group↦types). - Admin group-editor UI + API: name a group, pick Strava types (outdoor/indoor + manual), assign to a tab, order. Validate against the catalogue. Retire the dead
leaderboard_sorting_tabpath. - Seed event 52: groups Cycling {Ride,MountainBikeRide,GravelRide,EBikeRide,EMountainBikeRide, VirtualRide} and Run/Walk {Run,Walk,TrailRun,Hike,VirtualRun}. Overall intrinsic.
Phase B — Engine group model (shadow only)
- Migrations
sync_group_totals+sync_group_team_totals(guarded, TGA). - Rewrite
TallyStepoff binaryDistanceBucket→ overlap-aware per-group tally. - Generalize
RankStep→ per-group + overall, metric distance|time. config('sync_engine.connection')knob on allsync_*models.- Turn engine=v2 + shadow on event 52; verify per-group totals/ranks via sync-ops; diff expectations.
Phase C — WL read adapter (the merge swap)
- Add
getSyncDistanceMap()readingsync_totals+sync_group_totals; inbuildIndividualDataset/buildTeamDataset, branch onsync_event_settings.engine==='v2'to use it instead ofchallenge_leaderboard. Donation map +qualified+ merge + DTO all UNCHANGED. - Drive chips/columns from the event's groups/tabs (make authored config live).
- Verify DTO byte-identical (id≤50 path untouched); frontend unchanged.
Phase D — Go live on event 52
- Shadow off (engine=v2 live). Parity (
sync:reconcilevs challenge_leaderboard during transition)- alerts watch drift. Poll cadence confirmed.
Deferred / later
- Achievements convergence onto the same group definitions (retire hand-synced
ACTIVITY_GROUPS). - Per-event flags for hours-mode / e-bike / swim-split (documented; build when an event needs them).
Safety rails (unchanged)
Never artisan-as-root; admin-backend live on save; TGP writes only via the existing donation pipeline (engine writes TGA sync_* only); quota ≤120/15min; legacy events (≤50) never touched.