From 1e89137fa647b33cc866a57394542ed4073efac7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 30 Apr 2026 19:32:09 +0700 Subject: [PATCH] chore(plans): add countdown indicator plan + review report --- .../phase-01-build-autocountdown.md | 110 +++++++++++++++ .../phase-02-integrate-and-verify.md | 130 ++++++++++++++++++ .../260430-1919-auto-call-countdown/plan.md | 36 +++++ ...ode-reviewer-260430-1925-auto-countdown.md | 124 +++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 web/plans/260430-1919-auto-call-countdown/phase-01-build-autocountdown.md create mode 100644 web/plans/260430-1919-auto-call-countdown/phase-02-integrate-and-verify.md create mode 100644 web/plans/260430-1919-auto-call-countdown/plan.md create mode 100644 web/plans/reports/code-reviewer-260430-1925-auto-countdown.md diff --git a/web/plans/260430-1919-auto-call-countdown/phase-01-build-autocountdown.md b/web/plans/260430-1919-auto-call-countdown/phase-01-build-autocountdown.md new file mode 100644 index 0000000..acffaa7 --- /dev/null +++ b/web/plans/260430-1919-auto-call-countdown/phase-01-build-autocountdown.md @@ -0,0 +1,110 @@ +--- +phase: 1 +title: "Build AutoCountdown component" +status: completed +priority: P2 +effort: "1h" +dependencies: [] +--- + +# Phase 1: Build `AutoCountdown.svelte` + +## Overview + +Self-contained countdown component: shows seconds-remaining number with a +circular SVG ring that depletes from full → empty over each tick interval. +No knowledge of game state — pure visual driven by props. + +## Requirements + +**Functional** +- Display integer seconds remaining (e.g. `5 → 4 → 3 → 2 → 1`) +- Render circular progress ring (SVG) that depletes smoothly during the tick +- Reset to full whenever a new tick starts (parent signals via `tickKey` prop change) +- Pause/hide cleanly when `running === false` + +**Non-functional** +- Smooth animation via `requestAnimationFrame` — no `setInterval` polling +- Respect `prefers-reduced-motion`: skip ring animation, show only number +- File ≤ 200 lines (KISS — keep visual logic only) +- No localStorage / no settings reads — props-driven only + +## Architecture + +**Props** +```js +{ + running: boolean, // master is auto-calling + duration: number, // seconds per tick (settings.autoCallSpeed, 1..10) + tickKey: number, // changes on each draw — triggers ring reset +} +``` + +**Internal state** +- `tickStart` (`$state`, ms timestamp): set to `performance.now()` when + `tickKey` changes or `running` flips on +- `now` (`$state`, ms): updated by rAF loop while `running === true` +- `secondsRemaining` (`$derived`): `Math.ceil(duration - (now - tickStart) / 1000)` + clamped to `[0, duration]` +- `progress` (`$derived`): `(now - tickStart) / (duration * 1000)` clamped to `[0, 1]` + +**rAF loop** +- Single `$effect` keyed on `running`: starts loop when true, cancels on cleanup +- Loop sets `now = performance.now()`, then `requestAnimationFrame(loop)` +- When `running === false` → no rAF active, render last frame statically + +**SVG ring** +- Outer `` square, sized via wrapper class +- Background track: full circle, light stroke +- Progress arc: same circle, `stroke-dasharray = circumference`, + `stroke-dashoffset = circumference * progress` → arc shrinks as time elapses +- Rotated `-90deg` so depletion starts at 12 o'clock and goes clockwise + +**Reduced motion fallback** +- Detect once via `window.matchMedia('(prefers-reduced-motion: reduce)')` +- If set → skip rAF loop; update `now` only on `tickKey` change (one frame) +- Number still updates per tick (jumps from `5 → 4 → 3 ...`); ring stays full + +## Related Code Files + +- Create: `src/lib/AutoCountdown.svelte` + +## Implementation Steps + +1. Scaffold `
+ +
+ {/if} + ``` +6. Run `npm run lint` — fix any warnings +7. Run `npm test` — ensure all 53 tests still pass +8. Run `npm run dev`, open browser, manually verify: + - Enable master mode + auto-call in settings + - Click "Bắt đầu" → countdown appears, ring depletes, number ticks down + - Each new draw resets the countdown + - Click "Dừng" → countdown disappears + - Change `autoCallSpeed` mid-run → next tick uses new duration cleanly + - Toggle reduced-motion (DevTools → Rendering tab) → ring stays static, number still updates + +## Success Criteria + +- [ ] Countdown visible only during active auto-call +- [ ] Smooth ring animation on default-motion devices +- [ ] Number resets to `autoCallSpeed` value at each tick +- [ ] No console errors / no leaked rAF after stopping +- [ ] All 53 existing vitest tests pass +- [ ] `npm run lint` clean +- [ ] `npm run build` succeeds + +## Risk Assessment + +- **Speed change mid-run**: parent's `$effect` tears down + re-arms the + `setInterval` when `settings.autoCallSpeed` changes (already handled, + see line 116). The `AutoCountdown` `duration` prop will also flow + through, so its derived calculations re-base. Need to bump `tickCount` + on speed change too — otherwise ring shows wrong progress until next + natural tick. Mitigation: bump `tickCount++` inside the auto-call + `$effect` body so any re-arm (running, speed, enabled) resets the ring. +- **CSP impact**: SVG inline + Svelte-injected style attrs (e.g. + `stroke-dashoffset`) — already permitted by existing CSP setup + (see `scripts/inject-csp-hashes.mjs`). No new CSP work needed. +- **Build size**: one small SVG component, negligible. + +## Verification Checklist (manual) + +- [ ] `npm run dev` starts cleanly +- [ ] Settings → enable "Chế độ quản trò" + "Tự động xổ" +- [ ] Click "Bắt đầu", observe countdown +- [ ] Watch ≥3 ticks — verify smooth depletion + reset +- [ ] Change speed slider → ring re-bases without glitch +- [ ] DevTools → Rendering → "prefers-reduced-motion: reduce" → verify static fallback +- [ ] Stop, verify countdown unmounts; rAF count in DevTools idle + +## Docs Impact + +- Minor: add brief note to `docs/codebase-summary.md` about the new component + and to `docs/system-architecture.md` if it lists key UI components diff --git a/web/plans/260430-1919-auto-call-countdown/plan.md b/web/plans/260430-1919-auto-call-countdown/plan.md new file mode 100644 index 0000000..360dad1 --- /dev/null +++ b/web/plans/260430-1919-auto-call-countdown/plan.md @@ -0,0 +1,36 @@ +--- +title: "Auto-call countdown indicator" +status: completed +created: 2026-04-30 +completed: 2026-04-30 +slug: auto-call-countdown +--- + +# Auto-call countdown indicator + +Show a visible countdown (number + shrinking circular ring) while the master +panel auto-calls numbers, so the host knows exactly when the next draw fires. + +## Why + +`MasterPanel.svelte` currently shows only a static "Tự động: Xs/số" line while +auto-call runs. Host has no per-tick feedback — UX feels dead between draws, +especially at slower speeds (5–10s). + +## Phases + +| # | Phase | Status | +|---|-------|--------| +| 1 | [Build `AutoCountdown.svelte`](phase-01-build-autocountdown.md) | completed | +| 2 | [Integrate into MasterPanel + verify](phase-02-integrate-and-verify.md) | completed | + +## Key Files + +- Create: `src/lib/AutoCountdown.svelte` +- Modify: `src/lib/MasterPanel.svelte` + +## Out of Scope + +- Sound/vibration on tick — voice already speaks when number is drawn +- Configurable countdown styling — match existing token visual language +- Player-side countdown (this is master-only) diff --git a/web/plans/reports/code-reviewer-260430-1925-auto-countdown.md b/web/plans/reports/code-reviewer-260430-1925-auto-countdown.md new file mode 100644 index 0000000..eba5298 --- /dev/null +++ b/web/plans/reports/code-reviewer-260430-1925-auto-countdown.md @@ -0,0 +1,124 @@ +--- +title: Code review — AutoCountdown + MasterPanel integration +reviewer: code-reviewer +date: 2026-04-30 +slug: auto-countdown +scope: + - src/lib/AutoCountdown.svelte (new) + - src/lib/MasterPanel.svelte (modified) +plan: plans/260430-1919-auto-call-countdown/ +--- + +# Code Review — Auto-call countdown + +## Summary + +Implementation matches the plan. rAF cleanup, off-by-one clamp, reactivity, and +race conditions all look sound under Svelte 5 runes semantics. Two **minor** +items worth a follow-up; otherwise good to ship. + +## Findings + +### Critical +None. + +### Major +None. + +### Minor + +**M1. Hidden coupling: `AutoCountdown` reset effect doesn't depend on `duration`.** +File: `src/lib/AutoCountdown.svelte:26-32` + +```js +$effect(() => { + tickKey; // subscribe + if (running) { + tickStart = performance.now(); + now = tickStart; + } +}); +``` + +Reset only re-bases on `tickKey` or `running` rising edge. If a parent ever +changes `duration` without also bumping `tickKey`, the ring's progress jumps +mid-tick (because `totalMs` recomputes while `elapsedMs` is unchanged). + +Today this is safe: `MasterPanel`'s auto-call `$effect` reads +`settings.autoCallSpeed`, so any speed change tears down + re-arms the effect +which bumps `tickCount` (line 129). The component contract is implicit, not +enforced. + +Fix (cheap, robust): include `duration` in the reset effect: + +```js +$effect(() => { + tickKey; + duration; // also re-baseline if duration changes without a tick bump + if (running) { + tickStart = performance.now(); + now = tickStart; + } +}); +``` + +**M2. `reduceMotion` keeps rAF loop alive needlessly.** +File: `src/lib/AutoCountdown.svelte:36-43` + +When `reduceMotion === true`, `dashOffset` is forced to 0 (static ring), but +the rAF loop still runs at ~60Hz updating `now` purely to drive +`secondsRemaining`. A 1Hz `setInterval` (or skipping the loop and updating +`now` only on `tickKey` change as the plan suggested) would be cheaper and +match the plan spec verbatim. Not user-visible; pure CPU hygiene. + +### Nits + +**N1. `let tickStart = $state(performance.now())` at module top-level.** +Runs at component instantiation, not module import (Svelte 5 compiles `