yiekheng 7b4f0d0b84 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>
2026-05-10 08:36:26 +08:00

59 lines
2.1 KiB
TypeScript

/**
* Per-account dedupe of inbound QR strings from Baileys. Pure logic so the
* pair-handler stays thin and the dedupe is unit-testable.
*
* Invariant: for a given accountId, identical QR payloads are dropped.
* A different payload (or the first ever for that account) returns true,
* meaning "yes, emit this one to the web".
*/
export function makeQrDedupe() {
const last = new Map<string, string>();
return {
/** Returns true if this payload should be forwarded; false if it's a dup. */
shouldEmit(accountId: string, payload: string): boolean {
if (last.get(accountId) === payload) return false;
last.set(accountId, payload);
return true;
},
/** Forget the last payload — call when the session ends or pairing restarts. */
reset(accountId: string): void {
last.delete(accountId);
},
/** Test helper — current cache size. */
size(): number {
return last.size;
},
};
}
/**
* Pure logic for the pair page's countdown bar. Given a remaining-seconds
* value and the total window, return the rendering primitives. Extracted
* so the visual behaviour is unit-testable.
*/
export interface CountdownRender {
/** Width % for the progress bar [0..100]. */
pct: number;
/** 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). */
expired: boolean;
}
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 <= dangerThreshold,
expired: clamped <= 0,
};
}