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
|
// 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";
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user