Skip to content

Latest commit

 

History

History
1020 lines (739 loc) · 73.9 KB

File metadata and controls

1020 lines (739 loc) · 73.9 KB

Progress

README media refresh (2026-10-04)

Done

  • Hero image is now docs/images/host-lobby.gif: the real lobby filling with players, then a click that enlarges the QR. Captured as lossless 2x PNG frames over CDP screencast, cut and given a drawn cursor in Remotion, converted to a 1760px, 25fps GIF (6.3 MB).
  • Every screenshot retaken from one real game on the current UI (dark by default, light theme shown on the phone). Added the quiz library, the explanation screen and the player's final screen.
  • Removed the old screenshots and the stray frontend/join-dark.png.

Notes

  • The Remotion project and capture scripts live in a separate project outside this repo, so the app repo carries only the output.
  • Playwright's recordVideo is low-bitrate VP8 and headless screencast ignores deviceScaleFactor; launch Chromium with --force-device-scale-factor=2 for 2x frames.
  • Park the headless mouse away from the chip row while recording: a hovered chip shows its kick button.

Phase 25: Continue with Google (2026-10-04)

Spec: specs/phase-25-google-login.md.

Done

  • trivia_tap.auth.login_with_google redirects to frappe's Google authorize URL. The landing page stays inside the SPA. Frappe's login_via_google does the rest: verified email check, create or link the user, log in.
  • Boot flag google_login shows "Continue with Google" on the log in and sign up screens only when the google Social Login Key is enabled.
  • brand_site patch (rerun as #2) sets Portal Settings default role to Quiz Host, so a Google sign up lands as a host (System User).
  • Setup steps in README.

Exit criteria verified

Tests cover the redirect, the off-site guard, the not-set-up error and a Google sign up getting Quiz Host. Headless browser at 390px: button and "or with email" divider render; clicking it reaches accounts.google.com with the right client ID and redirect URI.

Notes

  • Google rejects plain http redirect URIs on any host but localhost, so http://trivia-tap.localhost:8000 cannot finish the flow. Local testing needs the site as bench default and http://localhost:8000, or an https tunnel.
  • A real Google account round trip is not verified yet: it needs a real client ID.

Phase 24: Join screen polish (2026-10-04)

Spec: specs/phase-24-join-polish.md.

Done

  • Game PIN is six boxes from reka-ui PinInput, styled like CodeStep. Finishing the PIN, or arriving from the QR link with it pre-filled, focuses the nickname.
  • Chosen face shows at 56px beside the nickname input. A shuffle button inside the input replaces the "More" chip that wrapped onto its own line on a phone. The picked suggestion chip is highlighted.
  • Carousel faces are 48px with faded strip edges.
  • "Join game" stays disabled until the PIN has six digits and the nickname is not blank.

Exit criteria verified

Headless browser at 390px and 1280px, dark and light: typed a live lobby PIN, focus moved to the nickname, picked a suggestion and a face, joined and landed in the lobby as that player. ?pin= pre-fills the boxes and focuses the nickname.

Phase 23: Reset password with an email link (2026-10-04)

Spec: specs/phase-23-password-reset-link.md.

Done

  • Profile page: "Forgot?" next to Current password calls send_reset_link, which mails the host's own inbox a /trivia-tap/reset-password?key=... link. The key and its expiry are Frappe's (User._reset_password, reset_password_link_expiry_duration). Administrator is refused.
  • ResetPassword.vue: new password and confirm, styled like the login page, open to guests. reset_password_with_link hands the key to Frappe's update_password and turns its 410 for a used or expired key into a ValidationError.
  • Profile page polish on the same base: bigger avatar with an edit badge, stat tiles, password form collapsed behind "Change".

Exit criteria verified

Headless browser as a throwaway Quiz Host: "Forgot?" mails the link to Mailpit, the link opens with cookies cleared, the new password logs in and lands on /host, the same link a second time shows the expired message, and a link without a key says it is broken. 96 app tests pass.

Security: whitelisted method audit (2026-10-04)

Spec: specs/security-whitelisted-methods.md.

Done

  • Quiz Host keeps only if_owner read on TT Session. The host methods in api.py are the only write path and save with ignore_permissions after get_host_session checks the caller.
  • get_player replaces get_session_by_pin plus get_participant_by_token in the token-gated guest methods. A wrong PIN and a wrong token raise the same PermissionError.
  • submit_answer, get_state and get_result are rate limited per IP only. get_state and get_result are POST only.
  • login_with_code refuses users who must pass Frappe 2FA.

Exit criteria verified

Over HTTP as two Quiz Hosts: creating a session on another host's quiz and editing your own session's host through /api/resource both return PermissionError. A PIN scan with rotating tokens gets only 403s, then 429 after 60 requests. Headless browser as a plain Quiz Host: unlock lobby, guest joins, start, answer, podium.

Phase 22: Slide quiz editor (2026-10-04)

Spec: specs/phase-22-slide-editor.md.

Done

  • QuizEditor.vue is now three panes: QuestionRail (question cards), QuestionSlide (the selected question laid out like the projector, tick a tile to mark it correct) and QuestionSettings (time limit, points, explanation, duplicate, delete).
  • Rail cards reorder by drag, live as the card passes others, and by Alt+Up / Alt+Down. Incomplete cards get a red dot. Duplicate and delete show on hover or focus.
  • Description, timers, explanations and host controls moved to QuizSettingsDialog.
  • Save checks the quiz on the client first (questionProblem in quiz.js mirrors TTQuiz.validate) and selects the broken question. Ctrl+S / Cmd+S saves. Unsaved changes are tracked against a snapshot of the payload and guard both route leave and tab close.
  • ImageField replaces the two copies of the upload, replace and remove buttons.
  • .field gets a mint border on focus instead of the browser's blue outline.

Exit criteria verified

Headless browser: new quiz, client validation jumps to the broken question, Ctrl+S saves and swaps /new for the quiz name, order persisted in the DB after Alt+Up, duplicate, drag both ways and delete, leave guard, settings dialog, preview, 390px layout and light theme.

Phase 21: One quiz list (2026-10-04)

Spec: specs/phase-21-one-quiz-list.md.

Done

  • The Play and Library tabs are gone. /host shows "Your quizzes" from QuizLibrary.vue: clicking a row opens the editor, Play on the right starts a session, a quiet trash button deletes.
  • Play stays a plain pill and lights up in brand green only on the hovered or focused row, so a column of green buttons does not drown out New quiz.
  • QuizList.vue is gone and /host/quizzes redirects to /host.

Exit criteria verified

Headless browser: row click opens /host/quizzes/<name>, Play opens the lobby, Close lobby returns to the list, /host/quizzes lands on /host, layout holds at 390px.

Phase 20: Animated join QR (2026-10-04)

Spec: specs/phase-20-animated-qr.md.

Done

  • AnimatedQr.vue: a 2D canvas where every dark QR module is a dot. The dots pop in as the mascot outline (dark pixels of the logo), hold with a small bob, then arc into their module and settle to ink. The logo badge lands last.
  • Lobby code and fullscreen code both use it. renderQr and the data URL are gone from Host.vue.
  • Living badge: scripts/build_mascot.py cuts the logo into background, head, eyes and sparks (connected components of the dark ink, background refitted as a smooth gradient) and writes trivia-tap-mascot.svg with CSS keyframes: head bob, glance, blink, spark flash. Reduced motion stops it. The SVG lives in frontend/src/assets so Vite hashes its URL: Frappe sends max-age=43200 for /assets, and at a fixed URL a browser kept showing an older mascot for hours.
  • The light box around the badge has rounded corners, concentric with the tile (tile radius plus padding, 28.5%). The modules run under it, so no square hole shows at the corners.
  • Fullscreen code: rounded-3xl is not in the frappe-ui radius scale and rendered square, now rounded-2xl. The dialog no longer draws a focus outline around itself.

Exit criteria verified

Headless browser on a live lobby: mascot frame, flight, settled frame, then the badge keeps moving. Settled frames decode to the join URL (OpenCV and zxing-cpp before the live badge, zxing-cpp after).

Phase 19: Email codes for sign up, reset and log in (2026-10-02)

Spec: specs/phase-19-email-code-auth.md.

Done

  • trivia_tap/email_code.py: EmailCode keeps a 6-digit code hashed in Redis for 10 minutes, 5 tries (atomic INCR), 30 second resend throttle per email.
  • trivia_tap/auth.py: send_code (mail from a background job, same answer whether or not the email has an account), sign_up now needs the code, reset_password hands a minted frappe reset key to frappe's update_password, login_with_code.
  • Login page: one code step (CodeStep.vue) shared by sign up, forgot password and code log in. Sign up checks password strength before mailing.
  • brand_site patch and after_install: Website Settings app name and logo set to TriviaTap, so mails no longer carry the Frappe wordmark.
  • Tests: 14 in trivia_tap/tests/test_auth.py.

Exit criteria verified

Over HTTP with codes read from Mailpit, and in a headless browser: weak password stops before any mail, wrong code refused, sign up lands on /host, forgot password sets the new password and logs in, code log in logs in, quick resend shows "Wait a few seconds", an existing email on sign up gets the "already have an account" mail.

Notes

  • Frappe skips the password policy when frappe.in_test, so the test for "a failed sign up keeps the code" fails the insert with a patch.
  • test_password_strength rejects user_data over HTTP (typed as tuple), so the SPA sends only the password. The server's sign up still checks with the name and email.

Phase 18: Host avatar and stats (2026-10-01)

Spec: specs/phase-18-host-avatar-stats.md.

Done

  • The profile header avatar opens a picture dialog: a hover tooltip, then left and right arrows (and arrow keys) through the initial and the avatar pack, saved on Save. It stores the avatar URL in User.user_image, so Desk shows it too. An uploaded Desk photo stays on offer as "Your photo".
  • HostAvatar shows the image or the initial in the bar, the menu and the profile header. user_image comes with the page boot.
  • trivia_tap.api.get_host_stats counts Ended games, players who were not kicked, and quizzes the host owns. The profile header card shows the three.
  • Tests in test_host_stats.py: zeros for a new host, Cancelled lobbies and kicked players left out, another host's games not counted. Full suite: 79 tests pass.

Verified

yarn build and pre-commit pass. In the browser, desktop and phone, dark and light: a saved pick shows at once in the bar, survives a reload and shows in the menu. Stepping and Escape save nothing. An uploaded photo opens as "Your photo · 1 / 26". Stats read 140, 2,835 and 5 for Administrator.

Notes

  • IntegrationTestCase rolls back per class, not per test, so the stats tests clear their hosts' rows in setUp.

Phase 17: Host account menu (2026-10-01)

Spec: specs/phase-17-host-account-menu.md.

Done

  • Host bar: Play and Library as text tabs with a mint underline, New quiz on the right (a plus icon on a phone), and an initial avatar that opens the account menu.
  • Account menu is a native popover: name and email, Profile, a three way Auto, Light, Dark theme choice, and Log out. The floating theme button is gone from host screens.
  • Profile page opens on a header card with the avatar, full name and email. Save name stays disabled until the name changes.
  • The "Host" eyebrow is gone from the picker, quiz list and profile. New quiz moved from the quiz list header to the bar.

Verified

yarn build and pre-commit pass. In the browser, desktop and 390px phone, dark and light: tabs mark the current page, the bar fits one row on a phone, the menu opens, closes on Escape and on Profile, switches theme and logs out to /join. Save name is disabled on load, enabled after an edit, disabled again after save, and the header card shows the new name.

Notes

  • The log in email field is type="email", so the browser refuses Administrator. Only the dev admin account hits it.

Phase 16: Brand theme from the logo (2026-10-01)

Spec: specs/phase-16-brand-theme.md.

Done

  • Ground moved from purple night to the logo's green-tinted ink, in both themes.
  • accent is mint (#20EEA0 dark, #0A7A52 light, 5.1:1 on the light ground).
  • Primary actions (Join game, Log in, Back to join, .ctl-go) wear the logo gradient through one --brand variable, exposed to Tailwind as bg-brand. Ember keeps answer 1, urgency and danger.
  • sunk, card, QR colours and theme-color follow the logo ink and white.
  • Join and Log in show the logo beside the wordmark. LOGO_URL lives in theme.js and replaces three copies of the path.
  • The lobby QR card gets a haze ring: in light mode it melted into the ground.

Verified

yarn build and pre-commit pass. In the browser: join (desktop and phone), log in, host quiz picker, quiz list, lobby with a player, read-time screen, in both themes.

Notes

  • The running bench worker predates today's frappe pull and fails every tt_ticker run with ImportError: defer_enqueue_after_commit. Games freeze at read time until bench start restarts. Reveal and podium screens were not re-checked for that reason.

Phase 15: Host profile (2026-09-28)

Spec: specs/phase-15-host-profile.md.

Done

  • /host/profile, opened from a profile pill in the host bar: edit first and last name, change password (current, new, confirm, each with the eye button).
  • trivia_tap.auth.change_password checks the current password before calling frappe's update_password. Frappe clears the session cookies on any AuthenticationError, so calling update_password directly logged the host out when they mistyped their current password.
  • Host bar: logo, Host, Quizzes, then "Hi, " linking to the profile. Log out moved into the profile page, the theme button floats at the bottom right of host screens, and host pages keep room under their content for it. The bar used to wrap to two rows on a phone; it now stays on one.
  • PasswordInput no longer carries the login page styling; each page passes its own field classes.

Verified

74 tests pass. In the browser: name saves and survives a reload, a wrong current password shows "Current password is wrong." and the host stays logged in, a mismatch is caught before the call, a real change reloads with "Password changed." and the next save still works, and the new password logs in. Light and dark, desktop and phone width.

Phase 14: Log in and sign up inside the SPA (2026-09-28)

Spec: specs/phase-14-spa-login-signup.md. A new host goes from /trivia-tap to a live game without leaving the SPA.

Done

  • /trivia-tap/login with Log in and Sign up tabs, styled like the join page. Guests who open any /host route land there with ?redirect=, and go back to that route after. A logged-in user who opens it goes to /host.
  • trivia_tap.auth.sign_up creates the user with the password they typed and the Quiz Host role, then logs them in. It keeps frappe's guards: sign up disabled, the hourly sign up cap, and the same vague message for a taken email. Its per-IP limit is frappe's own max_signups_per_minute. A first cut used 5 per hour, which a few weak-password retries used up, and which would lock out a school where every teacher shares one IP.
  • Password fields have an eye button (PasswordInput). Sign up asks for the password twice and says "Passwords do not match." before calling the server.
  • The join page links hosts to the login page.
  • Server messages are HTML (the password policy sends a <ul> of hints), which showed up as raw tags. errorText in api.js turns them into text for every screen that shows a server error.

Verified

72 tests pass. In the browser on trivia-tap.localhost: wrong password shows "Invalid login credentials", a weak password shows the policy hint as text, a new account lands on the host screen, writes a quiz, opens a lobby, a phone joins by the copied link and the game plays to the podium. Logging back in from /host/quizzes returns there. Light and dark, desktop and phone width.

Notes

  • trivia-tap.localhost had sign up disabled in Website Settings. It is now on. Sites that keep it off refuse the sign up tab with a clear message.
  • Quiz Host has desk access, so a new host is a System User. Their role only reaches TriviaTap DocTypes.

Fix: a host sees only their own games (2026-09-28)

Spec: specs/fix-host-data-scope.md.

  • Any Quiz Host could list every player (nickname, avatar, score, token hash) and every answer from every host's games through desk or frappe.client. Quizzes and sessions were already private.
  • trivia_tap/permissions.py scopes TT Participant and TT Answer to the host of their session, through permission_query_conditions and has_permission.
  • Verified with two hosts over the live site and in tests/test_permissions.py, which fails without the hooks. 71 tests pass.

Phase 13: Rename to TriviaTap (2026-09-27)

Spec: specs/phase-13-rename-trivia-tap.md. Quizzly is now TriviaTap, down to the DocType prefix, the dev site and the repo.

Done

  • Package quizzly is now trivia_tap, module Quizzly is now TriviaTap (folder trivia_tap/triviatap), workspace TriviaTap.
  • DocTypes QZ * are now TT *; socket events, rooms, Redis keys, the ticker job and browser storage keys moved from qz to tt. Two patches carry existing sites: rename_qz_doctypes renames the DocTypes and their tables, rename_qz_quiz_names turns QZ-0092 into TT-0092.
  • SPA moved from /quizzly/* to /trivia-tap/*. The page file is www/trivia_tap.py; two route rules map the hyphenated path onto it.
  • New logo public/images/trivia-tap-logo.png (256px, cropped from the source art) in the host bar, the QR badge, the favicon, the apps screen and README.
  • Site config key is trivia_tap_avatar_pack, theme storage key trivia-tap-theme.
  • Dev site quizzly.localhost is now trivia-tap.localhost, moved in place with quiz data kept. Bench folder is apps/trivia_tap.
  • README points at bwhtech/trivia_tap. Renaming the repo itself needs an org admin; this account has write access only.
  • Past specs, plan and progress entries use the new names too.
  • README screenshots retaken from a real game. The results screen has been two screens since phase 12, so the gallery gains host-stats.png beside the scoreboard.
  • The join page inputs showed the forms plugin's blue focus ring over the ember border. focus:ring-0 leaves the ember border alone.
  • test_host_state_reaps_it_and_offers_a_fresh_game asserted no live session at all, so any open lobby on the dev site failed it. It now asserts the abandoned game is not offered back.

Verified

69 tests pass. On trivia-tap.localhost: the five old quizzes open under TT- names, a full five-question game plays with eight players (seven over the API, one in a phone-sized browser), the lobby QR shows the new logo, desk shows the TriviaTap icon and workspace.

Notes

  • Frappe cannot migrate a site whose app package is gone, so other installed sites need the manual steps in the spec before the patches can run.
  • A format: autoname keeps its counter under an empty series key, so the quiz rename needs no series change.
  • The workspace JSON modified had to move forward, or migrate skips it and the sidebar keeps the old link labels.
  • Workspace Sidebar is inert on this frappe version; nothing recreates it and nothing needs to.

Phase 12: Scoreboard screen (2026-08-22)

Spec: specs/phase-12-scoreboard-screen.md. The screen that ends a question is now two: the answer split, then the standings.

Done

  • engine.py: new scoreboard phase, always the last beat before the next question, whichever side the explanation sits on. close_question builds the standings (score, points won, new rank, previous rank) and parks them in Redis state; carry_over hands them through the phases in between untouched. The last question skips the screen: the podium is the standings.
  • The top five and the streak callouts move off the stats payload onto the scoreboard one. The stats screen is the distribution alone now.
  • get_host_state returns the parked standings for a reload during the phase, and drops the top_5 nothing read any more.
  • Host.vue: standings screen. Rows open in the old order with the old scores, then the points count up on every row while the rows move to their new places. TransitionGroup keyed by nickname does the move, so row identity survives the re-sort. The rank column reads as list position until the rows land, or a player who fell out of the top five leaves a gap in the numbering.
  • Reload during the phase paints the settled board with no replay.

Verified

E2E on trivia-tap.localhost at 1920x1080, six players answering over the API, a real worker driving the loop:

  • Question closes to the distribution bars alone, Show scores moves on.
  • Standings open in the previous order at the previous scores, +974 chips land and the rows overtake: ann and eve climb from 4th and 5th past cid, bob, dee.
  • Host reload mid-standings comes back to the same five rows, settled.
  • Last question's button reads Final results and goes straight to the podium.

Notes

  • Engine tests cover the new phase both ways round the explanation and the last-question skip: 31 green in tests/test_engine.py, plus api/game_ux green.
  • A dev-bench aside, not this app: the single bench worker serves long after default, so a five-minute job from another site can starve the ticker and the game abandons itself. The host reload settles it, as designed.

Phase 11: Quiz preview (2026-08-20)

Spec: specs/phase-11-quiz-preview.md. Preview in the editor plays the quiz screens with no session and nobody in the lobby.

Done

  • AnswerGrid.vue: the projector answer grid lifted out of Host.vue unchanged, with correctOption as the reveal switch (null while the answer is still out). The host screen and the preview now render the same component.
  • QuizPreview.vue: fullscreen <dialog> over the editor that plays itself. Per question: read screen (3s), question (its own time limit), answer (5s), and the explanation before or after the answer per explanation_position, skipped when the question has nothing to explain. Engine constants are mirrored client-side.
  • The ring drains for real and turns red under five seconds, so a too-short window shows up before a room sits through it.
  • Pause holds the clock and resumes from where it stopped: the ring is measured against the beat's window, not the countdown's, so useCountdown is reused as-is instead of growing a pause. Back/Next and the arrow keys jump a beat, space toggles, the last beat offers Replay, Esc closes and stops the clock.
  • The walk clamps itself when an edit shortens the quiz while the dialog is open.
  • Preview reads the editor's reactive state, not the saved doc: it covers unsaved edits, needs no API and works on a quiz that has never been saved.
  • Editor header gets a Preview button, disabled until there is a question.

Deliberately not built

Every screen that only exists because players do: lobby, distribution bars, top 5, streaks, podium. Previewing those means inventing players and scores, and a made-up scoreboard teaches an author nothing. Sound too: a preview at a desk is not the room.

Verified

E2E on trivia-tap.localhost as Administrator, build served from the app:

  • Preview on a 2-question quiz with explanations after results: 8 beats, played through unattended. Read screen counted 3 down, question ran its 10s window with the ring going red under five, answer screen held 5s with the correct option ticked and the rest dimmed, explanation held its 5s. Pause froze the clock at 4s across three seconds of wall time and resumed to 2s without refilling the ring. Replay restarted at 1/8, Next disabled on the last beat, Esc closed and stopped the clock.
  • Regression on the live host screen after the AnswerGrid extraction: hosted a real session with a player joined, question screen and stats screen (tick, dim, distribution bars, top 5) render exactly as before.

Phase 10: Answer explanation screen (2026-08-19)

Spec: specs/phase-10-answer-explanation.md. A screen between the buzzer and the scoreboard that says why the answer is right.

Done

  • DocTypes: explanation (Small Text) and explanation_image (Attach Image) on TT Question, show_explanation (Check) on TT Quiz.
  • Engine (engine.py): new explanation phase between question and stats. close_question still settles scores, streaks, distribution and top 5 at the buzzer; whichever screen goes second is parked in Redis state by the one that goes first (closed_payload or explanation_after) and published unchanged, so nothing is computed twice.
  • explanation_position on the quiz puts the screen before the stats (default) or after them. The host buttons name the screen they actually open, off before_stats on the explanation event and explanation_next on the stats event. hold_seconds is the shared rule for how long a phase waits: the quiz's explanation_time_limit (default 10s) with auto-advance on, ADVANCE_WAIT_CAP with it off.
  • The phase is skipped when the toggle is off or the question has neither text nor image, so a half-authored quiz never lands on a blank screen.
  • Reconnect: get_host_state and the player get_state return the stored explanation payload while the phase is live, so a reload lands back on the explanation screen instead of skipping it.
  • Host screen: question, the correct answer in its shape colour, image, the explanation, a countdown ring (auto-advance only) and a Show results button. Player screen keeps its own verdict and shows the explanation under it, so nobody waits 10 seconds to find out they were right.
  • Authoring: one Explanations on/off toggle on the quiz, a before/after button and a seconds field beside it, plus a text box and an image uploader per question that appear with it.

Tests

  • TestExplanationScreen in tests/test_engine.py: the scoreboard is held back behind the explanation, scores are already settled when it shows, it expires into stats on its own, and it is skipped both when the quiz toggle is off and when the question has nothing to say.
  • E2E on trivia-tap.localhost with the demo bots: explanation -> results on both the host-driven and auto-advance paths, image and text-only questions, host reload and player reload mid-explanation, and the editor toggle round-trip.

Notes

  • The bench's single bench start worker serves every queue, so a long scheduled job from another app on this bench (navgold) blocked the long queue and left a game with no ticker. Not a TriviaTap bug: the game settled itself through is_abandoned, which is exactly what that guard is for. A dedicated bench worker --queue long unblocked testing.

Live Quiz Rework Phase 4: Trim Submit + Throttle answer_count (2026-07-23)

Spec: specs/live-quiz-rework/phase-4-submit-and-count.md. Cut the last two per-answer costs, and fixed the guest-socket blocker Phase 3 flagged.

Done

  • submit_answer (api.py): insert now uses ignore_links=True (session-active and participant-by-token are already validated; the (participant, question_row) unique index still backstops duplicates). Removed the per-submit answer_count broadcast block entirely.
  • Ticker (engine.py): maybe_push_answer_count broadcasts the live "N answered" count from the ticker instead, but only while a question is open, only on change, and at most every ANSWER_COUNT_THROTTLE (0.3s). Result: a few updates/sec total, flat regardless of player count. Throttle state + immutable pins live process-local in run_ticker (single deduplicated job), pruned each pass to the live session set so finished games don't leak.

Root-cause fix: guest sockets received no live events (Phase 3 blocker)

  • Symptom (Phase 3 note): with the real async ticker, players never advance get_ready -> question; they only limp via get_state resync.
  • Reproduced with a guest socket.io client against the live socketio server: it receives website-room events but nothing on tt_session_<pin>, so no game events ever arrive live. Host "worked" only because the frontend resync watchdog papers over it for slow lobby changes, not fast question transitions.
  • Root cause: apps/trivia_tap/package.json has "type": "module", so Node loads realtime/handlers.js as ESM. The frappe socketio server require()s it as CommonJS and gets {} instead of the handler function; app_handler(socket) throws and is swallowed, so tt_join/tt_leave never register and no socket ever joins the session room.
  • Fix: apps/trivia_tap/realtime/package.json = {"type":"commonjs"} overrides the module type for just that directory, so the CJS handler loads again without disturbing the ESM app root the frontend build relies on. Verified at the require() level (now returns function trivia_tap_handlers). Needs a socketio restart to take effect (the running server cached the failed load).

Tests

  • run-tests --app trivia_tap: 59 green. The engine test that filtered out answer_count events is unaffected (no such events emitted now).
  • Lint: pre-commit clean on all changed files.

Pending

  • Browser E2E (3+ guests: counter climbs via ticker, players advance to questions and answer) is blocked on a bench restart to reload the socketio handler. Run after restart per the rework rule (no merge until browser green).

Live Quiz Rework Phase 3: Batch Scoring Writes (2026-07-23)

Spec: specs/live-quiz-rework/phase-3-batch-scoring.md. Killed the N+1 write loops so reveal time stops scaling with player count.

Done

  • close_question: the participant loop now builds two in-memory dicts and no longer does I/O per participant. answer_updates ({answer: {is_correct, points}}) and participant_updates ({participant: {score, streak}}, streak-reset-to-0 for non-answerers in the same dict) flush through two frappe.db.bulk_update calls after the loop. Same commit + question_closed push as before.
  • finish_session: ranks persist via one frappe.db.bulk_update("TT Participant", {name: {"rank": rank}}) instead of one set_value per player.
  • bulk_update builds chunked CASE-WHEN UPDATEs and does not commit internally, so the ticker's tt_tick savepoint isolation (Phase 2) is intact. It also no-ops on an empty dict, so a question nobody answered is safe.

Tests

  • run-tests --app trivia_tap: 22 engine + 15 game-ux tests green. Scores, streaks, ranks, and podium identical to before — batching changed no number.
  • Load check (scripts-style console driver, 200 participants, half correct): close_question went 207.9 ms -> 36.7 ms. The residual is the answer fetch, not the writes; write time no longer scales with player count.

Browser E2E (trivia-tap.localhost)

  • Host + 3 guests (Alice/Bob/Cara) played "General Knowledge" driven by the real async long RQ worker (not the synchronous driver Phases 1-2 fell back to). The shared ticker marched through all 5 questions and rendered a correct podium with ranks 1/2/3 on both host and player screens. finish_session's batched rank write and close_question's batched score writes ran with zero Error Log entries.
  • Pre-existing, out-of-scope: with the real async ticker, the player view never advances from get_ready to the question phase (options never render), so guests can't answer and everyone scores 0. Host advances fine and gets every event. This is a player realtime/state issue untouched by this diff (which only edits close_question/finish_session DB writes) and predates it. Flagged for a separate fix; does not affect scoring correctness, which the unit suite gates.

Live Quiz Rework Phase 2: Multi-Game Scale (2026-07-21)

Spec: specs/live-quiz-rework/phase-2-multi-game-scale.md. Proved the shared ticker drives many concurrent games on the same workers with no starvation, the payoff of Phase 1's redesign.

Done

  • run_ticker gained per-session isolation: each session's step runs under a tt_tick savepoint inside its own try/except, commits on success, and on error rolls back to the savepoint + log_errors and continues. One bad game can no longer stall or kill the ticker for the others.
  • Fixed a real ticker bug the self-heal path was hiding: get_state read through Frappe's process-local cache (use_local_cache default True), so in the long-lived ticker a session whose state had expired in Redis still returned its last-seen dict forever. The if not state: srem self-heal never fired and the ticker spun on the vanished session. get_state now reads with use_local_cache=False, like pop_control already does.
  • Lifecycle was already airtight from Phase 1 and confirmed so: active_sessions is srem'd on finish, on end, and on abandon (all route through finish_session); the ticker srems any session whose state has vanished; and enqueue_game_loop always re-enqueues the deduped tt_ticker, so starting any new game self-heals a dead ticker and picks up every registered session. is_abandoned still settles a session the ticker somehow dropped.

Tests

  • test_ticker_survives_bad_session: seeds a bogus session that raises every pass alongside a real game; the real game still reaches the podium. Note: the guard must roll back to the savepoint, not call bare frappe.db.rollback(), which would discard the whole test transaction (and, in prod, sibling sessions' writes from the same pass).

Browser E2E (trivia-tap.localhost)

  • 5 concurrent games: 5 isolated browser guests joined 5 lobbies, all started, all rendered the same live question simultaneously (Q1 of 5), auto-advanced through all five questions, and every player reached the podium ("You won").
  • Backend proof via scripts/concurrent_games.py (console driver, phases driven by the real long RQ worker): 5 games marched in lockstep get_ready -> question -> closed for q0..q4, with exactly one tt_ticker job the entire run (max concurrent tt_ticker jobs observed: 1).

Live Quiz Rework Phase 1: Ticker Tracer (2026-07-21)

Spec: specs/live-quiz-rework/phase-1-ticker-tracer.md. Replaced the per-game busy-wait loop (run_game_loop, one RQ job per session) with a shared ticker plus a per-session Redis state machine.

Done

  • run_ticker: one self-looping RQ job (job_id="tt_ticker", deduplicate=True, queue long). Each pass reads tt:active_sessions, per session pops control and advances when a control fired or now >= next_ts, commits, sleeps TICK_SECONDS (0.5). Exits when the active set is empty.
  • advance_session(session_doc, state, control) dispatches on state["phase"] (get_ready -> question -> stats -> next / finish). end control finishes any phase.
  • get_ready / open_question / close_question lost their internal while/sleep loops; they now just write state (extended with phase and next_ts) and push. close_question reads auto_advance fresh to size the stats wait.
  • enqueue_game_loop seeds the first get_ready state, sadds the session, then enqueues the shared ticker. finish_session srems on the way out.
  • Deleted run_game_loop, wait_question_window, wait_before_next, POLL_SECONDS.
  • Tests green (test_engine, test_game_ux): TestGameLoop now drives the game via enqueue_game_loop + run_ticker; added coverage for auto-advance-off hold and advance-on-last-question finish.

Browser E2E (trivia-tap.localhost)

  • Full game played host + guest: get_ready pause -> question + countdown -> reveal -> auto-advance -> podium. Host skip closed the question within ~0.5s; the ticker exited with no lingering job after finish.
  • Env note: this bench's long RQ queue had no worker and was clogged with hanging jobs from another site, so the browser run drove run_ticker synchronously via bench execute (same realtime path). The RQ enqueue/dedup itself is unchanged standard frappe.enqueue.

Branches

  • Work committed on feat/live-quiz-ticker (spec commit + implementation commit).
  • Branched experimental off it and merged feat/live-quiz-ticker in (fast-forward, same commits). Both branches local only, not pushed.

Phase 9: README with screenshots (2026-07-20)

Spec: specs/phase-9-documentation.md. The README was still the app-scaffold default. It is now the front door: hero image, what-it-is, a collapsed gallery of eight screenshots, features, stack, dev setup, testing, contributing.

Done

  • Eight screenshots in docs/images/, all from one real session against trivia-tap.localhost (520 KB total). Host shots at 1440x900, player shots at 390x844 with DPR 2, one player shot in dark theme. Podium needed 1440x1010 to fit its leaderboard and button in a single frame.
  • scripts/seed_demo.py: creates the "General Knowledge" demo quiz. Run with bench --site trivia-tap.localhost console < scripts/seed_demo.py.
  • scripts/demo_bots.py: joins seven named players over the guest HTTP API and answers questions for them at plausible accuracies, so the lobby, distribution and podium look like a real game. Both scripts are throwaway tooling for pictures, not fixtures.
  • No CI badges: this repo has no git remote yet, so the badge URLs would not resolve. Add them with the remote.

Verified

bench --site trivia-tap.localhost run-tests --app trivia_tap: 56 tests, all green. pre-commit run --all-files: clean. Every command in the README was run as written.

Notes

  • The bench runs a single bench worker serving short, default and long. The game loop is enqueued on long, and while that worker was busy with another site's scheduled jobs the loop never started, so is_abandoned fired at 30s and every session jumped straight to an all-zero podium. Captures needed a dedicated frappe worker --queue long alongside it. Not an app bug, but a real deployment constraint: a live game needs a worker that is not competing with scheduled jobs.
  • The podium screen scrolls inside its own container, not the window, so with eight players a 900px-tall viewport clips the tail of the leaderboard and the "New game" button. Reachable, but only if you know to scroll there.

CI workflow fixes (2026-07-20)

Spec: specs/phase-8-ci.md, written after comparing our workflows against frappe/wiki. We already had wiki's server-test and linter jobs, on newer action and MariaDB versions than theirs. The one real gap is Playwright E2E, which wiki has and we don't, and which matters more here than there: trivia_tap is two browsers and a socket server, and the seam between them is exactly what bench run-tests cannot see.

The E2E harness is not built. It is half a day of work (root package.json, config, auth setup, two-context helpers, socketio under CI, countdown-timer flake), and /agent-browser already covers every fix manually, so the gap is "no unattended gate", not "untested app". Deferred until someone else contributes or a realtime regression ships unnoticed.

Three config fixes shipped now, no new dependency:

  • ci.yml concurrency group was develop-trivia-tap-${{ github.event.number }}. That expands to empty on push, so every push to develop shared one group key and cancelled the run before it. Now ${{ github.event.number || github.ref }}.
  • ci.yml gained paths-ignore for **.js, **.vue, **.css, **.ts. Frontend-only changes no longer build a bench to run python tests that cannot have changed.
  • Dropped the cypress/.* exclude from the eslint pre-commit hook. Inherited from boilerplate; no such directory ever existed here.

Verified

Both files parse as YAML. paths-ignore and the concurrency key only demonstrate themselves on a real PR, so neither is verified beyond that.

Theme toggle on the player side (2026-07-20)

The light theme shipped with a control on the host bar and in the lobby row, so only a logged-in host could switch. Players got whatever prefers-color-scheme said and no way out of it, which is backwards: the host is on one laptop they control, the players are on twenty phones in a room whose brightness nobody polled.

  • ThemeButton.vue wraps the existing cycleTheme from theme.js. Icon-only (🌗 auto, ☀️ light, 🌙 dark) with the label in aria-label and title, matching the mute button next to it rather than the host bar's text pill, because the player header is a phone header with no room for words.
  • Placed in the Play.vue header beside mute, and absolutely positioned top-right on Join.vue, which has no chrome of its own. HostBar.vue and Host.vue then dropped their "Theme: auto" text buttons for the same component, so one glyph means one thing everywhere in the app.
  • The component carries no styling of its own, only the glyph, the cycling and the label. The host wears it as a ctl pill, the players as a bare header icon, and .ctl is plain CSS that sits after Tailwind's utilities in index.css, so a component with default padding could not be overridden into a pill anyway. The player pages repeat the four utility classes rather than the component growing a variant prop.
  • No state added. theme.js already persists to localStorage and applies on load, so a player's choice survives join, the whole game, and a reload.

Verified

Real join at 390x844: toggled on the join screen (auto → light), joined a live session with a PIN from create_session, and the choice carried into the play header, where cycling to dark repainted the lobby. Host side at 1280x800: the pill in the quizzes bar and the one in the lobby control row both cycle and repaint. Test sessions deleted afterwards.

Phase 7: avatar carousel (2026-07-20)

Spec: specs/phase-7-avatar-carousel.md. Shipped as specced, Join.vue only, no backend.

  • Every slot keeps the large size and only the face inside scales (scale-[0.62] unselected, scale-100 selected). Sizing the button itself would reflow the row on every tap and jump the strip under the thumb; a transform does not reflow, so the row height and the join button below it stay put.

  • The ring, background, scale and opacity all moved onto a <span> inside the button. The button is now just the fixed slot, so the gold ring hugs the big face instead of a full-size slot around a shrunken one.

  • Centring became a watch on the selection with flush: "post" (plus the existing onMounted call, since an immediate watcher fires before the DOM exists). motion-safe:scroll-smooth animates it, and is motion-safe because CSS scroll-behavior is not covered by the global reduced-motion transition-duration override.

  • Strip moved out of the desktop right column (md:col-span-2) and dropped its md:max-w-sm cap, so desktop reads PIN, nickname, faces, join, the same order a phone already had.

  • Then the desktop layout went away entirely: Join.vue has no md: classes left. The two-column grid was only ever there to balance the tall 6-column avatar block, and once the faces were a strip the wide form was two stretched inputs next to each other and nothing else. One centred max-w-sm column at every width, so what a host sees on a laptop is what the players see on their phones. Same reason the strip keeps its gutter bleed on desktop now: the cut-off faces at the edge are what say it scrolls, at any width.

  • Strip trim: .no-scrollbar (new utility in index.css, scrollbar-width: none plus the -webkit-scrollbar rule) hides the desktop bar under the row, which was redundant next to the cut-off faces at the gutter. pb-1 became py-1.5, because overflow-x: auto also clips vertically and the selected face's ring was losing its top edge. Gap tightened to gap-1, since a shrunken 27px face inside a 44px slot already carries most of the spacing.

Verified

Headless browser at 390x844 and 1280 wide. Phone: opening random pick lands centred and big, tapping the last face in the roster scrolls the strip to its end (scrollLeft 890 of max 890) with the join button unmoved. Desktop: strip is the full-width row between the inputs and the join button. pre-commit clean on the changed file.

Phase 5 leftovers: light theme and avatar strip (2026-07-20)

Spec: specs/phase-5-visual-identity.md, both sections rewritten from "Deferred" to what shipped. Phase 5 now has nothing outstanding.

Light theme

  • The spec's plan was to make all eight tokens flip. That was the wrong split, and building it showed why: the four answer inks are the brand. A player learns "red is top-left" once, and a tile that changes hue with the room breaks the one thing the colour scheme exists to do. They do not theme at all.
  • What themes is the ground and what sits on it: night / dusk / haze / paper, plus alert / accent / ok. The second trio is the ember, gold and lagoon hues as text on the ground, which has to carry 4.5:1 and so darkens where the fill below it cannot. One token could not be both a vivid tile and legible small text on white; splitting the roles is what the original plan missed.
  • Fixed, never themed: ember, lagoon, gold, orchid (the tiles), sunk (the ink they carry), card (the QR quiet zone, which needs a light ground in either theme).
  • Tokens are RGB channel triplets, not hex, so Tailwind's opacity modifiers keep working: text-paper/40 is used 13 times.
  • prefers-color-scheme picks the default. A Theme control on the host bar and in the lobby control row overrides it in both directions and persists in localStorage. The OS preference alone was not enough: the case this exists for is a bright room, and the laptop driving the projector is usually still set to dark. It is in the lobby row as well as the bar because the bar is hidden once a game starts, which is exactly when a host notices the screen washing out.
  • Deleted SHAPES[].hex in passing: dead since phase 5, nothing read it.

Avatar scroll strip

  • One horizontal overflow-x: auto row with snap-x, capped max-w-sm on desktop. It bleeds past the page gutter on a phone, because the faces cut off at both edges are the only thing that says it scrolls.
  • The opening pick is random and lands anywhere in the roster, so the selected button scrolls itself into view on mount. Without that the strip opens on the first face and nothing looks chosen.

Verified

Full game in a real browser, host at 1440x900 and two guests: picker, lobby, read time, question (with and without an image), reveal, leaderboard, podium, plus both player views, in light and again in dark to check for regressions. Join screen at a real 390x844 viewport in both themes: the strip is one row and the join button clears the fold. Toggle checked to persist across a reload. 56 tests green, pre-commit run --all-files clean.

Known gap

--color-scheme emulation reverted to dark partway through a browser session more than once, so the OS-preference path was confirmed on the join page and the toggle carried the rest of the run. The two paths set the same tokens.

Fix: host screen on a phone (2026-07-20)

No spec. Host.vue only, no API and no doctype change. Every host screen was sized for a projector and broke at 390px.

Done

  • Lobby: PIN text-6xl up to sm:text-8xl, QR size-40 up to sm:size-48, padding and gaps scaled, and the PIN block centres under the QR on a phone. The copy button moved inside the join-host <p> as an inline element, so it follows the last line when the URL wraps instead of floating beside the first.
  • Question: the question text takes its own full-width row below the timer ring and the answer counter (flex-wrap plus order-last w-full sm:order-none). Beside a 96px ring and a counter it had a column too narrow to read two words in. The question image caps at 22vh under sm, which keeps all four options on screen without scrolling.
  • Results and podium: display sizes scaled, podium columns w-24 up to sm:w-36, and leaderboard rows got gap-3 with truncate on the nickname and shrink-0 on the score, which used to collide.

Verified

Live on trivia-tap.localhost at 390x844: lobby with and without a player, get-ready, question, closed stats, podium. Desktop at 1440x900 renders identically to before on the lobby and question screens.

Notes

  • The bench long queue is backed up with jobs from other sites behind a single worker, so run_game_loop never gets picked up: a started game sits on "Starting…" until is_abandoned settles it and the host lands on the podium. Testing ran the loop directly with bench execute trivia_tap.engine.run_game_loop. Bench config, not app code.

Phase 6: Navigation (2026-07-19)

Spec: specs/phase-6-navigation.md. Frontend only, no API and no doctype change.

Done

  • components/HostBar.vue on /host, /host/quizzes, /host/quizzes/:name: wordmark, Host/Quizzes links with the existing data-on active treatment, the logged-in user, and logout through the framework's own logout method. Rendered by the three screens rather than by a layout route, because App.vue is a bare <router-view /> and turning it into a layout host would cost more than the three lines of markup it saves.
  • The bar is hidden the moment /host holds a live session. A lobby, question, or podium is what 40 people in a room look at, and nav on it competes with the PIN.
  • The per-screen back links it replaces are gone (← Back to hosting, ← All quizzes), as is the picker's Edit quizzes link; the picker keeps a New quiz CTA only while the host has no quizzes.
  • readError(e) in api.js now backs every host screen's catch. A non-host used to get whatever the framework said, which names doctypes and permissions; the editor did not translate it at all.
  • End game names the cost: "End the game for all N players". A mid-game leave keeps the participant row on purpose (scores), so the count stays truthful after someone walks out.
  • Player Leave moved from the lobby and podium screens into the persistent header, so it exists during a question too. Hidden on the podium, where Back to join is already the primary action, and on kicked, where the header does not render.

Verified

End to end on trivia-tap.localhost: host nav across all three screens with the active pill correct, bar gone the instant a game started and back after New game, a player leaving mid-question and landing on /join, logout landing on /join, and a logged-out /host bouncing to login with the redirect intact. 56 tests green, pre-commit run --all-files clean.

Deferred

  • Session history nav. /host/history is specced in phase 4b and unbuilt. It becomes one more link in the bar and nothing else, which is the point of having the bar in one component now.
  • Mobile host bar. Plain text links fit at 390px. Collapse it when a fourth or fifth link makes it wrap.

Phase 4a: Content authoring (2026-07-19)

Spec: specs/phase-4a-content-authoring.md. The last slice of phase 4, shipped after 4b/4c and phase 5. Removes the Desk-only authoring constraint: a host now writes and plays a quiz without leaving the SPA.

Done

  • TT Question.image (Attach Image). question_payload carries image_url, null when absent, so an image-free quiz publishes exactly what it published before. Rendered contained above the answer grid on both screens: 40vh on the projector, 26vh on the phone, so options never leave the fold.
  • Authoring runs on frappe.client.* (get, insert, save, delete), plus one endpoint, list_quizzes, for the per-quiz question count. Four hand-written CRUD APIs were built first and then deleted: they re-implemented the framework, and their by-hand ownership check duplicated if_owner on TT Quiz. They were not a smaller attack surface either, since frappe.client.save is whitelisted for every logged-in user regardless.
  • Two rules the standard path imposes on the client, both found by testing and now pinned: send the loaded doc back as it came (frappe refuses a save that drops creation or owner, and rejects a stale modified, which is concurrent-edit protection the hand-written save_quiz silently lacked), and rebuild the question rows without name or idx (frappe keeps an idx it is given, so rows carrying the old one ignore a reorder).
  • Validation split by kind. The TT Quiz controller owns integrity (at least one question, non-blank text and options, correct_option in 1..4) so nothing on any path can write a quiz the engine cannot play. The editor owns the 5..120 second range with native min/max and a clamp, because it is an authoring taste: engine tests use 1 to 2 second questions on purpose, and putting the range in the controller broke 26 of them for no gain.
  • /host/quizzes (list, create, delete) and /host/quizzes/:name (editor). Options are edited inside the four game-coloured pills with the correct-option radio in place, so the author sees the player's screen while writing. Image upload is frappe-ui's FileUploader against the framework's upload_file, no custom endpoint.
  • The /host quiz picker now reads list_quizzes instead of frappe.client.get_list and links to the editor.
  • Tests: 5 in tests/test_authoring.py, covering only what this app adds to the standard path (reorder through frappe.client.save landing in idx order, the controller's rejections, the question count, LinkExistsError on a played quiz, image_url present and null). 56 green across the app.

Exit criteria verified

Quiz written entirely in the SPA (two questions, a checkerboard PNG uploaded onto the first), then hosted and played to podium with a real guest in a second browser: image on both host and player question screens, answers scored (1379), podium reached. Desk never opened. The image-free question rendered identically to before. pre-commit run --all-files clean.

Fixed while testing

  • The editor showed 0 in an unset time-limit field, because an unset Frappe Int reads back as 0. It now loads as empty and shows the quiz default as the placeholder.
  • Native radios inside the coloured answer pills rendered as a white disc with a blue dot: frappe-ui's stylesheet fills inputs, and accent-color alone could not fix the ground. They are appearance-none circles drawn from the border now.
  • A refused delete printed the framework's link error verbatim, HTML and all, so the host read raw <a href> markup pointing into Desk. The list now catches LinkExistsError by exc_type and says the quiz has been played.

Deferred

  • Drag-reorder. Up/down buttons instead, no dependency. Build drag when a host writes ~30-question quizzes and moving a row means crossing a screenful.
  • Uploaded images are not attached to the parent quiz, because a new quiz has no name yet when the picture is picked. They are ordinary public File rows, so a removed image leaves a file behind. Attach them (and clean up) when the file list gets noisy.

Phase 5: Visual identity (2026-07-19)

Spec: specs/phase-5-visual-identity.md. Engine untouched; CSS, markup, and one new component.

Done

  • Night-sky direction replaces the Kahoot lookalike. Eight tokens in tailwind.config.js (night, dusk, haze, paper, ember, lagoon, gold, orchid); the four answer inks differ in hue and lightness so they survive a washed-out projector and colourblind players.
  • Answer marks are now bolt, spark, moon, hex. svgFill stays a spelled-out literal per shape, same reason as before: Tailwind only generates class names it can see.
  • Type: Bricolage Grotesque display, Instrument Sans body, Martian Mono for PIN, timers, scores, ranks. Loaded in frontend/index.html with system fallbacks.
  • Signature: components/DrainRing.vue, one conic-gradient arc behind a radial mask. Time drains out of a ring instead of sliding along a bar, at three sizes across the phone and the projector, pulsing under 5 seconds.
  • Player question screen is full-bleed 2x2 tiles below a slim question strip, so a thumb reaches any corner.
  • Host read time got its own centred screen with a gold countdown; it was a bare left-aligned line with no timer, because the host never started a countdown on get_ready. Now it does, on both the live event and applyState.
  • Answer distribution bars sit in dusk tracks aligned to the answer grid. Before, a 0-vote bar was a hairline floating in a void.
  • Host controls use .ctl / .ctl-go in index.css rather than frappe-ui Button, which only ships a light theme.
  • Quality floor: one gold :focus-visible outline for every control, prefers-reduced-motion collapsing the ring pulse, podium rise, and bar growth, responsive to 390px.
  • Fixed a pre-existing ruff UP033 in passing: avatars.load_pack now uses functools.cache.

Verified

Full 4-question game in a real browser, host at 1440x900 plus two guest phones at 390x844: quiz picker, lobby with QR and player chips, read time, question, locked-in, reveal with distribution and leaderboard, podium, both host and player views. pre-commit run --all-files clean.

Deferred

Both are written up in the spec with the trigger condition, not just the idea.

  • Light theme. App is dark-only. Build it when someone hosts in a bright room and reports it washing out; the answer inks would need their own light-ground values, not an inversion.
  • Avatar picker as a scroll strip. The 6-column grid fits at 24 avatars. Build it when a pack ships more than ~30 and the join button drops below the fold.

Fixes found in end-to-end testing (2026-07-19)

A full host + two-player run in a real browser turned up three defects, all now fixed.

  • Host stuck on a dead game. A session whose loop worker died stayed Active forever, and get_live_host_session kept handing it back, so the quiz picker never returned and "New game" was a no-op. end_session was no better: it only set a Redis control flag that no loop was left to read. engine.is_abandoned now names the condition (Active, no loop state, older than the state TTL), get_live_host_session reaps every abandoned session it walks past, get_host_state settles a remembered one into its podium, and end_active_session ends a loopless game directly instead of flagging it. Five tests in test_game_ux.py, including one that a Lobby waiting for players is never reaped.
  • Countdown bar was invisible. Both the host and player timer bars used bg-ink-gray-9, which frappe-ui defines as an ink (text) token only, so the fill computed to rgba(0,0,0,0) and the bar always read as empty. Now bg-surface-gray-7.
  • Locked-in shape rendered black. The confirmation shape built its SVG class at runtime with fill.replace("bg-", "fill-"), so Tailwind never saw those class names and only generated the ones that happened to appear elsewhere: red worked, blue, amber and green came out black. SHAPES now carries an svgFill literal per shape.
  • A quiet socket froze a screen for good. Two player tabs stopped receiving events mid-game and never recovered: the design has no polling fallback, and useSessionRoom only re-joined the room on a connect event that never came. It now tracks the time of the last event and re-joins (plus resyncs) after 20 seconds of silence, so a lost room membership or a reconnect that never lands costs one get_state instead of the rest of the game. Long pauses between questions are normal, hence the generous threshold; verified in the browser that a silent stats pause triggers exactly one resync per interval and an active game triggers none.
  • Result badge was unreadable. The correct/wrong circle used bg-surface-green-3 and bg-surface-red-3, two tokens with opposite lightness, so no single glyph colour worked: a black ✓ on dark green, then a white ✕ on pale red. Both now use the strong game palette (bg-green-600 / bg-red-500) with white glyphs.
  • Tests leaked their fixtures onto the site. Engine steps commit mid-test, so the framework rollback left every test quiz and session behind; the host's quiz picker had grown to 26 stray "Engine Quiz" entries and 6 sessions stuck Active. GameTestCase.tearDown now deletes what it created, and the existing junk was purged.

Verified

Full 4-question game, host plus two guests, played through get-ready, live answer counts, reveal with distribution, streak callout and podium. Scores matched the engine (Ada 2043, Grace 2041).

Phase 4c: Player fun (2026-07-19)

Phase 4 was split into four independently shippable slices (specs/phase-4a..4d); this is the third.

Done

  • Avatar packs. A pack is a JSON manifest in trivia_tap/avatar_packs/ holding the roster, the background palette, and the framing; site_config.trivia_tap_avatar_pack picks the active one. trivia_tap/avatars.py loads it and hands it to the SPA through the existing portal boot context, so the roster has one source of truth and the join path costs no extra request. Shipped pack is DiceBear notionists (CC0, 24 avatars).
  • yarn build:avatars pre-renders kind: "dicebear" packs to static SVG under trivia_tap/public/avatars/<pack>/, output committed. The DiceBear libraries are devDependencies only and never reach the runtime bundle; at runtime an avatar id is just an <img> URL, which is also how a bought kind: "static" pack drops in with no code change.
  • TT Participant.avatar, validated in the controller against the active roster. join_session takes an optional avatar and falls back to a crc32-of-nickname pick. avatar now rides along on lobby updates, leaderboards, top-5, streak callouts, podium, and get_state.
  • Nickname generator: three suggestions with a reroll on the join screen. Word lists live in trivia_tap/nicknames.py and reach the SPA through the boot context.
  • Sound synthesised with Web Audio (frontend/src/sound.js): countdown tick, submit blip, correct/wrong stings, podium arpeggio, plus a persisted mute toggle on both screens.
  • Tests: 9 new (test_avatars.py, test_nicknames.py). 45 green across the app.

Exit criteria verified

Full 4-question game in headless Chrome (host + two players) against the live site: both players picked distinct avatars and generated nicknames, and those avatars showed on the host lobby chips, the live leaderboard, the player header, and both podiums. No console errors from the audio path. Contact sheet of all 24 avatars reviewed at render size.

Notes

  • No free avatar library matches the 3D-rendered reference look (Inner Teens); that style is a commercial category. The pack system exists so that decision stays reversible: swapping to a bought 3D pack is a manifest plus a folder.
  • notionists draws half-body portraits that read as a cropped torso in a circle. Framing (scale: 140, translateY: 25) is per-pack manifest data, chosen by rendering a comparison sheet.
  • An unknown avatar id is rejected rather than defaulted, so a stale client or a manifest entry that was never rendered fails loudly instead of showing a blank circle. A test asserts every manifest id has a file on disk.
  • trivia_tap/avatars.py (module) and trivia_tap/avatar_packs/ (data) are deliberately not the same name; a module and a package directory sharing a name in one directory breaks imports.
  • Players default to muted and the host defaults to audible: a classroom of phones all unmuting at once is a bad time.
  • Lobby background music is dropped from scope. A listenable loop is a composition, not a synth line.
  • Cleared three stale Active sessions from earlier phase testing; get_live_host_session picks the newest live session, so an abandoned one hides the quiz picker forever. Worth a real fix (auto-expire) if it recurs outside tests.

Phase 3: Game UX (2026-07-19)

Done

  • Player screen (Play.vue) is one state machine: lobby -> get-ready -> question -> locked-in -> result -> podium, plus a kicked terminal state. Kahoot shapes (triangle/diamond/circle/square, colour keyed to the canonical option id), local countdown bar, per-player answer shuffle seeded by the participant token, result interstitial with correct/wrong, points, streak, rank and top-5.
  • Host screen (Host.vue): lobby with giant PIN, client-side QR (qrcode), join URL, name grid, lock/kick/auto-advance/start; game view with live answer count, timer bar, correct-answer reveal, distribution bar chart, top-5 and streak callouts, next/skip/end; podium with a 1-2-3 stand and the full leaderboard.
  • Engine: a 3-second get_ready read-the-question pause before each question (own Redis phase, so reconnect lands in it too). question payloads now carry window_ms (clients count down from receipt, so client clock skew cannot matter) and randomize_answer_order.
  • New APIs: get_host_state (whole host screen in one call; finds the host's live session when no name is passed, so a reload restores mid-game), get_result (own outcome for the interstitial, keeping per-player data out of the broadcast), set_auto_advance (loop re-reads the flag each pause, so it can flip mid-game). get_state gained rank/leaderboard and now resolves Ended sessions so a player who reloads on the podium keeps it.
  • Nickname profanity filter in trivia_tap/profanity.py, applied in join_session: leetspeak folded, matched as a substring against a curated wordlist.
  • Tests: 9 new in tests/test_game_ux.py (filter both ways, host state in lobby/mid-question/non-host, own result and rank, podium after reload). 36 green across the app.

Exit criteria verified

Full 4-question game driven in headless Chrome with three browser sessions (host + two players) against the live site and a real RQ worker: get-ready countdown, shapes, per-player shuffle confirmed different for each player, correct/wrong interstitials with points, distribution chart, "Ada is on a 3 answer streak" callout, podium. Player and host both reloaded mid-question and landed back in the right phase with the right remaining time; both also restored the podium after reload. Profanity filter rejected Sh1tLord in the real join form.

Notes

  • Socket reconnects used to go silently deaf: socket.io reconnects on its own but the server-side room membership is gone, and tt_join was only emitted on mount. Found in E2E when a backgrounded host tab stopped receiving events and missed the podium. useSessionRoom now re-emits tt_join on every connect and resyncs from the state API.
  • A centered flex column (justify-center) clips its own top when the content overflows; the host game view uses m-auto on an inner wrapper instead.
  • Percentage heights collapse inside an items-end flex row (the parent's height is content-derived), which is why the first distribution chart rendered blank.
  • frappe-ui's tailwind preset caps fontSize at 3xl; 5xl and 6xl joined the existing 4xl/8xl overrides.
  • The game loop occupies one long-queue worker for the whole game. On this bench a single shared worker serves short/default/long, so an unrelated stuck job stalls every game; deployment wants dedicated long workers.

Phase 2: Game Engine (2026-07-19)

Done

  • trivia_tap/engine.py: RQ game loop (queue="long", job_id=tt_session_{name}, deduplicate, timeout sized to quiz length). Per question: Redis state write, question publish (no correct answer, server deadline_ts), sleep-with-poll until deadline + 1s grace, close, score, question_closed publish (correct option, distribution, top-5, streak callouts >= 3), then auto-advance after 5s stats or wait for host (capped at 5 min, then advances anyway). After last question: ranks persisted, podium published, status Ended, Redis state cleared.
  • Redis keys per spec: tt:{session}:state (dict, TTL window+30s), tt:{session}:answered:{question_row} (set, duplicate pre-check), plus tt:{session}:control for host commands (skip/advance/end) polled by the loop. Host controls never touch the loop process directly; the flag survives web/worker process boundary.
  • Scoring: Kahoot formula, response_ms clamped to window so grace submits floor at 500 base. Streak bonus capped at 250, multiplier 0/1/2. Non-answerers get streak reset at close.
  • APIs: host start_session (Lobby -> Active, enqueue loop, rejects empty lobby), next_question, skip_question, end_session (Lobby -> Cancelled, Active -> control flag). Guest submit_answer (full gauntlet in spec order, returns only {"ok": true}, publishes answer_count) and get_state (reconnect: phase, question sans answer, remaining_seconds, own score/answered). Both token-scoped rate-limited.
  • Tests: 19 in tests/test_engine.py, all green. Whole spec checklist covered: late/duplicate/wrong-question/kicked rejection, DB unique constraint as final word (Redis pre-check bypassed), scoring boundaries + streak reset + multipliers, no correct substring in any pre-close payload, reconnect remaining time, full loop to podium with scripted answers.

Exit criteria verified

Full game played start to podium over HTTP against the live site with the real RQ worker (2 players, 2 questions): questions arrived with correct remaining time, correct answer absent from payloads, duplicate submits got 417, scores/streaks/ranks persisted exactly per formula (checked in DB: 946 + 1982 = 2928, streak 2, rank 1), session Ended with podium event.

Notes

  • Loop commits after each publish so after_commit realtime events flush from the worker; submits land in separate web transactions and are visible at close.
  • wait_before_next accepts host advance even during the 5s stats pause; auto-advance mode ignores stray flags.
  • Host game-screen state API deliberately deferred to phase 3 (spec lists only guest get_state); host reconnect currently rides on the socket events.

Phase 1: Content + Session Shell (2026-07-19)

Done

  • All five DocTypes per spec: TT Quiz (+ child TT Question), TT Session, TT Participant, TT Answer. Permissions as specified: Quiz Host if_owner on Quiz/Session, System Manager only on Participant/Answer. TT Answer gets a DB-level unique index on (participant, question_row) via on_doctype_update.
  • trivia_tap/api.py: host APIs create_session, lock_lobby, unlock_lobby, kick_participant, get_lobby (host-only via session.host check); guest APIs join_session, leave_session (allow_guest, IP rate-limited 10/min). Tokens: 32-byte random, sha256 stored, raw returned once. Lobby changes publish lobby_update (and kicked) to room tt_session_{pin} with after_commit=True.
  • Nickname uniqueness (per session, non-kicked) validated in the TT Participant controller; kicked nicknames are freed for reuse.
  • trivia_tap/www/trivia_tap.py boot context injects csrf_token + site_name so frappe-ui requests work for logged-in hosts.
  • Frontend: Join.vue (PIN prefilled from ?pin=, nickname, error display), Play.vue (waiting room, live lobby count, kicked banner, leave), Host.vue (quiz picker, giant PIN, join link, live participant chips, lock toggle, click-to-kick). Player identity kept in localStorage (player.js), thin api.js wrapper over frappeRequest.
  • Tests: 8 integration tests in trivia_tap/tests/test_api.py, all green (bench --site trivia-tap.localhost run-tests --module trivia_tap.tests.test_api; needed set-config allow_tests true once).

Exit criteria verified

All checked E2E: over HTTP+socket (node client: join publishes lobby_update into the room), and in a real headless browser (two tabs, host + player): PIN/QR-link join, name pops on host screen live, wrong PIN 404, locked lobby 417 (error shown in UI), duplicate nickname 409, kick 200 with player seeing "The host removed you" and the kicked token rejected (403).

Notes

  • frappe-ui's tailwind preset caps fontSize at 3xl (24px); display sizes (4xl, 8xl) added in tailwind.config.js for the big PIN.
  • frappe.rate_limiter.rate_limit no-ops when frappe.request is absent, so direct calls in tests skip rate limits.
  • Deliberate shortcuts: host lobby state is in-memory (page refresh loses the session view; rejoin comes with get_state in phase 2); leave_session deletes the participant row and only in Lobby status.

Phase 0: Foundation + Spike (2026-07-19)

Done

  • App trivia_tap installed on trivia-tap.localhost (module TriviaTap).
  • Role Quiz Host created via fixture (trivia_tap/fixtures/role.json, synced on migrate).
  • SPA scaffold in frontend/: Vue 3 + frappe-ui + Vite, socket.io-client, vue-router with /join, /play, /host stubs. Production build outputs to trivia_tap/public/frontend and writes trivia_tap/www/trivia_tap.html. Served at /trivia-tap/* via website_route_rules. Verified: yarn build passes, /trivia-tap/join returns the SPA, yarn dev runs.

Spike decision: socket push for players. WON.

Question: can a guest (no login) socket.io connection receive events published to a custom room?

Answer: yes, verified empirically on this bench (frappe develop, v17):

  • Guest sockets authenticate with the sid=Guest cookie. frappe.realtime.get_user_info returns installed_apps from the site (not the user), so app-level socket handlers load for guests too.
  • The realtime node server loads apps/<app>/realtime/handlers.js per connecting socket. trivia_tap/realtime/handlers.js registers tt_join / tt_leave, which join/leave room tt_session_{pin} (PIN validated as 6 digits).
  • Test: node socket.io-client connected as Guest, emitted tt_join 123456, then frappe.publish_realtime(event="tt_session_123456", room="tt_session_123456") from the server. Event received by the guest client.

Consequence: players subscribe over socket.io (tt_join after joining a session). No 1s polling fallback is built. get_state stays planned for reconnect only.

Notes

  • Bench runs frappe v17.x-develop, not stable v16 as plan.md assumes. Spike result applies to this version.
  • bench start must be restarted after installing a new app: web workers only pick up the editable install at interpreter startup (symptom: ModuleNotFoundError: No module named 'trivia_tap' on every request).
  • Found and cleared a stale global maintenance_mode: 1 in common_site_config.json that 503'd every site on the bench.