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,3 @@
|
||||
export { useNotification } from './useNotification';
|
||||
export { useSound } from './useSound';
|
||||
export { useTitleFlash } from './useTitleFlash';
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useNotification
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for HTML5 desktop notifications.
|
||||
*
|
||||
* - Requests permission on mount if not already granted.
|
||||
* - Exposes `notify(title, body, onClick?)` to fire a notification.
|
||||
* - Clicking the notification executes the optional `onClick` callback,
|
||||
* focuses the window, and auto-closes the notification.
|
||||
*/
|
||||
export function useNotification() {
|
||||
const permissionRef = useRef<NotificationPermission | null>(null);
|
||||
|
||||
// ── Request permission on mount ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn(
|
||||
'[useNotification] This browser does not support desktop notifications',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
permissionRef.current = 'granted';
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission()
|
||||
.then((permission) => {
|
||||
permissionRef.current = permission;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[useNotification] Permission request failed:', err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Notify function ─────────────────────────────────────────
|
||||
const notify = useCallback(
|
||||
(title: string, body: string, onClick?: () => void): void => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('[useNotification] Notifications are not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn(
|
||||
'[useNotification] Notification permission is not granted',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
|
||||
// Attach click handler
|
||||
if (onClick) {
|
||||
notification.onclick = (event: Event) => {
|
||||
event.preventDefault();
|
||||
window.focus();
|
||||
notification.close();
|
||||
onClick();
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-close after 6 seconds to avoid cluttering the notification tray
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 6_000);
|
||||
} catch (err) {
|
||||
console.error('[useNotification] Failed to create notification:', err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { notify };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** C5 frequency in Hz (523.25) */
|
||||
const C5 = 523.25;
|
||||
|
||||
/** E5 frequency in Hz (659.25) */
|
||||
const E5 = 659.25;
|
||||
|
||||
/** Duration of each tone in seconds (120 ms) */
|
||||
const TONE_DURATION = 0.12;
|
||||
|
||||
/** Overall gain / volume (0.08 = 8%) */
|
||||
const VOLUME = 0.08;
|
||||
|
||||
/** Exponential fade-out duration in seconds (450 ms) */
|
||||
const FADE_DURATION = 0.45;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useSound
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for playing a notification chime using the Web Audio API.
|
||||
*
|
||||
* - Initialises an `AudioContext` lazily on the first user gesture
|
||||
* (click or keydown) to comply with browser autoplay policies.
|
||||
* - Exposes `playNotificationSound()` which plays a two‑tone chime:
|
||||
* C5 (523.25 Hz) for 120 ms → E5 (659.25 Hz) for 120 ms,
|
||||
* with a shared exponential fade‑out envelope (0.08 → 0.001 over 450 ms).
|
||||
* - If the `AudioContext` is suspended, a warning is logged and the
|
||||
* call is silently ignored.
|
||||
*/
|
||||
export function useSound() {
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// ── Lazy initialisation on first user gesture ──────────────
|
||||
useEffect(() => {
|
||||
const initAudio = () => {
|
||||
if (initializedRef.current) return;
|
||||
|
||||
try {
|
||||
audioCtxRef.current = new AudioContext();
|
||||
initializedRef.current = true;
|
||||
} catch (err) {
|
||||
console.warn('[useSound] Web Audio API is not available:', err);
|
||||
}
|
||||
|
||||
// Remove both listeners after the first gesture
|
||||
window.removeEventListener('click', initAudio);
|
||||
window.removeEventListener('keydown', initAudio);
|
||||
};
|
||||
|
||||
// Attach listeners for first gesture
|
||||
window.addEventListener('click', initAudio, { once: true });
|
||||
window.addEventListener('keydown', initAudio, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', initAudio);
|
||||
window.removeEventListener('keydown', initAudio);
|
||||
|
||||
// Close AudioContext on unmount
|
||||
const ctx = audioCtxRef.current;
|
||||
if (ctx) {
|
||||
ctx.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Play the two‑tone chime ────────────────────────────────
|
||||
const playNotificationSound = useCallback((): void => {
|
||||
const ctx = audioCtxRef.current;
|
||||
|
||||
if (!ctx) {
|
||||
console.warn('[useSound] AudioContext has not been initialised yet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state === 'suspended') {
|
||||
console.warn('[useSound] AudioContext is suspended — cannot play sound');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Single shared gain node for both oscillators → unified fade‑out
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(VOLUME, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + FADE_DURATION);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
// Helper: create and schedule a single sine‑wave tone
|
||||
const playTone = (frequency: number, startTime: number): void => {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency, startTime);
|
||||
osc.connect(gain);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + TONE_DURATION);
|
||||
};
|
||||
|
||||
// Schedule the two tones
|
||||
playTone(C5, now); // C5 starts immediately
|
||||
playTone(E5, now + TONE_DURATION); // E5 starts after C5 ends
|
||||
}, []);
|
||||
|
||||
return { playNotificationSound };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TITLE = 'Claro Cases Dashboard';
|
||||
|
||||
const FLASH_INTERVAL_MS = 1_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useTitleFlash
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for flashing the browser tab title when new unread cases arrive
|
||||
* and the tab is not focused.
|
||||
*
|
||||
* - Maintains an internal counter of unread case notifications.
|
||||
* - When `triggerNotification()` is called and the tab is **hidden**
|
||||
* (`document.visibilityState === 'hidden'` or window lacks focus),
|
||||
* the title alternates every 1 second between:
|
||||
* `"(🔔 N) ¡Nuevo Caso!"` ↔ `"Claro Cases Dashboard"`
|
||||
* - When the tab regains focus (visibilitychange → visible, or window focus),
|
||||
* the interval is cleared, the title is restored to `"Claro Cases Dashboard"`,
|
||||
* and the unread counter is reset to zero.
|
||||
* - All DOM event listeners are properly cleaned up on unmount.
|
||||
*/
|
||||
export function useTitleFlash() {
|
||||
const unreadCountRef = useRef(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const originalTitleRef = useRef(DEFAULT_TITLE);
|
||||
|
||||
// ── Start the title‑flashing interval ─────────────────────
|
||||
const startFlashing = useCallback(() => {
|
||||
// Don't start a second interval if one is already active
|
||||
if (intervalRef.current !== null) return;
|
||||
|
||||
const count = unreadCountRef.current;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Toggle between two title states
|
||||
document.title =
|
||||
document.title === DEFAULT_TITLE
|
||||
? `(🔔 ${count}) ¡Nuevo Caso!`
|
||||
: DEFAULT_TITLE;
|
||||
}, FLASH_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
// ── Stop the title‑flashing interval and restore the title ──
|
||||
const stopFlashing = useCallback(() => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
document.title = DEFAULT_TITLE;
|
||||
}, []);
|
||||
|
||||
// ── Trigger a new unread notification ──────────────────────
|
||||
const triggerNotification = useCallback(() => {
|
||||
unreadCountRef.current += 1;
|
||||
|
||||
// If the tab is hidden or blurred, start flashing immediately
|
||||
const isHidden =
|
||||
document.visibilityState === 'hidden' || !document.hasFocus();
|
||||
|
||||
if (isHidden) {
|
||||
startFlashing();
|
||||
}
|
||||
}, [startFlashing]);
|
||||
|
||||
// ── Listen for visibility / focus changes ──────────────────
|
||||
useEffect(() => {
|
||||
// Save the original title on mount (in case it was changed externally)
|
||||
originalTitleRef.current = DEFAULT_TITLE;
|
||||
document.title = DEFAULT_TITLE;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
} else if (unreadCountRef.current > 0) {
|
||||
// Tab became hidden with pending notifications → start flashing
|
||||
startFlashing();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// If there are unread notifications, start flashing on blur
|
||||
if (unreadCountRef.current > 0) {
|
||||
startFlashing();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
|
||||
// Clean up interval and restore title when the component unmounts
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
};
|
||||
}, [startFlashing, stopFlashing]);
|
||||
|
||||
return { triggerNotification };
|
||||
}
|
||||
Reference in New Issue
Block a user