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.ts con funciones puras (calcularAnalitica, tasaNoAsistencia, ocupacionSemanal, ingresosPorServicio, evolucionSemanal) que reciben citas/profesionales/servicios/clínica + hoy y devuelven el agregado. Página app/(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. API GET /api/analitica fina (solo exigirClinica + 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 en prisma/schema.prisma ni en ninguna migración (verificado por grep precioCongelado 2026-09-30: solo aparece en 001/data-model.md, 004/spec.md y MAPA.md). Es prerrequisito bloqueante de FR-004. Se implementa aquí como fundación: migración + escritura al reservar en POST /api/citas (+ prisma/seed.ts que congela servicio.precioCentimos al 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.precioCentimos vigente 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 + etiquetas S-N: DD/MM/AAAA–DD/MM/AAAA derivadas (006 FR-009). La ventana se recalcula en cada carga (FR-005); la semana en curso y las 2 futuras reservada nunca 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 en lib/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 − inicio real (no duracionMin del servicio, que pudo cambiar).
    • Ingresos (FR-004): solo completada: Σ precioCongeladoCentimos; cancelada/no_asistida = 0 €. Incluye profesionales/servicios con activo=false (la baja impide reservar, no reescribe). Formato formatearEuros («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.
  • 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.tsx reutilizado 4 veces), con eje etiquetado + valores junto a cada barra/punto, role="img" + <title>/aria-label en 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 where incluye clinicaId: clinica.id (citas, profesionales, servicios); profesionales/servicios se listan por clinicaId (activos e inactivos, para contar bajas según edge spec). Ningún groupBy sin filtro de clínica. Sin POST/PATCH/DELETE en /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 con hoy inyectado) + contrato (tests/contract/test_analitica_get.test.ts: 200 con clave, 401 sin clave, forma es-ES, 4 gráficos presentes) + scripts/comprobar-es.ts ampliado 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 hoy inyectado).