Rediseño visual UABC + Mulish + auditoría de BD (P0/P1)

Rediseño visual:
- Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens
  verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas,
  radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve
  transparencia + blur suave + borde verde muy sutil.
- 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E).
- Limpieza post-critique: eliminado triple CTA en home alumno, .card
  anidados en admin, aria-live decorativo, 26 aliases legacy en global.css,
  border-l verde en nav (patrón AI-slop), classList.remove obsoletos.

Auditoría de llamadas a BD (para 20-50 usuarios concurrentes):
- P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s)
  por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon).
  Invalidación en signout, PATCH /profile, baneos.
- P0-2: eliminado count pendientes duplicado en AppLayout; badge se
  hidrata client-side (BadgeSolicitudes con fetch inicial).
- P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta
  preciso en BadgeSolicitudes (con fallback a refetch).
- P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo
  reusa profile del middleware (3 queries → 1), inventario paralelo,
  estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009
  con 3 índices (fecha, vencidos parcial, audit compuesto).

Fixes P0 de UI post-critique:
- estadisticas.astro: ReferenceError DIAS restaurado.
- solicitudes (3 vistas): error rendering sanitizado (no expone mensaje
  Postgres crudo al admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
This commit is contained in:
2026-09-21 09:42:12 -07:00
parent a41d50ca62
commit ac3e7061ac
53 changed files with 2132 additions and 1576 deletions
+144
View File
@@ -0,0 +1,144 @@
// Cache in-memory de las estadísticas admin — 60s TTL.
// Motivo: /admin/estadisticas dispara 9 queries por visita; a 20-50 usuarios
// concurrentes con un admin abriendo la pestaña varias veces al día, absorbemos
// >90% de los hits con este cache.
// El cache es por día (isoDate) — cuando cambia el día, la key cambia y se re-computa.
import type { SupabaseClient } from '@supabase/supabase-js';
const TTL_MS = 60_000;
export type EstadisticasBundle = {
kpis: {
nSolicitadas: number;
nAprobadas: number;
nRechazadas: number;
nDevueltas: number;
nEnPrestamo: number;
nVencidos: number;
};
topMateriales: { label: string; valor: number }[];
solicitudesPorDia: { fecha: string; valor: number }[];
distribEstados: { label: string; valor: number }[];
};
type Entry = { at: number; data: EstadisticasBundle };
const cache = new Map<string, Entry>();
export function invalidateEstadisticas() {
cache.clear();
}
export async function getEstadisticas(
supabase: SupabaseClient,
dia: { isoDate: string; startUTC: Date; endUTC: Date },
): Promise<EstadisticasBundle> {
const key = dia.isoDate;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at < TTL_MS) return hit.data;
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// 9 queries en paralelo — mismo shape que la implementación inline anterior.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
{ data: topMaterialesRaw },
{ data: solicitudesUlt30 },
{ data: estadosRaw },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde),
supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true }),
supabase.from('solicitudes').select('estado'),
]);
// Top 10 materiales (agrupado en memoria).
const topMapa = new Map<string, number>();
for (const r of (topMaterialesRaw ?? []) as any[]) {
const nom = r.material?.nombre ?? '—';
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
}
const topMateriales = [...topMapa.entries()]
.map(([label, valor]) => ({ label, valor }))
.sort((a, b) => b.valor - a.valor)
.slice(0, 10);
// Solicitudes por día (30 días, rellena huecos con 0).
function toIsoDateMX(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Tijuana',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
const conteoPorDia = new Map<string, number>();
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
const k = toIsoDateMX(new Date(r.fecha_solicitud));
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
}
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
for (let i = 0; i < DIAS; i++) {
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
const iso = toIsoDateMX(d);
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
}
// Distribución de estados.
const estadoLabelMap: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoMapa = new Map<string, number>();
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
if (!r.estado) continue;
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
}
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
label: estadoLabelMap[k] ?? k,
valor: v,
}));
const data: EstadisticasBundle = {
kpis: {
nSolicitadas: nSolicitadas ?? 0,
nAprobadas: nAprobadas ?? 0,
nRechazadas: nRechazadas ?? 0,
nDevueltas: nDevueltas ?? 0,
nEnPrestamo: nEnPrestamo ?? 0,
nVencidos: nVencidos ?? 0,
},
topMateriales,
solicitudesPorDia,
distribEstados,
};
cache.set(key, { at: now, data });
return data;
}