From 7b4f0d0b844d2bd36de3a5412aeb08158fc1cbce Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 08:36:26 +0800 Subject: [PATCH] fix(web): pairing-window timer, reminder filter tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR / pairing - Replace the per-QR 30 s countdown with a single pairing-window timer matching the bot's PAIR_TIMEOUT (5 minutes). Baileys naturally rotates QR images every ~5 s — the previous 30 s bar reset on every rotation, which felt like a constantly-cycling timer to the user. - The new timer starts on the first QR and ticks down once; later QR rotations refresh the displayed image but leave the countdown alone. - Added a hint: "The QR rotates automatically every few seconds — scan whichever one is showing." Format switches to MM:SS. - countdownRender's danger threshold scales: 10 s for short windows (≤ 60 s), 30 s for the multi-minute pairing window, so the warning flash appears while the user can still react. Reminder filter tabs - Tabs are now: All / Active / Ended / Paused. "Failed" is dropped — reminder.status doesn't carry "failed" (run statuses do; that view belongs in /activity?filter=failed). Tests (+4 = 84 passing total) - qr-dedupe.test.ts: extended with a "pairing-window scaling" suite covering pct/danger/expired at 5-minute scale and the threshold split between short and long windows. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/app/reminders/page.tsx | 6 ++--- apps/web/src/components/pair-live.tsx | 34 ++++++++++++++++++++------- apps/web/src/lib/qr-dedupe.test.ts | 29 +++++++++++++++++++++-- apps/web/src/lib/qr-dedupe.ts | 13 ++++++---- 4 files changed, 64 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 49a39cd..6334ca2 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -12,7 +12,7 @@ import { describeRecurrence, specFromRrule } from "@/lib/recurrence"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -type FilterValue = "all" | "active" | "ended" | "failed"; +type FilterValue = "all" | "active" | "ended" | "paused"; // --------------------------------------------------------------------------- // Helpers @@ -61,7 +61,7 @@ const FILTER_TABS: { value: FilterValue; label: string }[] = [ { value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "ended", label: "Ended" }, - { value: "failed", label: "Failed" }, + { value: "paused", label: "Paused" }, ]; // --------------------------------------------------------------------------- @@ -74,7 +74,7 @@ interface PageProps { export default async function RemindersPage({ searchParams }: PageProps) { const { filter: rawFilter } = await searchParams; const filter: FilterValue = - rawFilter === "active" || rawFilter === "ended" || rawFilter === "failed" + rawFilter === "active" || rawFilter === "ended" || rawFilter === "paused" ? rawFilter : "all"; diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx index 97ba945..2f65cb0 100644 --- a/apps/web/src/components/pair-live.tsx +++ b/apps/web/src/components/pair-live.tsx @@ -22,11 +22,13 @@ interface PairLiveProps { function CountdownBar({ seconds, total }: { seconds: number; total: number }) { const { pct, danger, expired } = countdownRender(seconds, total); + const mm = Math.floor(seconds / 60); + const ss = String(seconds % 60).padStart(2, "0"); return (
- {expired ? "QR expired — waiting for refresh" : "QR expires in"} + {expired ? "Pairing window expired" : "Pairing expires in"} {!expired && ( - {seconds}s + {mm}:{ss} )}
@@ -51,18 +53,27 @@ function CountdownBar({ seconds, total }: { seconds: number; total: number }) { ); } -const COUNTDOWN_TOTAL = 30; +// Match the bot's PAIR_TIMEOUT_MS — 5 minutes — so the pairing-window +// timer is the single source of truth for "you have to scan by then". +// Individual QR rotations (~5 s each from Baileys) refresh the displayed +// QR but do NOT reset this timer — that would punish the user with a +// constantly-resetting countdown. +const PAIRING_WINDOW_SEC = 5 * 60; export function PairLive({ accountId, label }: PairLiveProps) { const router = useRouter(); const [pairingState, setPairingState] = useState({ phase: "waiting" }); - const [countdown, setCountdown] = useState(COUNTDOWN_TOTAL); + const [countdown, setCountdown] = useState(PAIRING_WINDOW_SEC); const timerRef = useRef | null>(null); + const startedRef = useRef(false); - // Reset and start countdown when QR arrives + /** Start the pairing-window countdown once. Subsequent QR refreshes do + * not restart this — only mount/connect/timeout/unmount touches it. */ const startCountdown = () => { + if (startedRef.current) return; + startedRef.current = true; if (timerRef.current) clearInterval(timerRef.current); - setCountdown(COUNTDOWN_TOTAL); + setCountdown(PAIRING_WINDOW_SEC); timerRef.current = setInterval(() => { setCountdown((c) => { if (c <= 1) { @@ -79,6 +90,8 @@ export function PairLive({ accountId, label }: PairLiveProps) { if (data.accountId !== accountId) return; // Bust the URL with the timestamp so the browser refetches each time. setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` }); + // Idempotent — only the first QR starts the global pairing-window + // timer; later QR rotations leave it ticking. startCountdown(); }, "session.connected": (data) => { @@ -130,8 +143,10 @@ export function PairLive({ accountId, label }: PairLiveProps) { {pairingState.phase === "qr" && (
- {/* Countdown — separate from the QR so it doesn't obstruct scanning */} - + {/* Pairing-window countdown — single source of truth. + The QR image rotates every ~5 s but this timer keeps ticking + against the bot's PAIR_TIMEOUT (5 min). */} + {/* QR image */} {/* eslint-disable-next-line @next/next/no-img-element */} @@ -148,6 +163,9 @@ export function PairLive({ accountId, label }: PairLiveProps) {

Open WhatsApp → tap ⋮ → Linked Devices → Link a device

+

+ The QR rotates automatically every few seconds — scan whichever one is showing. +