Files
lunchtime-web/docs/superpowers/specs/2026-11-05-participation-fun-design.md
2026-11-05 23:28:21 +00:00

3.3 KiB
Raw Permalink Blame History

Participation fun: live stats and podium stamps (short design)

Intent

Make the participant order screen (ParticipantView.tsx) feel rewarding: an anonymous, live "number go up" panel and a personal stamp for the first three people to submit.

Goals

  • Live horizontal bar chart per category: how often each item was picked, plus a participant count.
  • A personal stamp (🥇🥈🥉) shown only to the participant themself, in their "Your order" card, if they are 1st, 2nd or 3rd to submit.
  • Live updates only while the order is open; stop when it closes, resume if reopened.

Non-goals / anti-goals

  • No usernames, no emails, no way to tell who picked what or to reconstruct a full order.
  • No medals for most-picked items or any other item-based reward.
  • No websockets/SSE, no new infrastructure.
  • The order creator never receives a medal (their submission is skipped in the ranking).

Constraints

  • Frontend already polls every 5s (ParticipantView.tsx); follow that pattern.
  • Admin status changes (accepted/paid/handed_out) bump submissions.updated_at, so it cannot be the ranking key.

Approaches considered

  • Rank key: new ranked_at column (chosen) vs reusing updated_at (rejected: admin clicks would reshuffle medals).
  • Delivery: new GET /api/orders/{id}/stats polled only while open (chosen); extending GET /api/orders/{id} (rejected: no user identity, and its poll must keep running after close); SSE/websockets (rejected: new infra for a counter).

Design

DB (db.py): add submissions.ranked_at TEXT via the existing PRAGMA table_info + ALTER TABLE pattern; backfill from created_at.

Backend

  • create_submission and update_submission (the participant's own PUT) set ranked_at = now. admin_update_submission_status does not touch it. Deleting a submission removes it from the ranking naturally.
  • GET /api/orders/{order_id}/stats, public like get_order, optional X-User-Id header. Returns: { participant_count, categories: [{id, label, items: [{name, count}]}], my_rank: 1|2|3|null }
    • Counts come from every submission's choices_json, including the creator's.
    • my_rank is the caller's position among non-creator submissions ordered by ranked_at, only if 1–3. The creator is found via get_order_creator_info and their submission via user_order_tokens. Null for the creator, for non-submitters, and for rank 4 and above.
  • The stats logic goes in a new function in stats_service.py, with a small test in backend/tests/ (ranking: creator skipped, edit moves you back, admin status change does not, delete promotes others).

Frontend

  • statsService (or ordersService) gets a getOrderStats(orderId) call.
  • ParticipantView: a useEffect polls it every 5s while !isClosed and clears the interval when closed. The effect depends on isClosed, so a reopen restarts it. The last result stays on screen, frozen, after close.
  • A small stats card (antd horizontal bars per category, participant count) and a stamp inside the "Your order" card driven by my_rank.

Left to the builder

Bar styling, stamp wording and animation, number-change animation, how to lay out many categories or items (e.g. cap long lists).

My calls (not discussed)

  • The count and bars include the creator; only the medal skips them.
  • After close the last stats stay visible but frozen.
  • Backfill ranked_at from created_at.