feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4
- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod - Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket - Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming) - MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados - Backend legacy movido a legacy/, archivos residuales eliminados de raíz
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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]);
|
||||
|
||||
// ── Cleanup on unmount ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<span className="font-mono text-xl font-bold text-text-primary tabular-nums">
|
||||
{formatMMSS(displaySeconds)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Timer.displayName = 'Timer';
|
||||
|
||||
export default Timer;
|
||||
Reference in New Issue
Block a user