Research — 004 Panel de analítica (Phase 0)
Fecha: 2026-09-30. Fuente: specs/004-panel-analitica/spec.md (FR-001…FR-010 + clarificaciones 2026-09-29/30, US1–US5, SC-001…SC-007). Stack heredado: TypeScript strict, Next.js 15, Prisma + Postgres 16, Vitest + Playwright existente. Cero NEEDS CLARIFICATION abiertos (7 preguntas respondidas en spec); esta fase consolida decisiones.
R1. Dónde vive el cálculo: lib/analitica.ts puro + página fina + API fina de lectura
- Decision:
lib/analitica.tscon funciones puras (calcularAnalitica,tasaNoAsistencia,ocupacionSemanal,ingresosPorServicio,evolucionSemanal) que reciben citas/profesionales/servicios/clínica +hoyy devuelven el agregado. Páginaapp/(panel)/analitica/page.tsx(Server Component,force-dynamic) que exige sesión (clinicaDeLaSesion, misma clave que agenda, FR-001) y dibuja 4 gráficos SVG propios. APIGET /api/analiticafina (soloexigirClinica+calcularAnalitica, sin escritura) para tests de contrato y aislamiento. - Rationale: FR-006 (solo lectura) + Constitución IV (simplicidad): el dominio puro es testeable sin BD ni navegador; la página y la API son fachadas finas sobre el mismo cálculo (una sola definición, cero duplicación). Sigue el patrón existente (
lib/agenda.ts+app/api/agenda/route.ts+app/(panel)/agenda/page.tsx). - Alternatives considered: cálculo dentro del Server Component (rechazado: no reutilizable por la API ni testeable en Vitest sin render); vista SQL/materializada en BD (rechazado: alcance fantasma, la ventana se recalcula en cada carga FR-005 y la escala es ~500 citas); librería de gráficos externa (ver R5).
R2. Precio congelado ausente: migración S-05 pendiente (propiedad 001)
- Decision: la columna
Cita.precioCongeladoCentimos Int NOT NULL(enmienda S-05, 001 FR-006, MAPA «Aplicada») NO existe enprisma/schema.prismani en ninguna migración (verificado porgrep precioCongelado2026-09-30: solo aparece en001/data-model.md,004/spec.mdyMAPA.md). Es prerrequisito bloqueante de FR-004. Se implementa aquí como fundación: migración + escritura al reservar enPOST /api/citas(+prisma/seed.tsque congelaservicio.precioCentimosal crear cada cita) + retrolleno de la semilla. La página 004 solo lee el campo (FR-006, aclaración S-07). - Rationale: sin el campo, «cambiar la tarifa no altera lo ya facturado» (FR-004/SC-002) es imposible de cumplir; leer
servicio.precioCentimosvigente rompería SC-002 ante un cambio de tarifa. Con semilla v1 estática ambos coinciden (oráculos 5.040/4.900/2.030/2.385 €), así que la migración es invisible en números pero cierra la garantía. - Alternatives considered: leer precio vigente del servicio (rechazado: viola FR-004 «MUST ser el de la reserva»); campo anulable con fallback (rechazado: introduce dos fuentes de verdad; la spec exige copia inmutable NOT NULL).
R3. Tiempo: reutilizar lib/ventanas.ts (006), cero redefiniciones
- Decision: únicas primitivas temporales:
capturarAhora()(006 FR-001, un «ahora» por cálculo, truncado a minuto),semanasAnalitica(hoy)(006 FR-007: semana en curso lunes–domingo fuera, historia S-8…S-1),denominadorOcupacion(jornadaInicio, jornadaFin, diasLaborables, 8)(006 FR-008, nunca 26.400 hardcodeado),etiquetarJornada+ etiquetasS-N: DD/MM/AAAA–DD/MM/AAAAderivadas (006 FR-009). La ventana se recalcula en cada carga (FR-005); la semana en curso y las 2 futurasreservadanunca entran en la evolución. - Rationale: 006 FR-010 prohíbe redefinir «ahora», zona, bordes o ventanas fuera de
lib/(tiempo|ventanas).ts; la 004 cita por nombre.Europe/Madrid+formatearDia/formatearEuros(es-ES) cubren FR-007/FR-008 y Constitución II/VIII. - Alternatives considered: calcular lunes con
new Date()local enlib/analitica.ts(rechazado: defecto 006 FR-010 + rompe DST); congelar la ventana de US4 como constante (rechazado: FR-008 «MUST derivarse de la ventana calculada, nunca de un valor fijo»; los oráculos S-8…S-1 solo valen para semilla v1 cargada en la semana del 28/09/2026, Assumptions S-08).
R4. Definiciones de cálculo (contrato versionado S-08)
- Decision (todo en céntimos enteros,
sumarCentimos, redondeo mitad-arriba si hubiera fracción — FR-007):- Tasa (FR-002):
no_asistidas / (completada+no_asistida+cancelada)por profesional en la ventana;reservada(futura o con fin pasado = «sin desenlace», 001 FR-011) fuera de numerador y denominador, reportada aparte. Un decimal, coma decimal («7,3 %»). Sin citas ⇒ «—», no 0 % engañoso. - Ocupación (FR-003): UN número por profesional =
minutos(completadas en ventana) / denominadorOcupacion(vigente);cancelada/no_asistida/reservada= 0 min. La jornada vigente se declara junto al porcentaje. Minutos de cada cita =fin − inicioreal (noduracionMindel servicio, que pudo cambiar). - Ingresos (FR-004): solo
completada:Σ precioCongeladoCentimos;cancelada/no_asistida= 0 €. Incluye profesionales/servicios conactivo=false(la baja impide reservar, no reescribe). FormatoformatearEuros(«5.040,00 €»). - Evolución (FR-005): 8 filas S-8…S-1 con citas/completadas/no_asistidas/canceladas/ingresos; la suma cuadra con 409 (335+41+33) y 14.355,00 € en la ventana de referencia.
- Tasa (FR-002):
- Rationale: denominadores versionados (S-08): tasa con canceladas dentro, ocupación e ingresos solo con completadas. Oráculos de la spec (tasas 7,3/12,6/10,6 %, ocupaciones 25,1/18,7/15,6 %, tabla S-8…S-1) valen solo para semilla v1 + ventana 28/09/2026; fuera de ella los tests cuadran contra la historia realmente cargada (SC-003).
- Alternatives considered: tasa sin canceladas (rechazado: Clarifications 2026-09-29 fijan con canceladas, coherente con reparto 82/10/8); ocupación como serie semanal (rechazado: Clarifications fijan UN número = media de 8 semanas).
R5. Gráficos sin dependencia nueva: SVG propio accesible
- Decision: 4 gráficos de barras/líneas en SVG inline (componente
components/grafico-barras.tsxreutilizado 4 veces), con eje etiquetado + valores junto a cada barra/punto,role="img"+<title>/aria-labelen español, letra ≥ 16 px, sin desplazamiento horizontal a 390 px. Paleta sobre variables existentes con contraste verificado (texto ≥ 4,5:1, barras/rejilla ≥ 3:1). Cero jerga prohibida (dataset, serie, KPI, coeficiente, métrica, agregado— FR-009/SC-007). - Rationale: Constitución IV (cero alcance fantasma: sin chart.js/recharts para 4 gráficos estáticos de lectura) y VII (legible sin formación). SVG inline es responsive por
viewBox, no necesita JS de cliente y se verifica con test de contraste + grep de jerga + revisión visual (SC-007). - Alternatives considered: recharts/chart.js (rechazado: dependencia + JS cliente + riesgo de jerga en inglés por defecto, sin aporte para barras estáticas); tablas de texto plano (rechazado: FR-009 exige gráficos, no tablas).
R6. Aislamiento + solo lectura (FR-001/FR-006, SC-004/SC-006)
- Decision: todo
whereincluyeclinicaId: clinica.id(citas, profesionales, servicios); profesionales/servicios se listan porclinicaId(activos e inactivos, para contar bajas según edge spec). NingúngroupBysin filtro de clínica. SinPOST/PATCH/DELETEen/api/analitica(405 si se intenta) ni botones que muten; verificación por diff de BD antes/después en tests (SC-004) y por test con 2 clínicas (SC-006: 100 % propio, 0 % ajeno). - Rationale: FR-001 «la agregación MUST NOT ignorar este filtro aunque hoy la base solo contenga una clínica»; la fuga entre clínicas es defecto bloqueante. Solo lectura = cero escrituras en consulta (S-07); la migración S-05 es de la 001, no escritura de esta página.
- Alternatives considered: filtrar solo citas y reutilizar nombres globales de profesionales (rechazado: filtraría mal bajas y fugaría nombres de otra clínica, SC-006).
R7. Estrategia de tests (Constitución VI + SC-002…SC-007)
- Decision: Vitest unit (
tests/unit/test_analitica.test.ts: puras con historia sintética + oráculos semilla v1 cuando la ventana coincide; tasas, ocupación, ingresos al céntimo, evolución, vacíos «—»/«Aún no hay datos suficientes»/«0,00 €», precio congelado inmóvil ante cambio de tarifa, sin-escritura) + integración (tests/integration/test_analitica_aislamiento.test.ts: 2 clínicas, solo-lectura por diff, ventana móvil conhoyinyectado) + contrato (tests/contract/test_analitica_get.test.ts: 200 con clave, 401 sin clave, forma es-ES, 4 gráficos presentes) +scripts/comprobar-es.tsampliado con catálogo 004 + grep de jerga + test de contraste. Playwright existente cubre 1440/390 sin desplazamiento (SC-001 manual con cronómetro, no aserción auto de tiempo). - Rationale: cada FR/SC tiene test trazable; SC-002 (céntimo) y SC-003 (determinismo misma ventana) se verifican contra historia real, no contra tabla fija fuera de su ventana (Assumptions S-08).
- Alternatives considered: solo e2e (rechazado: oráculos al céntimo y aislamiento necesitan unit/integración deterministas con
hoyinyectado).