Research — 001 Núcleo de agenda (Phase 0)

Fecha: 2026-09-29. Todas las decisiones responden al user input «stack moderno 2026 con apariencia profesional» bajo constitución v1.0.0 (el plan decide stack, no la constitución).

R1. Framework full-stack: Next.js 15 + React 19 + TypeScript estricto

  • Decision: Monolito Next.js 15 (App Router) con React 19 Server Components y TypeScript 5.6 strict. Route Handlers como API. Despliegue Node 22 en contenedor.
  • Rationale: Un solo proyecto cubre US1–US4 (agenda SSR rápida + alta/desenlace interactivos) sin sincronizar dos codebases; Server Components dan primer pintado rápido para SC-001 (<10 s distinguir) y los Route Handlers mantienen RN1/RN2 junto a la BD. TypeScript estricto sostiene la exactitud (II) y la trazabilidad test↔spec (VI).
  • Alternatives considered: Vite SPA + Express separado (rechazado: dos despliegues y duplicación de tipos para una 001 de 5 entidades); Nuxt/Vue (rechazado: equipo y ecosistema shadcn más maduro en React en 2026); FastAPI + React (rechazado: introduce frontera Python/TS sin necesidad de ML en la 001).

R2. UI profesional 2026: Tailwind CSS 4 + shadcn/ui (Radix)

  • Decision: Tailwind 4 (tokens CSS-first) + componentes shadcn/ui (Button, Dialog, Calendar, Toast, Form) con tema clínico claro, WCAG AA, responsive 390px/1440px.
  • Rationale: Exige VII (sin formación, sin jerga, contraste y tamaños accesibles, portátil + móvil) con el estándar 2026 para paneles profesionales; shadcn aporta accesibilidad Radix sin lock-in de librería pesada. Formato es-ES vía Intl en cada componente de fecha/importe.
  • Alternatives considered: MUI/AntD (rechazado: peso y estética genérica, más difícil de ajustar a clínica pequeña); CSS puro sin sistema (rechazado: inconsistencia y coste); ending oscuro por defecto (rechazado: mostrador clínico exige tema claro legible).

R3. Persistencia: PostgreSQL 16 + Prisma 6 (exclusión para RN1)

  • Decision: PostgreSQL 16 como única BD; Prisma 6 para migraciones y acceso; restricción nativa EXCLUDE USING gist (profesionalId WITH =, tstzrange(inicio, fin) WITH &&) WHERE (estado IN ('reservada','completada')) + transacción serializable en POST /api/citas.
  • Rationale: RN1 capital (III) incluido el caso concurrente (FR-008) no se puede garantizar solo en aplicación; la exclusión lo hace imposible a nivel de BD incluso con dos reservas en el mismo instante. Prisma mantiene migraciones versionadas y seed tipado. timestamptz + now() único de BD sostiene RN2.
  • Alternatives considered: SQLite (rechazado: sin exclusión GiST, falso en concurrente); validación solo-aplicación con SELECT previo (rechazado: race condition); Redis lock/cola (rechazado: alcance fantasma IV para 2–5 profesionales).

R4. Dinero y tiempo exactos (II)

  • Decision: Dinero como INTEGER céntimos en BD y dominio; redondeo mitad-hacia-arriba si alguna operación futura genera fracción; visualización con Intl.NumberFormat('es-ES',{style:'currency',currency:'EUR'}) → 40,00 €. Tiempo como timestamptz; dominio en minutos; visualización con Intl.DateTimeFormat('es-ES',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) en Europe/Madrid → 29/09/2026, 10:00–10:45. Precisión de minuto para RN2 (inicio < now() = pasado).
  • Rationale: Cumple FR-017/FR-018 y sus ejemplos límite al céntimo/minuto; evita floats y zonas ambiguas (cambio de hora estacional se muestra en hora local de clínica).
  • Alternatives considered: NUMERIC(10,2)/float euros (rechazado: errores de redondeo); guardar strings formateados (rechazado: rompe ordenación y cálculo); timestamp without tz (rechazado: ambigüedad DST).

R5. Auth v1 deuda consciente + validaciones ES

  • Decision: Clave de panel por clínica guardada con hash (argon2/bcryptjs), comparada en servidor, sin límite ni bloqueo (clarificación Q4), mensaje genérico es-ES. Validación zod: paciente exige nombre+teléfono+email con formatos ES estrictos (teléfono +34/6-9xx, email RFC + es verosímil); nombre de servicio único por clínica (índice único parcial); prohibido DELETE de fichas con citas (Q3/A).
  • Rationale: Respeta FR-002/FR-003/FR-004 y supuestos sin introducir usuarios/roles (deuda explícita para spec posterior). Mensaje genérico no revela claves válidas (US4).
  • Alternatives considered: Auth provider externo / JWT por usuario (rechazado: alcance fantasma); bloqueo tras N fallos (rechazado por Q4/A); unicidad global de pacientes (rechazado por Q5/B).

R6. Semilla determinista Eleva (seedVersion=1)

  • Decision: prisma/seed.ts con PRNG mulberry32(seed=1): Clínica Eleva (jornada 09:00–20:00), 3 profesionales, 4 servicios canónicos, ~40 pacientes ES, 8 semanas pasadas (completada 82 % / no_asistida 10 % / cancelada 8 %, tolerancia ±2) y 2 futuras reservada, solo dentro de jornada y sin solapes vigentes. Clinica.seedVersion fija la versión; regenerar = prisma migrate reset --seed.
  • Rationale: Cumple FR-019 y principio V (misma semilla, misma historia) con datos citables por specs y tests.
  • Alternatives considered: Fixtures JSON ad hoc (rechazado: no versionado ni regenerable bit a bit); faker aleatorio sin semilla (rechazado: rompe SC-005).

R7. Estrategia de tests (VI + III)

  • Decision: Vitest (unit: dinero/tiempo/solape/estados; integración: Route Handlers + Promise.all doble reserva concurrente; semilla determinista) + Playwright (e2e US1–US4 en 1440px y 390px, cronometrando SC-001/SC-004). Matriz test↔FR/SC en quickstart.md. Verde total + antisolape en verde = puerta de merge.
  • Rationale: La constitución exige prueba antisolape incluido concurrente; Playwright prueba VII/VIII tal como lo ve recepción.
  • Alternatives considered: Solo unit sin e2e (rechazado: no prueba responsive ni flujos); Cypress (rechazado: Playwright mejor en 2026 para multi-viewport y trazas).