Skip to content

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_userid is the join key), configuration, and the NEW sync_* 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 by cid (TgpChallenge::cid(eventId)).

Current state (what's built vs not)

SideStateWhere
DonationFully v3, workingPayment → 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 builtadmin-backend/app/SyncEngine/*sync_* (TGA). Wired, engine='off', shadow.
Group config🔨 orphan scaffold to wireevent_activitiesevent_activity_map_stravastrava_activity (TGA).
Leaderboard merge (read)Reads BOTH from TGP todayWL 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 (config event_donation.min_qualification_amount; 0 = everyone qualifies; default 100). Manual override via users_meta.
  • target_distance and target_fund live 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.idtga_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

UIEndpointSource
Leaderboard table (indiv/team/group/gallery), chips, search, sort, pagingGET /events/{id}/leaderboardmerge (Flow 3)
Top-N card, gallery carouselGET /events/{id}/leaderboard/top, ?view=gallerymerge
Team / group member drawers/leaderboard/{team|group}/…/membersmerge (server zeroes non-qualified distance)
Fundraising barGET /events/{id}/statsevent_donation.fundraising.target_amount + live SUM
Recent donorsGET /events/{id}/recent-donationsdonations (mysql)
Fundraiser pages, donation success/sharefundraiser-dashboard / donations/successdonation config + challenge_donation_leaderboard
  • Row DTO kept byte-identical across the swap ⇒ frontend needs no change for the engine cutover.
  • Realtime: poll /leaderboard every 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)

  1. Seed strava_activity master to the full type catalogue (from leaderboard-activities.json).
  2. Add models EventActivity, EventActivityMapStrava, StravaActivity + relations (group→tab, group↦types).
  3. 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_tab path.
  4. 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)

  1. Migrations sync_group_totals + sync_group_team_totals (guarded, TGA).
  2. Rewrite TallyStep off binary DistanceBucket → overlap-aware per-group tally.
  3. Generalize RankStep → per-group + overall, metric distance|time.
  4. config('sync_engine.connection') knob on all sync_* models.
  5. 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)

  1. Add getSyncDistanceMap() reading sync_totals+sync_group_totals; in buildIndividualDataset /buildTeamDataset, branch on sync_event_settings.engine==='v2' to use it instead of challenge_leaderboard. Donation map + qualified + merge + DTO all UNCHANGED.
  2. Drive chips/columns from the event's groups/tabs (make authored config live).
  3. Verify DTO byte-identical (id≤50 path untouched); frontend unchanged.

Phase D — Go live on event 52

  1. Shadow off (engine=v2 live). Parity (sync:reconcile vs 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.

Organiser guide and developer documentation for the TogoActive platform.