32 KiB
🎨 ANÁLISIS UX/UI Y FRONTEND
Fecha: 2025-01-07
Analista: Experto UX/UI + Desarrollador Frontend
Versión del Proyecto: 1.0.0
📊 RESUMEN EJECUTIVO
| Aspecto | Estado | Calificación |
|---|---|---|
| Componentes UI | ✅ Bueno (shadcn/ui) | 7.5/10 |
| Reutilización | ⚠️ Mejorable | 6/10 |
| Accesibilidad | ⚠️ Básica | 5/10 |
| Responsividad | ✅ Buena | 8/10 |
| Rendimiento | ✅ Optimizado (lazy loading) | 7.5/10 |
| Flujos de Usuario | ⚠️ Algunos puntos de fricción | 6.5/10 |
| Estados de Carga/Error | ⚠️ Inconsistentes | 6/10 |
Calificación General: 6.6/10 ⚠️ BUENO CON MEJORAS NECESARIAS
1. 🔍 ANÁLISIS DE COMPONENTES UI
1.1 Reutilización de Componentes
✅ Componentes Bien Reutilizados
| Componente | Usos | Estado | Ubicación |
|---|---|---|---|
| Badge | 50+ | ✅ Excelente | components/shared/Badge.tsx |
| Button | 100+ | ✅ Excelente | components/ui/button.tsx |
| Card | 30+ | ✅ Bueno | components/ui/card.tsx |
| EmergencyButton | 10+ | ✅ Bueno | components/shared/EmergencyButton.tsx |
| MarkdownViewer | 15+ | ✅ Bueno | components/content/MarkdownViewer.tsx |
⚠️ Componentes con Duplicación o Inconsistencias
| Problema | Ubicaciones | Impacto | Prioridad |
|---|---|---|---|
| Card Variants Duplicados | ProcedureCard, DrugCard, GuideCard |
🟡 Media | Media |
| Loading States Inconsistentes | Múltiples páginas | 🟡 Media | Media |
| Error States Inconsistentes | ManualViewer, NotFound, otros |
🟡 Media | Media |
| Button Styles Inconsistentes | Varios componentes | 🟡 Baja | Baja |
❌ Problemas Identificados
1. Card Components con Lógica Similar pero No Unificada
// ❌ PROBLEMA: ProcedureCard.tsx (198 líneas)
// Lógica de expand/collapse, favoritos, compartir duplicada
// ❌ PROBLEMA: DrugCard.tsx
// Misma lógica pero implementación diferente
// ✅ SOLUCIÓN: Crear componente base <BaseCard>
Archivos afectados:
src/components/procedures/ProcedureCard.tsx(198 líneas)src/components/drugs/DrugCard.tsxsrc/components/guide/GuideCard.tsx
Fix sugerido:
// ✅ Crear: src/components/shared/BaseCard.tsx
interface BaseCardProps {
title: string;
subtitle?: string;
badges?: BadgeProps[];
expandable?: boolean;
defaultExpanded?: boolean;
onFavorite?: () => void;
onShare?: () => void;
children?: React.ReactNode;
}
export const BaseCard = ({
title,
subtitle,
badges,
expandable,
defaultExpanded = false,
onFavorite,
onShare,
children
}: BaseCardProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// ... lógica unificada
};
2. Loading States Inconsistentes
// ❌ PROBLEMA: MarkdownViewer.tsx (línea 84-90)
if (loading && showLoading) {
return (
<div className={`flex flex-col items-center justify-center py-12 ${className}`}>
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Cargando contenido...</p>
</div>
);
}
// ❌ PROBLEMA: ManualViewer.tsx (línea 32-45)
// Loading state diferente (solo "Capítulo no encontrado")
// Sin estado de carga real
// ✅ SOLUCIÓN: Crear componente <LoadingState>
Fix sugerido:
// ✅ Crear: src/components/shared/LoadingState.tsx
interface LoadingStateProps {
message?: string;
size?: 'sm' | 'md' | 'lg';
fullScreen?: boolean;
}
export const LoadingState = ({
message = 'Cargando...',
size = 'md',
fullScreen = false
}: LoadingStateProps) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12'
};
return (
<div className={`flex flex-col items-center justify-center ${
fullScreen ? 'min-h-[60vh]' : 'py-12'
}`}>
<Loader2 className={`${sizeClasses[size]} animate-spin text-primary mb-4`} />
<p className="text-muted-foreground">{message}</p>
</div>
);
};
3. Error States Inconsistentes
// ❌ PROBLEMA: NotFound.tsx (línea 11-21)
// Error state sin accesibilidad, sin navegación clara
// ❌ PROBLEMA: ManualViewer.tsx (línea 32-45)
// Error state diferente (usa "Capítulo no encontrado")
// ❌ PROBLEMA: MarkdownViewer.tsx (línea 94-102)
// Error state con más detalles pero sin acciones claras
Fix sugerido:
// ✅ Crear: src/components/shared/ErrorState.tsx
interface ErrorStateProps {
title?: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
icon?: React.ReactNode;
}
export const ErrorState = ({
title = 'Error',
message,
action,
icon = <AlertCircle className="w-12 h-12 text-destructive" />
}: ErrorStateProps) => {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4" role="alert">
{icon}
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<p className="text-muted-foreground text-center max-w-md">{message}</p>
{action && (
<Button onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
);
};
1.2 Responsividad y Breakpoints
✅ Breakpoints Implementados
| Breakpoint | Valor | Uso | Estado |
|---|---|---|---|
| Mobile | < 768px | Hook useIsMobile() |
✅ Funcional |
| Tablet | 768px - 1024px | Tailwind md: |
✅ Implementado |
| Desktop | > 1024px | Tailwind lg:, xl:, 2xl: |
✅ Implementado |
⚠️ Problemas de Responsividad
1. Hook useIsMobile() con Problema de Hydration
// ❌ PROBLEMA: src/hooks/use-mobile.tsx (línea 6)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
// ⚠️ PROBLEMA: undefined inicial causa hydration mismatch en SSR
// ⚠️ PROBLEMA: window.innerWidth no está disponible en SSR
Fix sugerido:
// ✅ FIX: src/hooks/use-mobile.tsx
import { useState, useEffect } from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
// Solo ejecutar en cliente
if (typeof window === 'undefined') return;
const checkMobile = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
// Check inicial
checkMobile();
// Media query listener (más eficiente que resize)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const handleChange = (e: MediaQueryListEvent) => {
setIsMobile(e.matches);
};
// Modern API
if (mql.addEventListener) {
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
} else {
// Fallback para navegadores antiguos
mql.addListener(handleChange);
return () => mql.removeListener(handleChange);
}
}, []);
return isMobile;
}
2. Grids sin Breakpoints Específicos
// ❌ PROBLEMA: Index.tsx (línea 52)
<div className="grid grid-cols-2 gap-3">
{/* Emergency Buttons */}
</div>
// ⚠️ PROBLEMA: grid-cols-2 siempre, incluso en mobile pequeño
// Debería ser grid-cols-1 en mobile muy pequeño
Fix sugerido:
// ✅ FIX: Responsive grid
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Emergency Buttons */}
</div>
3. Floating Button Sin Consideración Mobile
// ❌ PROBLEMA: Index.tsx (línea 151-157)
<Link
to="/rcp"
className="fixed bottom-24 right-4 z-40 w-16 h-16 rounded-full bg-primary flex items-center justify-center shadow-lg animate-pulse-ring"
aria-label="Emergencia - RCP"
>
<AlertTriangle className="w-8 h-8 text-primary-foreground" />
</Link>
// ⚠️ PROBLEMA: Puede taparse con BottomNav en mobile
// ⚠️ PROBLEMA: No considera safe-area-inset-bottom
Fix sugerido:
// ✅ FIX: Safe area aware floating button
<Link
to="/rcp"
className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] right-4 z-40 w-16 h-16 rounded-full bg-primary flex items-center justify-center shadow-lg animate-pulse-ring touch-manipulation"
aria-label="Emergencia - RCP"
style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}
>
<AlertTriangle className="w-8 h-8 text-primary-foreground" />
</Link>
1.3 Accesibilidad (ARIA, Contraste, Navegación por Teclado)
✅ Buenas Prácticas Implementadas
| Práctica | Estado | Ejemplo |
|---|---|---|
| aria-expanded | ✅ Implementado | ProcedureCard.tsx línea 76 |
| aria-label | ✅ Implementado | Botones de acción (línea 99, 109) |
| Loading lazy en imágenes | ✅ Implementado | MarkdownViewer.tsx línea 324, 345 |
| Enlaces externos con rel | ✅ Implementado | MarkdownViewer.tsx línea 259 |
❌ Problemas Críticos de Accesibilidad
1. Falta de ARIA Labels en Elementos Interactivos
// ❌ PROBLEMA: Index.tsx (línea 37-45)
<button
onClick={onSearchClick}
className="w-full flex items-center gap-4 p-4 bg-card border border-border rounded-xl hover:border-primary/50 transition-colors"
>
{/* ⚠️ PROBLEMA: Sin aria-label, sin type="button" */}
<Search className="w-6 h-6 text-muted-foreground" />
<span className="text-muted-foreground">
Buscar protocolo, fármaco, calculadora...
</span>
</button>
Fix sugerido:
// ✅ FIX: Añadir aria-label y type
<button
type="button"
onClick={onSearchClick}
className="w-full flex items-center gap-4 p-4 bg-card border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label="Abrir búsqueda de protocolos, fármacos y calculadoras"
>
<Search className="w-6 h-6 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground">
Buscar protocolo, fármaco, calculadora...
</span>
</button>
2. Falta de Focus Visible
// ❌ PROBLEMA: Muchos botones sin focus visible
// No hay estilos focus en Tailwind config
// ✅ SOLUCIÓN: Añadir focus ring a todos los botones
Fix en Tailwind Config:
// ✅ FIX: tailwind.config.ts
extend: {
// ... existing config
ringOffsetWidth: {
DEFAULT: '2px',
},
// Asegurar que focus:outline-none está en base
}
Añadir a index.css:
/* ✅ FIX: Focus visible para todos los elementos interactivos */
*:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 2px;
border-radius: calc(var(--radius) - 2px);
}
3. Imágenes Sin Alt Text Consistente
// ❌ PROBLEMA: MarkdownViewer.tsx (línea 344)
alt={imageAlt || 'Imagen'}
// ⚠️ PROBLEMA: 'Imagen' no es descriptivo
// ⚠️ PROBLEMA: No hay validación de alt text en Markdown
Fix sugerido:
// ✅ FIX: Mejorar alt text fallback
alt={imageAlt || imageCaption || `Imagen en ${filePath.split('/').pop()}`}
4. Falta de Skip Links
// ❌ PROBLEMA: No hay skip links para navegación por teclado
// Usuarios de screen readers deben tabular por toda la navegación
// ✅ SOLUCIÓN: Añadir skip link al inicio de App.tsx
Fix sugerido:
// ✅ FIX: Añadir en App.tsx después del ErrorBoundary
<div className="min-h-screen bg-background flex flex-col">
{/* Skip to main content link */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg"
>
Saltar al contenido principal
</a>
<Header ... />
<main id="main-content" className="pt-14 pb-safe flex-1">
{/* ... */}
</main>
</div>
5. Contraste de Colores
// ⚠️ PROBLEMA: No hay verificación de contraste WCAG AA
// Colores definidos en HSL pueden no cumplir contraste mínimo
// ✅ SOLUCIÓN: Usar herramienta de verificación
// Recomendación: @storybook/addon-a11y o pa11y
1.4 Inconsistencias Visuales
1. Espaciado Inconsistente
// ❌ PROBLEMA: Diferentes sistemas de espaciado
// Index.tsx: space-y-6
// ManualViewer.tsx: space-y-6
// RCP.tsx: espacio inconsistente
// ✅ SOLUCIÓN: Usar sistema de diseño consistente
const spacing = {
section: 'space-y-6',
card: 'space-y-3',
list: 'space-y-2',
};
2. Typography Scale Inconsistente
// ⚠️ PROBLEMA: Diferentes tamaños de fuente sin sistema
// h1: text-2xl, text-3xl, text-4xl (inconsistente)
// ✅ SOLUCIÓN: Usar sistema de tipografía
2. 🔄 FLUJOS DE USUARIO EN CÓDIGO
2.1 Flujo Principal: Búsqueda → Resultado → Detalle
Mapeo del Flujo:
1. Usuario abre app (Home)
↓
2. Click en Search Bar (Index.tsx línea 37)
↓
3. Abre SearchModal (SearchModal.tsx)
↓
4. Escribe query (debounce implícito, línea 54-58)
↓
5. Muestra resultados (línea 60-118)
↓
6. Click en resultado → Navigate (línea 137-146)
↓
7. Página de detalle (ProcedureCard, DrugCard, etc.)
⚠️ Puntos de Fricción Identificados
1. Sin Debounce en Búsqueda
// ❌ PROBLEMA: SearchModal.tsx (línea 54-58)
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
// Búsqueda inmediata sin debounce
// Puede causar múltiples renders innecesarios
}, [query, typeFilter, categoryFilter]);
Fix sugerido:
// ✅ FIX: Añadir debounce
import { useDebouncedValue } from '@/hooks/useDebounce';
const SearchModal = ({ isOpen, onClose }: SearchModalProps) => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300); // 300ms debounce
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
return;
}
// Búsqueda con debouncedQuery
}, [debouncedQuery, typeFilter, categoryFilter]);
};
2. Navegación Sin Estado de Carga
// ❌ PROBLEMA: Al hacer click en resultado, no hay feedback inmediato
// El usuario no sabe si está cargando o si hay error
// ✅ SOLUCIÓN: Añadir loading state en navigate
Fix sugerido:
// ✅ FIX: Añadir loading state
const handleResultClick = (result: SearchResult) => {
setResults([]); // Limpiar resultados
addToHistory({ ...result, path: result.path });
navigate(result.path);
onClose(); // Cerrar modal
};
// En el botón de resultado:
<button
onClick={() => {
setIsNavigating(true);
handleResultClick(result);
}}
disabled={isNavigating}
className={cn(
"flex items-center gap-3 p-3 rounded-lg transition-colors",
isNavigating && "opacity-50 cursor-not-allowed"
)}
>
{isNavigating ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowRight />}
</button>
3. Sin Historial de Búsqueda Visible en Modal
// ⚠️ PROBLEMA: Historial solo visible en Home, no en SearchModal
// Usuario debe cerrar modal y volver a Home para ver historial
// ✅ SOLUCIÓN: Mostrar historial reciente en SearchModal cuando está vacío
2.2 Flujo: Onboarding / Primera Visita
Estado Actual: ❌ NO EXISTE
// ❌ PROBLEMA: No hay onboarding para nuevos usuarios
// No hay explicación de features
// No hay tour de la app
// ✅ SOLUCIÓN: Crear componente de onboarding
Fix sugerido:
// ✅ CREAR: src/components/onboarding/OnboardingFlow.tsx
interface OnboardingStep {
id: string;
title: string;
description: string;
target: string; // CSS selector
position: 'top' | 'bottom' | 'left' | 'right';
}
export const OnboardingFlow = () => {
const [currentStep, setCurrentStep] = useState(0);
const [isComplete, setIsComplete] = useState(
localStorage.getItem('onboarding-complete') === 'true'
);
if (isComplete) return null;
const steps: OnboardingStep[] = [
{
id: 'search',
title: 'Busca rápidamente',
description: 'Usa la barra de búsqueda para encontrar protocolos, fármacos o calculadoras',
target: '[aria-label="Abrir búsqueda"]',
position: 'bottom',
},
{
id: 'emergency',
title: 'Emergencias críticas',
description: 'Acceso rápido a los protocolos más importantes',
target: '[aria-label="Emergencia - RCP"]',
position: 'left',
},
// ... más pasos
];
// Implementar overlay y tooltip
};
2.3 Flujo: Navegación Nested (Manual, Guías)
Mapeo del Flujo:
1. Usuario va a /manual (ManualIndex.tsx)
↓
2. Click en bloque → Expande (acordeón)
↓
3. Click en capítulo → Navigate a /manual/:parte/:bloque/:capitulo
↓
4. ManualViewer carga MarkdownViewer (línea 4)
↓
5. Navegación anterior/siguiente (línea 48-53)
⚠️ Puntos de Fricción
1. Sin Breadcrumbs
// ❌ PROBLEMA: ManualViewer.tsx no tiene breadcrumbs
// Usuario no sabe dónde está en la jerarquía
// ✅ SOLUCIÓN: Añadir breadcrumbs
Fix sugerido:
// ✅ FIX: Añadir breadcrumbs
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink } from '@/components/ui/breadcrumb';
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/manual">Manual</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink>{capituloData.bloque}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<span>{capituloData.titulo}</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
2. Navegación Anterior/Siguiente No Visible Hasta Scroll
// ⚠️ PROBLEMA: ManualViewer.tsx (línea 55-126)
// Navegación anterior/siguiente solo visible al final del contenido
// En contenido largo, usuario no ve opciones de navegación hasta el final
// ✅ SOLUCIÓN: Añadir sticky navigation
Fix sugerido:
// ✅ FIX: Sticky navigation
<div className="sticky top-14 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b border-border py-3">
<div className="flex items-center justify-between">
{capituloAnterior && (
<Link to={capituloAnterior.ruta} className="flex items-center gap-2">
<ChevronLeft className="w-5 h-5" />
<span className="hidden sm:inline">Anterior</span>
</Link>
)}
{capituloSiguiente && (
<Link to={capituloSiguiente.ruta} className="flex items-center gap-2 ml-auto">
<span className="hidden sm:inline">Siguiente</span>
<ChevronRight className="w-5 h-5" />
</Link>
)}
</div>
</div>
3. Sin Indicador de Progreso de Lectura
// ⚠️ PROBLEMA: No hay indicador de cuánto ha leído el usuario
// No hay "X% completado" o barra de progreso
// ✅ SOLUCIÓN: Añadir indicador de progreso
2.4 Estados de Carga/Error
✅ Estados Implementados
| Componente | Loading | Error | Empty | Estado |
|---|---|---|---|---|
| MarkdownViewer | ✅ Sí | ✅ Sí | ✅ Sí | ✅ Completo |
| ManualViewer | ⚠️ Parcial | ⚠️ Parcial | ✅ Sí | ⚠️ Mejorable |
| SearchModal | ❌ No | ❌ No | ✅ Sí | ❌ Falta |
| ProcedureCard | ❌ No | ❌ No | ❌ No | ❌ Falta |
❌ Problemas Identificados
1. MarkdownViewer Re-renderiza en Cada filePath Change
// ❌ PROBLEMA: MarkdownViewer.tsx (línea 54-81)
useEffect(() => {
// Fetch se ejecuta cada vez que filePath cambia
// No hay cancelación de fetch anterior si cambia rápido
fetch(normalizedPath)
.then(...)
.catch(...);
}, [filePath, onError]);
// ⚠️ PROBLEMA: Si filePath cambia rápidamente, múltiples fetches concurrentes
// ⚠️ PROBLEMA: Race condition - respuesta antigua puede sobrescribir nueva
Fix sugerido:
// ✅ FIX: Cancelar fetch anterior
useEffect(() => {
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
setLoading(true);
setError(null);
const abortController = new AbortController();
fetch(normalizedPath, { signal: abortController.signal })
.then((res) => {
if (!res.ok) {
throw new Error(`Error ${res.status}: ${res.statusText}`);
}
return res.text();
})
.then((text) => {
// Solo actualizar si el fetch no fue cancelado
if (!abortController.signal.aborted) {
setContent(text);
setLoading(false);
}
})
.catch((err) => {
// Ignorar errores de cancelación
if (err.name === 'AbortError') return;
const errorMessage = `No se pudo cargar el archivo: ${err.message}`;
if (!abortController.signal.aborted) {
setError(errorMessage);
setLoading(false);
if (onError) {
onError(new Error(errorMessage));
}
}
});
return () => {
abortController.abort();
};
}, [filePath, onError]);
2. Sin Skeleton Loading
// ❌ PROBLEMA: Loading states son spinners genéricos
// No hay skeleton loaders que muestren estructura del contenido esperado
// ✅ SOLUCIÓN: Añadir skeleton loaders
Fix sugerido:
// ✅ CREAR: src/components/shared/SkeletonLoader.tsx
export const MarkdownSkeleton = () => (
<div className="space-y-4 animate-pulse">
<div className="h-8 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-5/6" />
<div className="h-64 bg-muted rounded" /> {/* Imagen skeleton */}
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-4/5" />
</div>
);
// Usar en MarkdownViewer:
if (loading && showLoading) {
return <MarkdownSkeleton />;
}
3. ⚡ RENDIMIENTO FRONTEND
3.1 Bundle Analysis
✅ Optimizaciones Implementadas
| Optimización | Estado | Detalles |
|---|---|---|
| Lazy Loading de Páginas | ✅ Implementado | App.tsx línea 23-54 |
| Code Splitting | ✅ Implementado | Vite config manual chunks |
| Tree Shaking | ✅ Automático | Vite + ES Modules |
| Image Lazy Loading | ✅ Implementado | loading="lazy" en imágenes |
⚠️ Problemas Identificados
1. Bundle Size Grande por react-markdown
// ⚠️ PROBLEMA: react-markdown + plugins es pesado (~150KB gzipped)
// Se carga incluso si no se usa MarkdownViewer
// ✅ SOLUCIÓN: Lazy load react-markdown también
Fix sugerido:
// ✅ FIX: Lazy load MarkdownViewer completo
const MarkdownViewer = lazy(() => import('@/components/content/MarkdownViewer'));
// En ManualViewer.tsx:
<Suspense fallback={<MarkdownSkeleton />}>
<MarkdownViewer filePath={filePath} />
</Suspense>
2. Vendor Chunks Muy Grandes
// ⚠️ PROBLEMA: vite.config.ts (línea 59)
chunkSizeWarningLimit: 1000, // 1MB - muy alto
// ⚠️ PROBLEMA: vendor-react puede ser > 500KB
// Debería optimizarse más
Fix sugerido:
// ✅ FIX: Optimizar chunks más agresivamente
build: {
chunkSizeWarningLimit: 500, // Reducir a 500KB
rollupOptions: {
output: {
manualChunks: (id) => {
// Separar react-markdown en chunk propio
if (id.includes('react-markdown')) {
return 'vendor-markdown';
}
if (id.includes('remark') || id.includes('rehype')) {
return 'vendor-markdown-plugins';
}
// ... resto de lógica
},
},
},
}
3. Imágenes Sin Optimización
// ❌ PROBLEMA: Imágenes en public/assets/ no están optimizadas
// No hay WebP, no hay responsive images, no hay srcset
// ✅ SOLUCIÓN: Implementar optimización de imágenes
Fix sugerido:
// ✅ FIX: Usar next/image o similar (o implementar manual)
// Crear componente OptimizedImage
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
sizes?: string;
}
export const OptimizedImage = ({ src, alt, width, height, sizes }: OptimizedImageProps) => {
// Detectar soporte WebP
const [supportsWebP, setSupportsWebP] = useState(false);
useEffect(() => {
const webP = new Image();
webP.onload = webP.onerror = () => {
setSupportsWebP(webP.height === 2);
};
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
}, []);
const webPSrc = src.replace(/\.(jpg|jpeg|png)$/, '.webp');
return (
<picture>
{supportsWebP && <source srcSet={webPSrc} type="image/webp" />}
<img
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
loading="lazy"
decoding="async"
/>
</picture>
);
};
3.2 Re-renders Innecesarios
❌ Problemas Identificados
1. ProcedureCard Re-renderiza en Cada Toggle
// ❌ PROBLEMA: ProcedureCard.tsx (línea 22-33)
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const { isFavorite, toggleFavorite: toggleFavoriteHook } = useFavorites();
// ⚠️ PROBLEMA: useFavorites() puede causar re-renders en todos los cards
// cuando cambia un favorito
// ✅ SOLUCIÓN: Memoizar componente
Fix sugerido:
// ✅ FIX: Memoizar ProcedureCard
import { memo, useCallback } from 'react';
const ProcedureCard = memo(({ procedure, defaultExpanded = false }: ProcedureCardProps) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const { isFavorite, toggleFavorite } = useFavorites();
const handleToggleFavorite = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite({
id: procedure.id,
type: 'procedure',
title: procedure.shortTitle,
path: `/soporte-vital?id=${procedure.id}`,
});
}, [procedure.id, procedure.shortTitle, toggleFavorite]);
// ... resto del componente
}, (prevProps, nextProps) => {
// Comparación personalizada para evitar re-renders innecesarios
return (
prevProps.procedure.id === nextProps.procedure.id &&
prevProps.defaultExpanded === nextProps.defaultExpanded
);
});
ProcedureCard.displayName = 'ProcedureCard';
2. SearchModal Re-renderiza en Cada Query Change
// ❌ PROBLEMA: SearchModal.tsx (línea 54-118)
// useEffect ejecuta búsqueda en cada cambio de query
// Sin debounce, sin memoización de resultados
// ✅ SOLUCIÓN: Debounce + memoización
Fix sugerido (ver sección 2.1 para debounce):
// ✅ FIX: Memoizar resultados de búsqueda
import { useMemo } from 'react';
const searchResults = useMemo(() => {
if (debouncedQuery.length < 2) return [];
const expandedQuery = expandQueryWithAcronyms(debouncedQuery);
// ... lógica de búsqueda
return [...procedures, ...drugs];
}, [debouncedQuery, typeFilter, categoryFilter]);
3. MarkdownViewer Re-renderiza Props Sin Cambios
// ❌ PROBLEMA: MarkdownViewer.tsx
// No está memoizado, re-renderiza incluso si filePath no cambia
// ✅ SOLUCIÓN: Memoizar
3.3 Optimización de Assets
Problemas:
- ❌ Sin compresión de imágenes (WebP, AVIF)
- ❌ Sin responsive images (srcset)
- ❌ Sin preload de assets críticos
- ⚠️ Sin service worker para cache de assets
Soluciones:
- ✅ Implementar optimización de imágenes (ver 3.1)
- ✅ Añadir preload de assets críticos en
index.html - ✅ Mejorar service worker para cache agresivo de assets
4. 🔧 MEJORAS CONCRETAS - REFACTORS ESPECÍFICOS
4.1 Refactor de Componentes
Prioridad Alta
| Componente | Problema | Fix | Esfuerzo | Impacto |
|---|---|---|---|---|
| BaseCard | Duplicación lógica | Crear componente base | 4 horas | Alto |
| LoadingState | Estados inconsistentes | Unificar componente | 2 horas | Medio |
| ErrorState | Estados inconsistentes | Unificar componente | 2 horas | Medio |
| OptimizedImage | Sin optimización | Implementar componente | 4 horas | Alto |
| useDebounce | Falta debounce | Crear hook | 1 hora | Medio |
Prioridad Media
| Componente | Problema | Fix | Esfuerzo | Impacto |
|---|---|---|---|---|
| OnboardingFlow | No existe onboarding | Crear componente | 1 día | Alto |
| Breadcrumbs | Falta navegación clara | Añadir a ManualViewer | 2 horas | Medio |
| SkeletonLoader | Loading genérico | Crear skeletons específicos | 3 horas | Medio |
| StickyNavigation | Navegación no visible | Implementar sticky nav | 2 horas | Medio |
4.2 Librerías Recomendadas
| Problema | Librería | Razón | Instalación |
|---|---|---|---|
| Debounce | use-debounce |
Hook ya implementado | npm i use-debounce |
| Accesibilidad | @react-aria/* |
Componentes accesibles | npm i @react-aria/button |
| Imágenes | next/image (adaptar) |
Optimización automática | O implementar manual |
| Análisis | web-vitals |
Métricas de rendimiento | npm i web-vitals |
| Testing A11y | @axe-core/react |
Testing accesibilidad | npm i @axe-core/react |
4.3 Archivos que Necesitan Atención Urgente
🔴 Crítico (1-2 días)
-
src/components/content/MarkdownViewer.tsx- ✅ Añadir cancelación de fetch (race condition)
- ✅ Memoizar componente
- ✅ Mejorar manejo de errores
-
src/hooks/use-mobile.tsx- ✅ Fix hydration mismatch
- ✅ Usar MediaQueryList API
-
src/components/layout/SearchModal.tsx- ✅ Añadir debounce
- ✅ Memoizar resultados
- ✅ Añadir loading state
🟡 Importante (3-5 días)
-
src/components/procedures/ProcedureCard.tsx- ✅ Memoizar componente
- ✅ Extraer a BaseCard
- ✅ Optimizar re-renders
-
src/pages/Index.tsx- ✅ Añadir aria-labels
- ✅ Fix responsive grid
- ✅ Fix floating button safe area
-
src/pages/ManualViewer.tsx- ✅ Añadir breadcrumbs
- ✅ Sticky navigation
- ✅ Indicador de progreso
🟢 Mejoras (1-2 semanas)
- Crear componentes compartidos (BaseCard, LoadingState, ErrorState)
- Implementar onboarding flow
- Optimización de imágenes completa
- Añadir skeleton loaders
5. 📊 TABLA RESUMEN DE PROBLEMAS
| ID | Problema | Severidad | Archivo | Línea | Esfuerzo Fix | Prioridad |
|---|---|---|---|---|---|---|
| U1 | Falta aria-labels en botones | 🔴 Alta | Index.tsx |
37 | 30 min | 1º |
| U2 | Race condition en MarkdownViewer | 🔴 Alta | MarkdownViewer.tsx |
54-81 | 1 hora | 2º |
| U3 | Hydration mismatch en useIsMobile | 🔴 Alta | use-mobile.tsx |
6-18 | 1 hora | 3º |
| U4 | Sin debounce en búsqueda | 🟡 Media | SearchModal.tsx |
54-58 | 1 hora | 4º |
| U5 | Duplicación lógica en Cards | 🟡 Media | ProcedureCard.tsx |
Todo | 4 horas | 5º |
| U6 | Re-renders innecesarios | 🟡 Media | ProcedureCard.tsx |
22 | 2 horas | 6º |
| U7 | Sin skeleton loaders | 🟡 Media | Múltiples | - | 3 horas | 7º |
| U8 | Sin breadcrumbs | 🟡 Media | ManualViewer.tsx |
- | 2 horas | 8º |
| U9 | Imágenes sin optimizar | 🟡 Media | MarkdownViewer.tsx |
278-354 | 4 horas | 9º |
| U10 | Sin focus visible | 🟡 Media | Múltiples | - | 2 horas | 10º |
| U11 | Sin skip links | 🟢 Baja | App.tsx |
- | 30 min | 11º |
| U12 | Sin onboarding | 🟢 Baja | Nuevo | - | 1 día | 12º |
Total Problemas: 12
Críticos: 3
Importantes: 7
Mejoras: 2
6. ✅ CHECKLIST DE IMPLEMENTACIÓN
Quick Wins (1 día)
- U1: Añadir aria-labels a botones principales
- U2: Fix race condition en MarkdownViewer
- U3: Fix hydration mismatch en useIsMobile
- U4: Añadir debounce en búsqueda
- U11: Añadir skip links
Refactors Importantes (1 semana)
- U5: Crear BaseCard component
- U6: Memoizar ProcedureCard
- U7: Implementar skeleton loaders
- U8: Añadir breadcrumbs
- U10: Añadir focus visible styles
Optimizaciones (1-2 semanas)
- U9: Optimizar imágenes (WebP, srcset)
- U12: Implementar onboarding flow
- Mejorar bundle splitting
- Añadir web-vitals tracking
Última actualización: 2025-01-07
Próxima revisión recomendada: 2025-02-07 (1 mes)