Research — 006 Referencia temporal compartida

Fuente: spec.md (FR-001…FR-011 + Q1–Q4 2026-09-30). Stack heredado de la 001: TypeScript strict, Next.js 15, Prisma + Postgres 16, date-fns-tz + Intl es-ES, Vitest. Cero NEEDS CLARIFICATION abiertos (las 4 preguntas de la spec están respondidas); esta fase consolida decisiones.

D1. Origen y propagación del «ahora» (FR-001, Q1/A)

  • Decision: capturarAhora(): Date = truncarAMinuto(new Date()) una vez al inicio de cada operación (alta, desenlace, cancelarCita, selección de recordatorios, cálculo de analítica) desde el reloj del servidor de aplicación; se pasa como parámetro a todas las validaciones de esa operación. La BD usa el valor propagado; prohibido now()/CURRENT_TIMESTAMP en reglas de negocio.
  • Rationale: elimina discrepancias portal/recepción/recordatorios/analítica sobre la misma cita (US1) y hace SC-001 verificable (cero discrepancias con reloj fijado). Tolerancia cero + truncado (no redondeo) hacen el borde determinista.
  • Alternatives considered: now() de Postgres por regla (estado actual de RN2 en la 001) — rechazado: dos now() en la misma operación pueden caer en minutos distintos y servidor/BD pueden derivar; además impide tests con reloj controlado. Reloj por chequeo (no capturado) — rechazado por US1-AS3 (el reloj avanza durante la operación).

D2. Precisión de minuto y truncado (FR-002)

  • Decision: reutilizar truncarAMinuto existente (setUTCSeconds(0,0) sobre clon) en capturarAhora y en toda entrada (instanteDeEntrada, instanteLocal). Dos instantes del mismo minuto son el mismo minuto.
  • Rationale: ya implementado y testeado en lib/tiempo.ts; evita que segundos/milisegundos decidan un borde de 24 h o un «pasado» en el minuto exacto (edge spec: cita en el mismo minuto no es pasado).
  • Alternatives considered: redondeo al minuto más cercano — rechazado: 23 h 59 min 40 s redondearía a 24 h y entraría en plazo indebidamente. Comparación con segundos — rechazado: rompe SC-002 (bordes escritos en minutos).

D3. Zona canónica y cambio de hora estacional (FR-003, edge DST)

  • Decision: ZONA_HORARIA = 'Europe/Madrid' ya centralizada en lib/tiempo.ts; toda construcción usa fromZonedTime(fecha+hora, ZONA) y toda lectura usa Intl con timeZone: Europe/Madrid. La hora ambigua del día del cambio se resuelve con el desplazamiento vigente en el «ahora» capturado (es decir: se comparan instantes UTC, se muestran en Madrid).
  • Rationale: comparar en UTC elimina la ambigüedad de la hora repetida (02:30 dos veces); mostrar siempre con día/mes/año+hora/minutos 24 h cumple Constitución II. date-fns-tz + Intl ya son dependencia aprobada, sin nada nuevo.
  • Alternatives considered: guardar strings locales sin zona — rechazado: ordenación y diferencias de 24 h fallan en DST. Guardar en UTC y mostrar sin zona explícita — rechazado: la spec exige Europe/Madrid nominal; otro nombre es defecto (FR-003).

D4. Fórmulas derivadas y bordes (FR-004…FR-007, Q3/A, US2)

  • Decision: escribir cada fórmula una sola vez en lib/ventanas.ts como función pura (inicio, ahora) => boolean (o cálculo de semanas), con los bordes literales de la spec:
    • RN2 pasado: inicio < ahora (igual no es pasado).
    • Cancelación: inicio − ahora ≥ 24 h (24 h 00 min dentro, 23 h 59 min fuera, sin tope).
    • Recordatorios: 24 h ≤ inicio − ejecución ≤ 48 h (ambos incluidos).
    • Analítica: semana en curso = semana natural lunes–domingo que contiene hoy en Madrid, recalculada en cada llamada (diasLaborables no altera bordes); historia = 8 anteriores cerradas S-8…S-1; la en curso nunca entra.
  • Rationale: Q3/A prohíbe repetir fórmulas en cada spec; un único módulo hace SC-004 verificable por grep y SC-002 testeable en un solo sitio. Funciones puras ⇒ tests sin BD ni reloj real.
  • Alternatives considered: duplicar cada borde en 001/portal/recordatorios/analítica — rechazado por FR-010 (redefinir fuera es defecto). Expresar ventanas en horas naturales «mismo día» — rechazado: la spec exige diferencias exactas de 24/48 h con precisión de minuto.

D5. Formato y etiquetado (FR-003, FR-009, Q4/A)

  • Decision: reutilizar formatearFechaHora/formatearHora/formatearTramo (es-ES, 24 h, sin espacios inseparables). Añadir etiquetarSemana(n, inicio, fin) → S-N: DD/MM/AAAA–DD/MM/AAAA y etiquetarJornada(jornadaInicio, jornadaFin, diasLaborables) → 09:00–20:00, Lun–Vie, derivados de la ventana calculada, nunca constantes.
  • Rationale: Q4/A exige etiquetas derivadas; Intl ya normaliza es-ES. Abreviaturas de día en español de España (Lun…Dom) para el denominador visible (FR-008/FR-009).
  • Alternatives considered: etiquetas constantes o ISO (W39) — rechazadas: Q4/A y Constitución VIII piden forma legible de clínica sin jerga.

D6. Jornada, calendario y denominador honesto (FR-008, Q2/A, US3)

  • Decision: Clinica.diasLaborables Int[] días ISO 1–7, defecto {1,2,3,4,5}, junto a jornadaInicio/Fin (defecto 09:00/20:00). Denominador = minutosJornada × nºDíasLaborables × 8, calculado con la configuración vigente y mostrado junto a cada porcentaje; prohibido hardcodear 26.400 en ningún caso (con defecto se calcula igual). Festivos fuera de v1 (abiertos salvo exclusión explícita). Reconfiguración a mitad de periodo: se usa la vigente y se declara, sin historia.
  • Rationale: Q2/A lo ubica en la entidad Clínica de la 001 (enmienda S-06, ya reflejada en su data-model); derivar el denominador evita ocupaciones infladas en clínicas con sábados (US3). Int[] en Postgres es consultable y migra sin tabla nueva, coherente con FR-011.
  • Alternatives considered: constante 5 días / 26.400 min — rechazada por FR-008 (MUST NOT). Tabla de festivos en v1 — rechazada (fuera de alcance, edge spec). Reconstrucción histórica de jornada — rechazada (edge spec: vigente + declaración).

D7. Prohibición de redefinir y verificación (FR-010, SC-004)

  • Decision: lib/ventanas.ts es la única definición ejecutable; las demás specs solo citan 006 FR-00X por nombre. Verificación canónica en cada merge (misma en QS-7 y T021): grep -rn "now()\|CURRENT_TIMESTAMP\|Europe/\|España (península)\|24 *h\|48 *h\|S-[18]" --include="*.ts" lib app prisma | grep -v "lib/\(tiempo\|ventanas\).ts" debe dar cero definiciones rivales fuera de citas 006 FR-00X (revisión manual de cada hit; diasLaborables no se grepa: es campo legítimo de Clinica).
  • Rationale: convierte SC-004 en chequeo mecánico barato, coherente con Constitución VI (trazabilidad) y IV (cero duplicación).
  • Alternatives considered: revisión solo manual — rechazada: no escala con agentes en paralelo (Constitución I).