PatitasLimpias
Documento técnico internoVersión 1.0.020 de agosto de 2026

Esta página no es material comercial: es la especificación funcional-técnica que usan producto, desarrollo y QA para verificar el motor de agenda. El mismo contenido está versionado en el repositorio como docs/ESPECIFICACION-AGENDA-v1.0.0.md.

Especificación funcional-técnica de agenda — PatitasLimpias QA20

Documento: Arquitectura de agenda y reglas críticas de disponibilidad Versión: 1.0.0 Fecha: 20 de agosto de 2026 Estado: Vigente — implementado y desplegado en este repositorio Responsable del servicio: QA Tester Tank — patitaslimpias-qa20@tankmail.lat Ámbito: reservas de baño y peluquería canina por sede, recurso (lavador) y servicio

Este documento no describe un sistema por construir: describe el sistema que corre en este repositorio. Cada regla, estado y validación enunciada aquí tiene su implementación indicada entre paréntesis (archivo y función), de modo que producto, desarrollo y QA verifiquen contra el código y no contra una intención.

1. Objetivo y alcance

1.1 Objetivo

Definir la lógica técnica de disponibilidad en tiempo real por sede, recurso (lavador) y servicio, con una garantía dura: nunca pueden existir dos reservas activas para el mismo lavador en la misma franja horaria. La especificación cubre además buffers, cancelaciones, reagendamientos, control de concurrencia y trazabilidad completa de los estados de la cita.

1.2 Dentro del alcance

  • Cálculo de disponibilidad en tiempo real (sin caché).
  • Modelo de datos de agenda: sedes, horarios, servicios, lavadores, habilidades,

turnos, bloqueos, clientes, mascotas, citas, ocupación y eventos.

  • Máquina de estados de la cita, con 9 estados y transiciones cerradas.
  • Reglas de negocio verificables (20), casos de prueba (15), escenarios de

concurrencia (5) y criterios de aceptación de QA (10).

  • Prevención de doble reserva a nivel de motor de base de datos.

1.3 Fuera del alcance de la versión 1.0.0

  • Cobro en línea. El sitio no procesa pagos; el valor se paga en la sede.
  • Notificaciones automáticas por correo o WhatsApp. La app genera enlaces de

compartición (wa.me, mailto:) que dispara la persona, no el servidor.

  • Recursos físicos distintos del lavador (tinas, secadores) como restricción

independiente. Ver §12, evolución prevista.

  • Reservas recurrentes y listas de espera.

1.4 Glosario

TérminoDefinición operativa
Franja / slotUnidad atómica de agenda: 15 minutos. SLOT_MIN = 15.
RecursoLavador. Es el único recurso que se reserva de forma exclusiva en v1.0.0.
BufferMinutos posteriores al servicio reservados al mismo lavador para alistar el puesto.
OcupaciónFila en cita_slots: un lavador, una fecha, una franja.
Cita activaCita en estado pendiente, confirmada o en_proceso. Ocupa agenda.
Ventana de reservaDías hacia adelante en los que se puede reservar (por defecto 30).
Antelación mínimaMinutos que deben faltar para poder reservar una franja de hoy (por defecto 60).

2. Arquitectura

