Files
Claro-cases/src/components/shared/Timer.tsx
T
bryan_garcia 83e3ec2cff fix(dashboard): resolver bugs críticos de tiempo real en HITL — race conditions, In-Band Auth y multi-stream buffer
- AppShell: corregir condición de carrera REST/WS que perdía tokens de agent_stream_chunk
- init_state atómico + eliminación de doble fuente REST/WS para actualización en tiempo real
- conversation_ended e idempotencia de eventos en máquina de estados por conversación
- Seguridad: migrar JWT de query param a In-Band Auth (primer mensaje {action:auth}) con timeout 5s y cierre 1008
- Multi-stream buffer: reemplazar buffer plano por TTL LRU (200 entradas, 60s TTL) para evitar pisado de tokens entre agentes
- agent_stream_completed ya no borra buffer incondicionalmente — delega purge a la política LRU
- Timer: corregir display de 00:00 en estado PENDING con visualización inmediata + cleanup en stop()
- Tests: 8 tests multi-stream, tests In-Band Auth, tests idempotencia y máquina de estados, tests Timer
- Resultado: 86/86 tests pasan | TypeScript 0 errores
2026-07-29 04:32:27 -05:00

205 lines
7.0 KiB
TypeScript

import {
useState,
useRef,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
} from 'react';
// ─────────────────────────────────────────────────────────────
// Imperative handle
// ─────────────────────────────────────────────────────────────
export interface TimerHandle {
start: () => void;
stop: () => void;
getElapsed: () => number; // returns elapsed seconds
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
function formatMMSS(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = Math.floor(totalSeconds % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function storageKey(caseId: string | number): string {
return `timer_case_${caseId}`;
}
interface StoredTimer {
startTimestamp: number; // Date.now() when started
accumulated: number; // seconds accumulated before last start
}
function readStorage(caseId: string | number): StoredTimer | null {
try {
const raw = localStorage.getItem(storageKey(caseId));
if (!raw) return null;
return JSON.parse(raw) as StoredTimer;
} catch {
return null;
}
}
function writeStorage(caseId: string | number, data: StoredTimer): void {
try {
localStorage.setItem(storageKey(caseId), JSON.stringify(data));
} catch {
// localStorage unavailable
}
}
export function clearTimerStorage(caseId: string | number): void {
try {
localStorage.removeItem(storageKey(caseId));
} catch {
// ignore
}
}
// ─────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────
interface TimerProps {
caseId: string | number;
}
// ─────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────
const Timer = forwardRef<TimerHandle, TimerProps>(({ caseId }, ref) => {
const [displaySeconds, setDisplaySeconds] = useState<number>(0);
// Mutable refs to avoid re-renders on tick
const isRunningRef = useRef(false);
const accumulatedRef = useRef(0);
const startTimestampRef = useRef<number | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ── Restore from localStorage on mount ─────────────────────
useEffect(() => {
const stored = readStorage(caseId);
if (stored) {
accumulatedRef.current = stored.accumulated;
// If the timer was running when the page closed, treat startTimestamp
// as the new start point but keep the accumulated time.
if (stored.startTimestamp > 0) {
const elapsedSinceStore =
Math.floor((Date.now() - stored.startTimestamp) / 1000);
const total = stored.accumulated + elapsedSinceStore;
accumulatedRef.current = total;
setDisplaySeconds(total);
// Don't auto-start — the parent must call start() explicitly
} else {
setDisplaySeconds(stored.accumulated);
}
}
}, [caseId]);
// ── Imperative API ────────────────────────────────────────
const start = useCallback(() => {
if (isRunningRef.current) return; // already running
isRunningRef.current = true;
startTimestampRef.current = Date.now();
// Persist: store startTimestamp + accumulated so far
writeStorage(caseId, {
startTimestamp: startTimestampRef.current,
accumulated: accumulatedRef.current,
});
// Mostrar valor actual INMEDIATAMENTE (sin esperar 1s al primer tick)
setDisplaySeconds(accumulatedRef.current);
intervalRef.current = setInterval(() => {
if (startTimestampRef.current === null) return;
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
const total = accumulatedRef.current + elapsed;
setDisplaySeconds(total);
}, 1000);
}, [caseId]);
const stop = useCallback(() => {
if (!isRunningRef.current) return;
isRunningRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Finalize accumulated time
if (startTimestampRef.current !== null) {
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
accumulatedRef.current += elapsed;
startTimestampRef.current = null;
}
// Persist: accumulated with no running timer
writeStorage(caseId, {
startTimestamp: 0,
accumulated: accumulatedRef.current,
});
setDisplaySeconds(accumulatedRef.current);
}, [caseId]);
const getElapsed = useCallback((): number => {
if (isRunningRef.current && startTimestampRef.current !== null) {
const elapsed = Math.floor(
(Date.now() - startTimestampRef.current) / 1000,
);
return accumulatedRef.current + elapsed;
}
return accumulatedRef.current;
}, []);
useImperativeHandle(ref, () => ({ start, stop, getElapsed }), [
start,
stop,
getElapsed,
]);
// ── Cleanup on unmount ────────────────────────────────────
useEffect(() => {
return () => {
// 1. Primero: persistir estado vía stop() (ya es idempotente)
stop();
// 2. Segundo: anular el intervalo (por si stop no lo hizo)
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// 3. Tercero: resetear bandera (para StrictMode)
isRunningRef.current = false;
};
}, [stop]);
// ── Render ────────────────────────────────────────────────
return (
<span className="font-mono text-xl font-bold text-text-primary tabular-nums">
{formatMMSS(displaySeconds)}
</span>
);
});
Timer.displayName = 'Timer';
export default Timer;