fix(web): pairing-window timer, reminder filter tabs

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 08:36:26 +08:00
parent 6cb387bf59
commit 7b4f0d0b84
4 changed files with 64 additions and 18 deletions

View File

@ -12,7 +12,7 @@ import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type FilterValue = "all" | "active" | "ended" | "failed"; type FilterValue = "all" | "active" | "ended" | "paused";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -61,7 +61,7 @@ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "ended", label: "Ended" }, { 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) { export default async function RemindersPage({ searchParams }: PageProps) {
const { filter: rawFilter } = await searchParams; const { filter: rawFilter } = await searchParams;
const filter: FilterValue = const filter: FilterValue =
rawFilter === "active" || rawFilter === "ended" || rawFilter === "failed" rawFilter === "active" || rawFilter === "ended" || rawFilter === "paused"
? rawFilter ? rawFilter
: "all"; : "all";

View File

@ -22,11 +22,13 @@ interface PairLiveProps {
function CountdownBar({ seconds, total }: { seconds: number; total: number }) { function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
const { pct, danger, expired } = countdownRender(seconds, total); const { pct, danger, expired } = countdownRender(seconds, total);
const mm = Math.floor(seconds / 60);
const ss = String(seconds % 60).padStart(2, "0");
return ( return (
<div className="flex w-full max-w-64 flex-col gap-1"> <div className="flex w-full max-w-64 flex-col gap-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{expired ? "QR expired — waiting for refresh" : "QR expires in"} {expired ? "Pairing window expired" : "Pairing expires in"}
</span> </span>
{!expired && ( {!expired && (
<span <span
@ -34,7 +36,7 @@ function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
danger ? "text-destructive" : "text-foreground" danger ? "text-destructive" : "text-foreground"
}`} }`}
> >
{seconds}s {mm}:{ss}
</span> </span>
)} )}
</div> </div>
@ -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) { export function PairLive({ accountId, label }: PairLiveProps) {
const router = useRouter(); const router = useRouter();
const [pairingState, setPairingState] = useState<PairingState>({ phase: "waiting" }); const [pairingState, setPairingState] = useState<PairingState>({ phase: "waiting" });
const [countdown, setCountdown] = useState(COUNTDOWN_TOTAL); const [countdown, setCountdown] = useState(PAIRING_WINDOW_SEC);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | 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 = () => { const startCountdown = () => {
if (startedRef.current) return;
startedRef.current = true;
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setCountdown(COUNTDOWN_TOTAL); setCountdown(PAIRING_WINDOW_SEC);
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setCountdown((c) => { setCountdown((c) => {
if (c <= 1) { if (c <= 1) {
@ -79,6 +90,8 @@ export function PairLive({ accountId, label }: PairLiveProps) {
if (data.accountId !== accountId) return; if (data.accountId !== accountId) return;
// Bust the URL with the timestamp so the browser refetches each time. // Bust the URL with the timestamp so the browser refetches each time.
setPairingState({ phase: "qr", qrUrl: `/api/qr/${accountId}?t=${data.ts}` }); 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(); startCountdown();
}, },
"session.connected": (data) => { "session.connected": (data) => {
@ -130,8 +143,10 @@ export function PairLive({ accountId, label }: PairLiveProps) {
{pairingState.phase === "qr" && ( {pairingState.phase === "qr" && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
{/* Countdown — separate from the QR so it doesn't obstruct scanning */} {/* Pairing-window countdown single source of truth.
<CountdownBar seconds={countdown} total={COUNTDOWN_TOTAL} /> The QR image rotates every ~5 s but this timer keeps ticking
against the bot's PAIR_TIMEOUT (5 min). */}
<CountdownBar seconds={countdown} total={PAIRING_WINDOW_SEC} />
{/* QR image */} {/* QR image */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
@ -148,6 +163,9 @@ export function PairLive({ accountId, label }: PairLiveProps) {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Open WhatsApp tap Linked Devices Link a device Open WhatsApp tap Linked Devices Link a device
</p> </p>
<p className="text-xs text-muted-foreground/80 italic">
The QR rotates automatically every few seconds scan whichever one is showing.
</p>
<Button asChild variant="outline" size="sm" className="mt-1"> <Button asChild variant="outline" size="sm" className="mt-1">
<a <a
href={pairingState.qrUrl} href={pairingState.qrUrl}

View File

@ -52,7 +52,7 @@ describe("makeQrDedupe", () => {
}); });
}); });
describe("countdownRender — QR expired timer", () => { describe("countdownRender — short window (per-QR / ≤ 60 s)", () => {
it("at full window: 100% width, not danger, not expired", () => { it("at full window: 100% width, not danger, not expired", () => {
expect(countdownRender(30, 30)).toEqual({ pct: 100, danger: false, expired: false }); expect(countdownRender(30, 30)).toEqual({ pct: 100, danger: false, expired: false });
}); });
@ -67,7 +67,7 @@ describe("countdownRender — QR expired timer", () => {
expect(countdownRender(1, 30).danger).toBe(true); expect(countdownRender(1, 30).danger).toBe(true);
}); });
it("> 10 s is not in danger", () => { it("> 10 s is not in danger for short windows", () => {
expect(countdownRender(11, 30).danger).toBe(false); expect(countdownRender(11, 30).danger).toBe(false);
}); });
@ -89,3 +89,28 @@ describe("countdownRender — QR expired timer", () => {
expect(r.pct).toBeGreaterThanOrEqual(0); expect(r.pct).toBeGreaterThanOrEqual(0);
}); });
}); });
describe("countdownRender — pairing-window (5 min) scaling", () => {
const TOTAL = 5 * 60;
it("full pairing window: 100%, not danger", () => {
expect(countdownRender(TOTAL, TOTAL)).toEqual({ pct: 100, danger: false, expired: false });
});
it("4 minutes left: ~80%, not danger", () => {
const r = countdownRender(4 * 60, TOTAL);
expect(r.pct).toBe(80);
expect(r.danger).toBe(false);
expect(r.expired).toBe(false);
});
it("danger threshold scales up to 30 s for long windows", () => {
expect(countdownRender(31, TOTAL).danger).toBe(false);
expect(countdownRender(30, TOTAL).danger).toBe(true);
expect(countdownRender(15, TOTAL).danger).toBe(true);
});
it("expired pairing window", () => {
expect(countdownRender(0, TOTAL).expired).toBe(true);
});
});

View File

@ -35,21 +35,24 @@ export function makeQrDedupe() {
export interface CountdownRender { export interface CountdownRender {
/** Width % for the progress bar [0..100]. */ /** Width % for the progress bar [0..100]. */
pct: number; pct: number;
/** True when ≤ 10 s remain — UI uses this to switch to destructive colours. */ /** True when the remaining time crosses the danger threshold UI flips
* to destructive colours. Threshold scales with `total`: 10 s for short
* per-QR timers, 30 s for the multi-minute pairing window. */
danger: boolean; danger: boolean;
/** True when the QR has expired (≤ 0). The page will be waiting for a refresh. */ /** True when the QR has expired (≤ 0). */
expired: boolean; expired: boolean;
} }
const DANGER_THRESHOLD_SEC = 10;
export function countdownRender(seconds: number, total: number): CountdownRender { export function countdownRender(seconds: number, total: number): CountdownRender {
const safeTotal = total > 0 ? total : 1; const safeTotal = total > 0 ? total : 1;
const clamped = Math.max(0, Math.min(seconds, safeTotal)); const clamped = Math.max(0, Math.min(seconds, safeTotal));
const pct = Math.round((clamped / safeTotal) * 100); const pct = Math.round((clamped / safeTotal) * 100);
// Short windows (≤60 s) use a 10 s warning; longer ones (the 5-minute
// pairing window) use 30 s so it shows up while still actionable.
const dangerThreshold = safeTotal <= 60 ? 10 : 30;
return { return {
pct, pct,
danger: clamped > 0 && clamped <= DANGER_THRESHOLD_SEC, danger: clamped > 0 && clamped <= dangerThreshold,
expired: clamped <= 0, expired: clamped <= 0,
}; };
} }