2.1 Diagrama de arquitectura

                 NAVEGADOR (cliente final)                  NAVEGADOR (operación)
        ┌──────────────────────────────────┐        ┌───────────────────────────────┐
        │  /                landing SSR    │        │  /panel        login/setup    │
        │  /reservar        wizard 3 pasos │        │  /panel/agenda día por sede   │
        │  /cita/[token]    enlace público │        │  /panel/citas/[id] detalle    │
        │                                  │        │  /panel/recursos config       │
        └───────────────┬──────────────────┘        └───────────────┬───────────────┘
                        │ fetch JSON                                │ server actions
                        │ (no-store)                                │ (cookie de sesión)
                        ▼                                           ▼
        ┌───────────────────────────────────────────────────────────────────────────┐
        │                      CAPA HTTP — Next.js App Router                       │
        │                                                                           │
        │  GET  /api/disponibilidad   franjas libres (público, solo horas)          │
        │  POST /api/citas            alta de reserva                               │
        │  server actions /cita/...   cancelar y reagendar con token                │
        │  server actions /panel/...  transiciones, config; exigen sesión           │
        │  GET  /health               liveness del pipeline                         │
        └───────────────────────────────┬───────────────────────────────────────────┘
                                        │  (toda decisión ocurre aquí abajo)
                                        ▼
        ┌───────────────────────────────────────────────────────────────────────────┐
        │                    MOTOR DE AGENDA — lib/agenda.ts                        │
        │                                                                           │
        │   disponibilidad()   horario sede ∩ turno lavador − bloqueos              │
        │                      − ocupación − antelación − ventana                   │
        │   crearCita()        BEGIN IMMEDIATE → recálculo → SAVEPOINT por lavador  │
        │   reagendarCita()    libera franjas propias → crea sucesora enlazada      │
        │   cambiarEstado()    valida contra TRANSICIONES → registra evento         │
        │   expirarPendientes() barrido perezoso de pendientes vencidas             │
        └───────────────────────────────┬───────────────────────────────────────────┘
                                        │  SQL (node:sqlite, síncrono)
                                        ▼
        ┌───────────────────────────────────────────────────────────────────────────┐
        │              SQLite en volumen persistente — lib/db.ts                    │
        │                                                                           │
        │   cita_slots(lavador_id, fecha, slot_min)  ◄── PK = barrera anti-doble    │
        │   citas · cita_eventos · bloqueos · horarios · catálogos · ajustes        │
        │   migraciones numeradas aplicadas al arranque (migrations/*.sql)          │
        └───────────────────────────────────────────────────────────────────────────┘

2.2 Decisiones de arquitectura y su motivo

#DecisiónMotivo
A-1La franja mínima es de 15 minutos y toda duración debe ser múltiplo de 15.Permite representar la ocupación como filas discretas y convertir el anti-solapamiento en una restricción de clave primaria. Sin discretizar, el anti-solapamiento requiere comparaciones de rango y bloqueos explícitos.
A-2La ocupación se materializa en cita_slots en vez de derivarse de citas.Un índice único sobre (lavador, fecha, franja) hace que la doble reserva sea imposible aunque falle la validación previa. La barrera es del motor, no del código.
A-3El buffer se reserva como franjas propias marcadas tipo='buffer'.El alistamiento consume agenda real. Si el buffer no ocupara franjas, dos citas seguidas dejarían al lavador sin tiempo entre una y otra.
A-4Las horas se guardan como fecha (YYYY-MM-DD) + inicio_min (minutos desde medianoche) en hora local de la sede.Colombia no aplica horario de verano, así que la hora local es estable. Guardar UTC obligaría a convertir en cada consulta y volvería ilegibles las consultas de agenda. La zona horaria queda registrada por sede (sedes.zona_horaria) para cuando deje de ser homogénea.
A-5La disponibilidad no se cachea nunca.Una franja libre en caché es una promesa que el sistema no puede cumplir. El costo de recalcular es una consulta indexada por día.
A-6Reagendar crea una cita sucesora en vez de mutar la original.Conserva el historial completo: qué se prometió primero, cuándo se movió y hacia dónde. La fila original queda en estado reagendada y enlaza con la nueva por reagendada_desde_id.
A-7El panel de operación exige sesión; no existe lectura pública de datos de clientes.Nombre, teléfono y correo son datos personales. La única lectura sin sesión es la de la propia cita, mediante un token aleatorio de 16 caracteres.
A-8La expiración de citas pendientes es un barrido perezoso, no un cron.El despliegue es un contenedor sin planificador. El barrido se ejecuta en cada consulta de disponibilidad y al abrir la agenda, que es cuando el resultado importa.

2.3 Componentes y responsabilidad

ComponenteArchivoResponsabilidadNo hace
Motor de agendalib/agenda.tsDisponibilidad, alta, reagendamiento, transiciones, bloqueos, expiración.No formatea, no autentica, no responde HTTP.
Configuraciónlib/configuracion.tsAlta y edición de sedes, servicios, lavadores, turnos y parámetros.No toca citas ni ocupación.
Accesolib/auth.tsOperador único inicial, sesiones con cookie httpOnly, scrypt con sal por usuario.No expone datos de clientes.
Persistencialib/db.ts (provisto)Conexión SQLite, migraciones al arranque.
API públicaapp/api/disponibilidad, app/api/citasTraducción HTTP ↔ motor.No decide disponibilidad.
Acciones de citaapp/cita/[token]/acciones.tsCancelar y reagendar con el token como credencial.No confía en lo que el navegador afirme.
Acciones de panelapp/panel/acciones.tsTransiciones y configuración; primera línea de cada acción: exigir sesión.

3. Modelo de datos

3.1 Diagrama entidad-relación

   sedes 1───N horarios_sede            servicios 1───N lavador_servicios N───1 lavadores
     │                                      │                                     │
     │ 1                                    │ 1                                   │ 1
     │                                      │                                     │
     N                                      N                                     N
  lavadores 1───N horarios_lavador        citas ────────────────────────────────► │
     │                                      │  ▲                                  │
     │ 1                                    │  │ reagendada_desde_id (auto-FK)    │
     N                                      │  └──────────────────────────────────┘
  bloqueos                                  │
                                            ├──1 clientes 1───N mascotas
                                            ├──N cita_slots      (ocupación 15 min)
                                            └──N cita_eventos    (trazabilidad)

  ajustes (clave/valor)      operadores 1───N sesiones_panel

3.2 Entidades clave

1. `sedes` — punto de atención.

ColumnaTipoRegla
idINTEGER PK
nombreTEXT NOT NULL2–80 caracteres.
direccion, ciudadTEXT NULLOpcionales; si faltan, la UI omite el bloque.
zona_horariaTEXT NOT NULLPor defecto America/Bogota. Define qué es "hoy" y "ahora" para esa sede.
activaINTEGER NOT NULL0 = no aparece en el sitio ni ofrece franjas. Nunca se borra: hay citas históricas que la referencian.

2. `horarios_sede` — franja de apertura por día de semana.

ColumnaTipoRegla
sede_idFK sedes
dia_semanaINTEGER0 = domingo … 6 = sábado.
abre_min, cierra_minINTEGERMinutos desde medianoche. cierra_min > abre_min.
UNIQUE (sede_id, dia_semana). La ausencia de fila = sede cerrada ese día.

3. `servicios` — catálogo transversal a las sedes.

ColumnaTipoRegla
duracion_minINTEGERMúltiplo de 15, entre 15 y 480.
buffer_minINTEGERMúltiplo de 15, entre 0 y 120.
precio_centavosINTEGEREntero en centavos. Se muestra siempre con código ISO 4217 (COP 40.000).
monedaTEXTCOP en v1.0.0.
tamanoTEXTtodos, pequeno, mediano o grande. Restringe qué mascota puede reservarlo.
activoINTEGER0 = fuera del catálogo, sin borrar historia.

4. `lavadores` — el recurso que se reserva en exclusiva.

ColumnaTipoRegla
sede_idFK sedesUn lavador pertenece a una sola sede.
nombreTEXT NOT NULL2–80 caracteres.
activoINTEGER0 = no recibe nuevas citas; las ya agendadas siguen visibles.

5. `lavador_servicios` — habilidades. PK (lavador_id, servicio_id). Un servicio se ofrece en una sede si y solo si al menos un lavador activo de esa sede lo tiene asignado.

6. `horarios_lavador` — turno por día. UNIQUE (lavador_id, dia_semana). La ausencia de fila = ese lavador no trabaja ese día.

7. `bloqueos` — ausencias, mantenimiento, cierres parciales.

ColumnaRegla
sede_idObligatorio.
lavador_idNULL = bloqueo de toda la sede.
fecha, inicio_min, fin_minfin_min > inicio_min.
motivoTexto libre, hasta 120 caracteres.

8. `citas` — la reserva.

ColumnaRegla
tokenUNIQUE, 16 caracteres base64url aleatorios (randomBytes(12)). Credencial del enlace público.
sede_id, lavador_id, servicio_id, cliente_id, mascota_idFK obligatorias.
fecha, inicio_minHora local de la sede.
duracion_min, buffer_min, precio_centavos, monedaCopiados del servicio al momento de reservar. Cambiar el catálogo no reescribe citas existentes.
estadoVer §4.
reagendada_desde_idFK a citas. Enlaza la cadena de reagendamientos.
versionEntero incremental; sube en cada transición (bloqueo optimista).
creada_en, actualizada_enMarcas de tiempo UTC.

9. `cita_slots` — ocupación materializada. La entidad crítica.

ColumnaRegla
lavador_id, fecha, slot_minPRIMARY KEY compuesta. Es la barrera anti doble reserva.
cita_idFK a la cita dueña de la franja.
tiposervicio o buffer.

10. `cita_eventos` — bitácora inmutable.

ColumnaRegla
tipocreada, transicion, reagendada, nota.
estado_anterior, estado_nuevoAmbos extremos de la transición.
actorcliente, operacion o sistema.
detalleTexto explicativo (motivo, destino del reagendamiento, causa de expiración).
creado_enUTC. Nunca se actualiza ni se borra.

11. `clientes` y 12. `mascotas` — identidad mínima. Del cliente se guarda nombre y al menos un canal de contacto (correo o teléfono). De la mascota, nombre, talla, raza opcional y notas de cuidado.

13. `ajustes` — parámetros operativos clave/valor: granularidad_min, antelacion_minima_min, ventana_reserva_dias, cancelacion_sin_costo_horas, reagendamiento_limite_horas, max_reagendamientos.

14. `operadores` y 15. `sesiones_panel` — acceso del equipo.

3.3 Índices

ÍndicePropósito
cita_slots PK (lavador_id,fecha,slot_min)Anti-solapamiento y consulta de ocupación del día.
idx_cita_slots_citaLiberar todas las franjas de una cita en una sentencia.
idx_citas_dia (sede_id,fecha,inicio_min)Agenda del día ordenada.
idx_citas_lavador (lavador_id,fecha)Carga por lavador.
idx_bloqueos_dia (sede_id,fecha)Bloqueos aplicables al día consultado.
idx_cita_eventos_cita (cita_id,id)Historial en orden.

3.4 Política de migraciones

Las migraciones son numeradas y aditivas (migrations/001_init.sql, …). Nunca se edita una migración aplicada, nunca se ejecuta DROP ni DELETE de datos de usuario. Para desactivar entidades se usa la bandera activo/activa. Los datos de demostración viven exclusivamente en migrations/demo-seed.sql, que solo corre con DEMO_SEED=1 (entornos QA).


4. Máquina de estados de la cita

4.1 Estados (9)

#EstadoSignificado¿Ocupa agenda?¿Terminal?
1pendienteReservada por el cliente, sin confirmar por la sede. La franja ya está bloqueada.No
2confirmadaLa sede confirmó la cita.No
3en_procesoEl servicio comenzó.No
4completadaEl servicio terminó.Sí (histórico)
5cancelada_clienteCancelada por el cliente desde su enlace.No — libera
6cancelada_sedeCancelada por operación.No — libera
7no_showEl cliente no se presentó.Sí (histórico)
8reagendadaCerrada porque se movió; enlaza con la sucesora.No — libera
9expiradaPendiente que nunca se confirmó y cuya hora pasó.No — libera

4.2 Diagrama de transiciones

                        ┌──────────────┐
          reserva ─────►│  pendiente   │
                        └──┬───┬───┬───┘
              confirmar    │   │   │    +15 min tras la hora, sin confirmar
                 ┌─────────┘   │   └──────────────────────────► expirada
                 ▼             │
          ┌──────────────┐     │ cancelar (cliente|sede) ─────► cancelada_*
          │  confirmada  │     │ reagendar ────────────────────► reagendada ──► (sucesora)
          └──┬───┬───┬───┘
     iniciar │   │   │ no_show ─────────────────────────────► no_show
             │   │   └─ cancelar (cliente|sede) ────────────► cancelada_*
             │   └───── reagendar ───────────────────────────► reagendada ──► (sucesora)
             ▼
      ┌──────────────┐  completar
      │  en_proceso  │───────────────────────────────────────► completada
      └──────┬───────┘
             └───────── cancelar (solo sede) ────────────────► cancelada_sede

4.3 Matriz de transiciones permitidas

lib/agenda.tsTRANSICIONES. Cualquier par no listado se rechaza con mensaje explícito («No se puede pasar de X a Y») y no deja rastro de cambio.

Desde \ Haciapendienteconfirmadaen_procesocompletadacancelada_clientecancelada_sedeno_showreagendadaexpirada
pendiente✅ (sistema)
confirmada
en_proceso
completada
cancelada_cliente
cancelada_sede
no_show
reagendada
expirada

4.4 Efecto sobre las franjas

TransiciónEfecto en cita_slotsMotivo
cancelada_cliente / cancelada_sedeLibera todas las franjas de la cita.La hora vuelve al mercado de inmediato.
expiradaLibera.Nunca se confirmó; retener la hora no aporta.
reagendadaLibera (antes de insertar las de la sucesora).La hora vieja queda disponible en el mismo instante en que se ocupa la nueva.
completada / no_showConserva.El tiempo se consumió: la agenda histórica debe mostrar al lavador ocupado en esa franja.

5. Cálculo de disponibilidad en tiempo real

5.1 Entradas

disponibilidad({ sedeId, servicioId, fecha, excluirCitaId? })lib/agenda.ts.

5.2 Algoritmo

 1. Validar fecha (formato y existencia real del día).
 2. Sede existe y activa            → si no: sin franjas, motivo explícito.
 3. Servicio existe y activo        → si no: sin franjas, motivo explícito.
 4. delta = fecha − hoy(sede)
    delta < 0                       → "Esa fecha ya pasó."
    delta > ventana_reserva_dias    → "Solo puedes reservar hasta con N días…"
 5. Horario de la sede para ese día de semana
    sin fila                        → "La sede no atiende los <día>."
 6. requerido = duracion_min + buffer_min
    slotsRequeridos = ceil(requerido / 15)
 7. Candidatos = lavadores activos de la sede
                 ∩ que tienen el servicio asignado
                 ∩ con turno definido ese día de semana
    vacío                           → "No hay lavadores disponibles para ese servicio los <día>."
 8. Cargar en memoria, para esa fecha y esos lavadores:
      - ocupación (cita_slots), excluyendo la cita propia si se está reagendando
      - bloqueos de la sede (lavador_id NULL) y de cada lavador
      - carga del día por lavador (conteo de franjas ocupadas) para el balanceo
 9. minimoInicio = (delta == 0) ? ahora + antelacion_minima_min : 0
10. Para cada inicio desde abre_min hasta cierra_min − requerido, paso 15:
      si inicio < minimoInicio                       → descartar
      libres = []
      para cada lavador candidato:
        si inicio < turno.inicio o inicio+requerido > turno.fin  → siguiente
        si [inicio, inicio+requerido) solapa un bloqueo aplicable → siguiente
        si alguna de las slotsRequeridos franjas está ocupada     → siguiente
        libres.push(lavador)
      si libres no vacío:
        ordenar libres por (carga del día ASC, id ASC)   ← balanceo determinista
        emitir { inicio_min, etiqueta HH:MM, capacidad: libres.length, lavadores: libres }

5.3 Salida

Lista ordenada de franjas con su capacidad (cuántos lavadores podrían tomarla). La API pública no expone la identidad de los lavadores, solo la capacidad: quien consulta horas libres no necesita saber quién está libre.

5.4 Propiedades garantizadas

  1. Toda franja ofrecida cabe completa —servicio y buffer— dentro del horario

de la sede y del turno del lavador que la tomaría.

  1. Ninguna franja ofrecida solapa una ocupación existente ni un bloqueo.
  2. La respuesta refleja el estado del instante: no hay caché intermedia

(cache-control: no-store, dynamic = "force-dynamic").

  1. Ofrecer una franja no la reserva. La reserva es el único acto que ocupa

agenda, y revalida todo (§6).


6. Alta de la reserva y prevención de doble reserva

6.1 Secuencia

 cliente                API /api/citas             crearCita()                  SQLite
   │  POST {sede, servicio, fecha, hora, datos}                                  │
   ├───────────────────────►│                                                    │
   │                        │ valida formato, límites, consentimiento            │
   │                        ├──────────────────────►│                            │
   │                        │                       │ BEGIN IMMEDIATE ──────────►│ (lock de escritura)
   │                        │                       │ disponibilidad(...) ──────►│
   │                        │                       │   franja ausente → ROLLBACK, 409
   │                        │                       │ upsert cliente + mascota ─►│
   │                        │                       │ para cada lavador libre:   │
   │                        │                       │   SAVEPOINT intento ──────►│
   │                        │                       │   INSERT cita ────────────►│
   │                        │                       │   INSERT N cita_slots ────►│
   │                        │                       │     conflicto de PK → ROLLBACK TO intento, siguiente lavador
   │                        │                       │   evento 'creada' ────────►│
   │                        │                       │ COMMIT ───────────────────►│
   │◄───────────────────────┤ 201 { token, url }    │                            │

6.2 Las tres barreras

BarreraDóndeQué evita
1. PresentaciónEl navegador solo muestra franjas devueltas por la API.Que el usuario elija una hora obviamente imposible. No es una garantía: la vista envejece.
2. Revalidación transaccionaldisponibilidad() se vuelve a ejecutar dentro de BEGIN IMMEDIATE.Que una franja tomada hace 40 segundos se confirme igual.
3. Restricción del motorPK (lavador_id,fecha,slot_min) en cita_slots.La doble reserva, incluso si 1 y 2 fallaran por un error de código.

La barrera 3 es la que convierte la promesa en garantía: no depende de que el código esté bien escrito, sino de que el motor rechace la fila.

El código de error distingue dónde se detectó el choque: no_disponible cuando lo vio la revalidación de la barrera 2, conflicto cuando lo rechazó la clave primaria de la barrera 3. Para el visitante ambos son la misma respuesta HTTP 409 y el mismo camino: volver a elegir hora con la disponibilidad fresca.

6.3 Reintento por lavador

Cuando una franja tiene capacidad 2 y dos clientes la piden a la vez, el primero toma al lavador con menos carga. El segundo, al chocar con la PK, no falla: ROLLBACK TO SAVEPOINT y reintenta con el siguiente lavador libre. Solo si se agotan todos los candidatos se responde 409 con el mensaje «Alguien acaba de tomar esa hora».


7. Buffers

ReglaDetalle
Definiciónservicios.buffer_min, múltiplo de 15, de 0 a 120 minutos.
MomentoPosterior al servicio, nunca previo.
OcupaciónSe materializa como franjas tipo='buffer' del mismo lavador.
Efecto en la ofertaUna franja se ofrece solo si inicio + duracion + buffer cabe en el horario de la sede y en el turno del lavador.
Efecto en la vista del clienteEl cliente ve la hora de inicio y fin del servicio; el buffer se declara aparte («+15 min de alistamiento en agenda»).
CongelamientoEl buffer se copia a la cita al reservar. Cambiar el catálogo después no altera las citas ya creadas.

Ejemplo. Servicio de 75 min + buffer de 15 = 90 min = 6 franjas. Una reserva a las 10:00 ocupa 10:00, 10:15, 10:30, 10:45, 11:00 (servicio) y 11:15 (buffer). La siguiente hora ofrecible para ese lavador es 11:30.


8. Cancelación y reagendamiento

8.1 Cancelación

AspectoCliente (enlace público)Operación (panel)
Estados de origenpendiente, confirmadapendiente, confirmada, en_proceso
Estado destinocancelada_clientecancelada_sede
FranjasSe liberan de inmediatoSe liberan de inmediato
RegistroEvento con motivo y marca de cancelación tardía si faltan menos de cancelacion_sin_costo_horasEvento con motivo
ReversiónNo existe. Cancelar es terminal; para volver hay que reservar de nuevo.Igual

8.2 Reagendamiento

AspectoClienteOperación
Límite temporalHasta reagendamiento_limite_horas (4 h) antes del inicioSin límite temporal
Límite de vecesmax_reagendamientos (2) por cadenaSin límite
Franja destinoDebe estar libre; se valida dentro de la transacciónIgual — operación tampoco puede sobrescribir a otro cliente
LavadorSe prefiere conservar el mismo si sigue libre; si no, se asigna otro candidatoIgual
Estado resultanteLa sucesora hereda confirmada si la original lo estaba; si no, nace pendienteIgual
HistorialOriginal → reagendada + evento; sucesora → evento creada con origenIgual
Enlace públicoEl token viejo redirige al vigente siguiendo reagendada_desde_id

8.3 Bloqueos y citas ya reservadas

Crear un bloqueo no cancela las citas que caen dentro. El sistema las devuelve como conflictos y avisa a operación («N cita(s) ya reservada(s) caen dentro del bloqueo y siguen en pie»). Cancelar automáticamente citas de clientes reales por una acción de configuración sería una pérdida silenciosa de compromisos ya adquiridos.


9. Reglas de negocio (20)

IDReglaVerificación
RN-01Un lavador no puede tener dos citas activas que ocupen la misma franja de 15 min.PK de cita_slots. Intento de violación → error de restricción → 409.
RN-02Toda cita ocupa ceil((duracion + buffer)/15) franjas consecutivas del mismo lavador.Conteo de filas en cita_slots por cita_id.
RN-03Una franja se ofrece solo si el servicio más su buffer caben dentro del horario de la sede.inicio + requerido <= cierra_min.
RN-04Una franja se ofrece solo si cabe dentro del turno del lavador que la tomaría.inicio >= turno.inicio && inicio + requerido <= turno.fin.
RN-05Entre varios lavadores libres para una franja gana el de menor carga del día; a igual carga, el de menor id.Orden determinista, reproducible en pruebas.
RN-06La disponibilidad nunca se sirve desde caché.no-store en la API; force-dynamic en las páginas.
RN-07La disponibilidad se revalida dentro de la transacción de alta; lo que vio el navegador no decide.crearCita() llama a disponibilidad() tras BEGIN IMMEDIATE.
RN-08Para el día de hoy solo se ofrecen franjas que empiecen al menos antelacion_minima_min (60) después de ahora.Comparación contra la hora local de la sede.
RN-09No se reserva más allá de ventana_reserva_dias (30) ni en fechas pasadas.Rechazo con motivo explícito.
RN-10Un servicio se ofrece en una sede solo si un lavador activo de esa sede lo tiene asignado.serviciosDeSede() cruza habilidades.
RN-11Al reagendar, las franjas de la propia cita no cuentan como ocupadas.excluirCitaId en el cálculo; permite mover 10:00 → 10:15.
RN-12Al reagendar se conserva el mismo lavador si sigue libre en el destino.Orden de candidatos con el original primero.
RN-13Un servicio con talla definida solo admite mascotas de esa talla.Validación en crearCita() con mensaje que nombra el servicio.
RN-14Toda transición de estado debe existir en la matriz TRANSICIONES; si no, se rechaza sin efectos.Rechazo previo a cualquier escritura.
RN-15Cancelar, expirar y reagendar liberan las franjas de inmediato.DELETE FROM cita_slots WHERE cita_id = ? dentro de la transacción.
RN-16Completar y marcar no-asistió no liberan franjas: el tiempo se consumió.La agenda histórica muestra al lavador ocupado.
RN-17Toda transición deja un evento inmutable con actor, estados y marca de tiempo.cita_eventos no se actualiza ni se borra.
RN-18Una cita pendiente cuya hora pasó hace más de 15 min se marca expirada y libera su franja.Barrido perezoso en disponibilidad y agenda.
RN-19Un bloqueo nuevo no cancela citas existentes; se reportan como conflictos a operación.Lista de conflictos en el aviso del panel.
RN-20La cita congela precio, duración y buffer del servicio al momento de reservar.Cambiar el catálogo no reescribe citas ya creadas.

9.1 Reglas de datos personales y acceso

IDRegla
RD-01Ninguna ruta pública lista clientes, correos o teléfonos. La lectura de datos personales exige sesión de panel o el token de la propia cita.
RD-02El token de cita es aleatorio (96 bits), no correlativo, y las páginas de cita se sirven con noindex.
RD-03El cliente debe aceptar explícitamente la Política de Privacidad para poder agendar (acepta === true validado en el servidor).
RD-04Cada cita exige al menos un canal de contacto: correo o teléfono.

10. Casos de prueba de disponibilidad y conflicto (15)

Datos base de los casos: sede abierta de 08:00 a 18:00; servicio S60 de 60 min con buffer de 15 (5 franjas); lavador L1 con turno 08:00–16:00; lavador L2 con turno 10:00–18:00; ambos con S60 asignado; granularidad 15 min; antelación 60 min; ventana 30 días.

#CasoPrecondiciónAcciónResultado esperado (inequívoco)
CP-01Oferta básicaAgenda vacía, fecha futuraConsultar disponibilidad de S60Franjas de 08:00 a 16:45; ninguna después de 16:45 (16:45+75 = 18:00). Capacidad 2 entre 10:00 y 14:45; 1 fuera de ese rango.
CP-02Anti-solapamiento simpleL1 ocupado 10:00–11:15 (S60+buffer), L2 inexistenteConsultar 10:00, 10:15, 10:30, 10:45, 11:00Ninguna de esas cinco franjas se ofrece. La primera ofrecida es 11:15.
CP-03Buffer que impide la franja contiguaL1 con cita 10:00–11:00 y buffer hasta 11:15; L2 ausente ese díaIntentar reservar 11:00Rechazado: 11:00 no aparece en la oferta. Sí aparece 11:15.
CP-04Cierre de sedeSede cierra 18:00Consultar 17:00 para S60 (75 min requeridos)17:00 no se ofrece (17:00+75 = 18:15 > 18:00). Última ofrecida: 16:45.
CP-05Fuera del turno del lavadorSolo L1 (hasta 16:00) tiene S60Consultar 15:15No se ofrece (15:15+75 = 16:30 > 16:00). Última ofrecida para L1: 14:45.
CP-06Bloqueo de lavadorBloqueo L1 12:00–13:00; L2 libreConsultar 11:30 y 12:00Ambas se ofrecen con capacidad 1 (solo L2). Ninguna con capacidad 2.
CP-07Bloqueo de sede completaBloqueo con lavador_id NULL, 12:00–13:00Consultar 11:30 y 12:00Ninguna de las dos se ofrece: el bloqueo aplica a todos los lavadores.
CP-08Antelación mínimaHoy, hora local 09:10, antelación 60Consultar hoyLa primera franja ofrecida es 10:15 (primer múltiplo de 15 ≥ 10:10). 10:00 no se ofrece.
CP-09Fecha pasadaFecha = ayerConsultarCero franjas, motivo «Esa fecha ya pasó.»
CP-10Fuera de ventanaFecha = hoy + 31, ventana 30ConsultarCero franjas, motivo que menciona los 30 días.
CP-11Día sin horario de sedeDomingo sin fila en horarios_sedeConsultar domingoCero franjas, motivo «La sede no atiende los domingo.»
CP-12Servicio sin lavador habilitadoS60 no asignado a ningún lavador activo de la sedeConsultarCero franjas, motivo «No hay lavadores disponibles para ese servicio los <día>.» Además, el servicio no aparece en el selector de esa sede.
CP-13Reserva de franja ya tomada (vista envejecida)Cliente A tomó la última capacidad de las 10:00; el navegador de B aún la muestraB envía POST /api/citas para 10:00HTTP 409 y ninguna fila nueva en citas ni en cita_slots. El cuerpo trae codigo: "no_disponible" («Esa franja ya no está disponible. Elige otra hora.») cuando la revalidación interna ya ve la franja ocupada, o codigo: "conflicto" («Alguien acaba de tomar esa hora…») cuando el choque se detecta en la clave primaria. Ambos son 409 y ambos devuelven al paso 2 con las horas actualizadas.
CP-14Reagendar sobre sí mismaCita a las 10:00, se mueve a 10:15 (solapa consigo misma)Reagendar a 10:15Aceptado: se liberan primero las franjas propias. Original → reagendada; sucesora en 10:15 con reagendada_desde_id apuntando a la original.
CP-15Liberación por cancelaciónL1 sin capacidad libre a las 10:00; la única cita se cancelaConsultar 10:00 tras la cancelaciónLa franja vuelve a ofrecerse en la misma consulta siguiente, con capacidad incrementada en 1.

10.1 Casos complementarios de estado

#CasoAcciónResultado esperado
CE-01Transición ilegalMarcar completada una cita pendienteRechazo con «No se puede pasar de "Pendiente de confirmar" a "Completada"». Estado y franjas sin cambios.
CE-02Cancelar una cita completadaCancelar desde el panelRechazo: completada es terminal.
CE-03Reagendar dentro del límiteCliente reagenda faltando 2 h (límite 4 h)Rechazo con el número de horas de la política. Operación sí puede.
CE-04Tope de reagendamientosCadena con 2 reagendamientos previosTercer intento del cliente rechazado; el mensaje indica el máximo.
CE-05ExpiraciónCita pendiente de ayerAl consultar disponibilidad hoy queda expirada, con evento de actor sistema, y sus franjas liberadas.

11. Matriz de escenarios de concurrencia (5)

Mecanismo base: BEGIN IMMEDIATE serializa a los escritores del archivo SQLite; la PK de cita_slots es la barrera final; SAVEPOINT permite reintentar con otro lavador sin abortar la transacción completa.

#EscenarioSecuenciaComportamiento esperadoGarantía que lo sostiene
C-01Dos clientes, misma franja, un solo lavador libreA y B envían POST para 10:00 casi simultáneamenteEl primero en obtener el lock crea la cita. El segundo revalida dentro de su transacción, ya no ve la franja y recibe 409 con mensaje de franja tomada. Se crea exactamente una cita y cita_slots tiene una sola fila por franja.BEGIN IMMEDIATE + revalidación interna (RN-07)
C-02Dos clientes, misma franja, dos lavadores libresA y B envían POST para 10:00Ambas reservas se crean: A toma el lavador de menor carga, B toma el otro. Capacidad de esa franja pasa de 2 → 0. Ninguna colisión visible para el usuario.Balanceo determinista (RN-05) + reintento con SAVEPOINT
C-03Reserva vs. reagendamiento hacia la misma franjaA reserva 11:00 mientras B mueve su cita a 11:00; único lavadorGana quien obtiene primero el lock de escritura. El perdedor recibe 409/«franja no disponible» y su cita original queda intacta (en el reagendamiento, la liberación de franjas propias y la inserción de las nuevas ocurren en la misma transacción: o pasa todo, o no pasa nada).Atomicidad de la transacción de reagendamiento
C-04Cancelación concurrente con nueva reserva sobre la franja liberadaB cancela su cita de 15:00 mientras A intenta reservar 15:00Si la cancelación confirma primero, A obtiene la franja normalmente. Si A llega primero, ve la franja ocupada y recibe 409; tras la cancelación, un reintento suyo tiene éxito. Nunca quedan dos citas activas sobre la misma franja ni una franja huérfana.Serialización de escritores + DELETE de franjas dentro de la transacción de cancelación
C-05Doble envío del mismo formulario (doble clic / reintento de red)El navegador de A envía dos veces el mismo POSTLa primera crea la cita. La segunda no encuentra libre la franja (la ocupó la primera) y responde 409 si no quedan lavadores; si quedaba otro lavador libre, se crea una segunda cita legítima para el mismo cliente en la misma hora con otro lavador. La UI deshabilita el botón mientras hay envío en curso para evitarlo.Barrera de PK; mitigación de UI en el envío

Nota de honestidad técnica sobre C-05. En v1.0.0 no existe clave de idempotencia por envío. La barrera impide la doble reserva del *mismo lavador*, no que un cliente termine con dos citas legítimas en lavadores distintos si fuerza dos envíos. La mitigación actual es de interfaz (botón bloqueado durante el envío). La solución completa —token de idempotencia por intento— está listada en §12.


12. Evolución prevista (no implementado en v1.0.0)

TemaDescripciónMotivo de aplazamiento
Idempotencia de altaToken por intento de reserva, único en base, para colapsar reenvíos del mismo formulario.Requiere migración adicional; el riesgo actual es acotado y visible.
Recursos físicosTinas y secadores como recursos con su propia exclusividad.El cuello de botella observado es la persona, no el equipo.
Notificaciones automáticasRecordatorios por correo/WhatsApp.Exige proveedor externo; hoy el compartir es una acción del usuario.
Lista de esperaAvisar cuando se libera una franja.Depende de notificaciones.
Horarios partidosMás de un bloque por día (mañana/tarde) en horarios_sede.Hoy se resuelve con un bloqueo intermedio.

13. Criterios de aceptación para QA (10)

#CriterioCómo se verificaAprueba si
QA-01Imposibilidad de doble reserva del mismo lavadorReservar toda la capacidad de una franja y forzar un POST adicional a esa horaRespuesta 409, y SELECT COUNT(*) FROM cita_slots GROUP BY lavador_id, fecha, slot_min HAVING COUNT(*) > 1 devuelve cero filas
QA-02El buffer ocupa agendaReservar S60 (60+15) a las 10:00Se crean 5 filas en cita_slots (4 servicio + 1 buffer) y la siguiente franja ofrecida para ese lavador es 11:15
QA-03Ninguna franja ofrecida se sale del horarioConsultar disponibilidad en una sede que cierra a las 18:00Ninguna franja cumple inicio + duracion + buffer > cierra_min
QA-04Antelación mínima respetadaConsultar el día de hoyNinguna franja ofrecida empieza antes de ahora + antelacion_minima_min
QA-05Cancelar libera la horaCancelar una cita y volver a consultar esa fechaLa franja reaparece en la consulta inmediatamente siguiente y cita_slots no tiene filas de esa cita
QA-06Reagendar conserva historialReagendar una cita desde el enlace públicoExisten dos filas en citas (original reagendada, sucesora activa con reagendada_desde_id), el token viejo redirige al nuevo y hay eventos en ambas
QA-07Transiciones ilegales rechazadasIntentar pendiente → completada y completada → cancelada_sedeAmbas rechazadas con mensaje que nombra los estados; ningún cambio de estado ni evento registrado
QA-08Trazabilidad completaRecorrer una cita por creación, confirmación, inicio y cierrecita_eventos tiene un registro por transición con actor, estado anterior, estado nuevo y marca de tiempo; el historial es visible en el panel y en el enlace del cliente
QA-09Sin lectura pública de datos personalesRecorrer las rutas del sitio sin sesiónNinguna página o API sin autenticar lista nombres, correos o teléfonos; /panel/* redirige a login; la cita solo es accesible con su token
QA-10Concurrencia sin corrupciónEjecutar los cinco escenarios de §11En todos los casos: una cita por franja y lavador, sin franjas huérfanas (cita_slots sin cita activa) y sin citas activas sin franjas

13.1 Consultas de verificación

-- QA-01: doble reserva (debe devolver 0 filas)
SELECT lavador_id, fecha, slot_min, COUNT(*) AS n
  FROM cita_slots GROUP BY 1,2,3 HAVING n > 1;

-- QA-10a: franjas huérfanas (debe devolver 0 filas)
SELECT s.* FROM cita_slots s JOIN citas c ON c.id = s.cita_id
 WHERE c.estado NOT IN ('pendiente','confirmada','en_proceso','completada','no_show');

-- QA-10b: citas activas sin franjas (debe devolver 0 filas)
SELECT c.id FROM citas c
 WHERE c.estado IN ('pendiente','confirmada','en_proceso')
   AND NOT EXISTS (SELECT 1 FROM cita_slots s WHERE s.cita_id = c.id);

-- QA-08: trazabilidad de una cita
SELECT id, tipo, estado_anterior, estado_nuevo, actor, detalle, creado_en
  FROM cita_eventos WHERE cita_id = ? ORDER BY id;

14. Trazabilidad de los requisitos

Bloque exigidoSecciónImplementación
Arquitectura§2lib/agenda.ts, lib/db.ts, rutas de app/
Modelo de datos (15 entidades)§3migrations/001_init.sql
Reglas de disponibilidad§5, RN-03…RN-10disponibilidad()
Anti-solapamiento por recurso§6, RN-01, RN-02PK de cita_slots
Buffers§7, RN-02, RN-03servicios.buffer_min, franjas tipo='buffer'
Cancelación§8.1, RN-15cancelarCita()
Reagendamiento§8.2, RN-11, RN-12reagendarCita()
Concurrencia§11, RN-07BEGIN IMMEDIATE, SAVEPOINT, PK
Trazabilidad§4.4, RN-17cita_eventos, panel y enlace público

15. Historia de versiones

VersiónFechaCambiosAutor
1.0.020 de agosto de 2026Versión inicial vigente. Arquitectura de agenda, modelo de 15 entidades, máquina de 9 estados, 20 reglas de negocio, 15 casos de prueba de disponibilidad y conflicto, 5 casos complementarios de estado, 5 escenarios de concurrencia y 10 criterios de aceptación de QA. Implementada y desplegada junto a este documento.QA Tester Tank

Registro de versión. Este archivo se publica en el repositorio como docs/ESPECIFICACION-AGENDA-v1.0.0.md y se sirve en línea en /especificacion desde el mismo contenido, de modo que la versión leída por producto, desarrollo y QA sea siempre la desplegada. Cualquier cambio de reglas exige una versión nueva del documento en la misma entrega que el cambio de código.

Ver el motor funcionandoPanel de operación