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:
parent
6cb387bf59
commit
7b4f0d0b84
@ -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";
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="flex w-full max-w-64 flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{expired ? "QR expired — waiting for refresh" : "QR expires in"}
|
||||
{expired ? "Pairing window expired" : "Pairing expires in"}
|
||||
</span>
|
||||
{!expired && (
|
||||
<span
|
||||
@ -34,7 +36,7 @@ function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
|
||||
danger ? "text-destructive" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{seconds}s
|
||||
{mm}:{ss}
|
||||
</span>
|
||||
)}
|
||||
</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) {
|
||||
const router = useRouter();
|
||||
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 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" && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Countdown — separate from the QR so it doesn't obstruct scanning */}
|
||||
<CountdownBar seconds={countdown} total={COUNTDOWN_TOTAL} />
|
||||
{/* 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). */}
|
||||
<CountdownBar seconds={countdown} total={PAIRING_WINDOW_SEC} />
|
||||
|
||||
{/* QR image */}
|
||||
{/* 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">
|
||||
Open WhatsApp → tap ⋮ → Linked Devices → Link a device
|
||||
</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">
|
||||
<a
|
||||
href={pairingState.qrUrl}
|
||||
|
||||
@ -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", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("> 10 s is not in danger", () => {
|
||||
it("> 10 s is not in danger for short windows", () => {
|
||||
expect(countdownRender(11, 30).danger).toBe(false);
|
||||
});
|
||||
|
||||
@ -89,3 +89,28 @@ describe("countdownRender — QR expired timer", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,21 +35,24 @@ export function makeQrDedupe() {
|
||||
export interface CountdownRender {
|
||||
/** Width % for the progress bar [0..100]. */
|
||||
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;
|
||||
/** True when the QR has expired (≤ 0). The page will be waiting for a refresh. */
|
||||
/** True when the QR has expired (≤ 0). */
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
const DANGER_THRESHOLD_SEC = 10;
|
||||
|
||||
export function countdownRender(seconds: number, total: number): CountdownRender {
|
||||
const safeTotal = total > 0 ? total : 1;
|
||||
const clamped = Math.max(0, Math.min(seconds, safeTotal));
|
||||
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 {
|
||||
pct,
|
||||
danger: clamped > 0 && clamped <= DANGER_THRESHOLD_SEC,
|
||||
danger: clamped > 0 && clamped <= dangerThreshold,
|
||||
expired: clamped <= 0,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user