Compare commits
12 Commits
v2.1.0-sta
...
c30688810a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c30688810a | ||
|
|
f2666c7d3c | ||
|
|
424071b97c | ||
|
|
eba93db452 | ||
|
|
ed524af16b | ||
|
|
5987502664 | ||
|
|
aa893368cd | ||
|
|
7e4991f960 | ||
|
|
a4287a122e | ||
|
|
6db211b9aa | ||
|
|
d50c31f8f4 | ||
|
|
d6be2b5c6b |
@@ -77,11 +77,15 @@ Principios estructurales (de ADR-001):
|
|||||||
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
|
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
|
||||||
n < umbral).
|
n < umbral).
|
||||||
|
|
||||||
## 6. ADRs iniciales
|
## 6. ADRs
|
||||||
|
|
||||||
|
Ver `docs/adr/` para los archivos completos.
|
||||||
|
|
||||||
- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*.
|
- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*.
|
||||||
- **ADR-002** — Stack tecnológico y residencia de datos.
|
- **ADR-002** — Stack tecnológico y residencia de datos.
|
||||||
- **ADR-003** — Consentimiento granular versionado e inmutable.
|
- **ADR-003** — Consentimiento granular versionado e inmutable.
|
||||||
|
- **ADR-004** — Capa de identidad y autenticación (SSO Keycloak para staff; sin login para respondentes).
|
||||||
|
- **ADR-005** — Esquema declarativo de formularios y estrategia de dos fases (Sprint 4). La lógica del flujo vive en datos (JSONB), interpretada por un motor backend; el frontend wizard es un renderer sin reglas de negocio. Motor genérico diferido a Fase 2.
|
||||||
|
|
||||||
## 7. Riesgos
|
## 7. Riesgos
|
||||||
|
|
||||||
|
|||||||
24
BACKLOG.md
24
BACKLOG.md
@@ -7,6 +7,19 @@
|
|||||||
|
|
||||||
## Seguridad
|
## Seguridad
|
||||||
|
|
||||||
|
### [SEC-002] Sprint de compliance & seguridad
|
||||||
|
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
|
||||||
|
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
|
||||||
|
de hardening. Antes de escalar a más empresas o datos reales, se requiere un sprint dedicado.
|
||||||
|
- **Ítems principales (ver COMPLIANCE.md secciones 5 y 6):**
|
||||||
|
- Registro de auditoría append-only (log inmutable de operaciones sensibles)
|
||||||
|
- Hashing SHA-256 de integridad de respuestas (detección de tampering sin blockchain)
|
||||||
|
- Cifrado en reposo a nivel de volumen/disco de la base de datos
|
||||||
|
- Roadmap de hardening por capas: aplicación, autenticación, datos, infraestructura,
|
||||||
|
observabilidad y proceso (revisión periódica de CVEs, pen testing)
|
||||||
|
- **Dependencia:** no bloquea Sprint 4; sí bloquea escalar a producción masiva.
|
||||||
|
- **Ver:** `COMPLIANCE.md` secciones 5 y 6.
|
||||||
|
|
||||||
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
|
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
|
||||||
- **Estado:** Pendiente
|
- **Estado:** Pendiente
|
||||||
- **Prioridad:** Alta
|
- **Prioridad:** Alta
|
||||||
@@ -58,6 +71,17 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### [BACKLOG-002] Fase 2 — Motor genérico de formularios
|
||||||
|
- **Estado:** Diferido post-deadline
|
||||||
|
- **Contexto:** En Sprint 4 se construye un intérprete del esquema declarativo específico
|
||||||
|
para el formulario de cuidadores (ADR-005, Fase 1). Una vez entregado ese caso, el
|
||||||
|
intérprete se extrae a un motor universal que interprete cualquier esquema definido en
|
||||||
|
`questions.conditional_rules` + campos de instanciación, sin hardcodear ningún formulario.
|
||||||
|
El formulario de cuidadores se vuelve el primer caso de prueba del motor sin reescribir
|
||||||
|
el contrato de datos.
|
||||||
|
- **Dependencia:** Fase 1 completada (Sprint 4 — wizard funcional).
|
||||||
|
- **Ver:** ADR-005 (`docs/adr/ADR-005-esquema-declarativo-formularios.md`).
|
||||||
|
|
||||||
- Dashboard / reportes (Sprint 3)
|
- Dashboard / reportes (Sprint 3)
|
||||||
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
|
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
|
||||||
- Builder visual de encuestas (post-validación del caso fijo)
|
- Builder visual de encuestas (post-validación del caso fijo)
|
||||||
|
|||||||
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Changelog — Datos País sobre el Cuidado
|
||||||
|
|
||||||
|
Todas las versiones notables de este proyecto se documentan en este archivo.
|
||||||
|
|
||||||
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/)
|
||||||
|
y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [No publicado] — Sprint 4: Formulario Wizard v3
|
||||||
|
|
||||||
|
En desarrollo en `main`. Cambio mayor: reescritura del formulario a wizard
|
||||||
|
pregunta-por-pantalla con instancias de vínculo familiar.
|
||||||
|
|
||||||
|
### Por agregar
|
||||||
|
- Wizard pregunta-por-pantalla para todo el formulario
|
||||||
|
- Instrumento v3: 10 secciones, mini-wizard anidado para vínculos familiares
|
||||||
|
- Columna `instance_id` en `answers` para datos multi-instancia
|
||||||
|
- Autocomplete de barrios desde dataset INE/DGEEC
|
||||||
|
- Subtítulos explicativos del "porqué" por sección
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.0] — 2026-06-16
|
||||||
|
|
||||||
|
Versión estable preservada en la rama `v2.1.0-stable`. Última versión del
|
||||||
|
formulario lineal antes del rediseño a wizard.
|
||||||
|
|
||||||
|
### Agregado
|
||||||
|
- Dashboard multitenante con selector de empresa (Todos / InQuality / Fundación / Demo)
|
||||||
|
- Flag `p_all` (UUID cero) para vista agregada de todos los surveys
|
||||||
|
- Vista de control `/admin/responses` con tabla plana, filtro por empresa,
|
||||||
|
colapsar/expandir por submission
|
||||||
|
- Dos encuestas piloto: InQuality y Fundación Solidaridad (organizaciones separadas)
|
||||||
|
- Método formalizado: `method/PATTERNS.md` y `method/ANTI_PATTERNS.md`
|
||||||
|
(patrones C.8, AP.10, AP.11)
|
||||||
|
|
||||||
|
### Cambiado
|
||||||
|
- Instrumento actualizado a v2.1 (feedback equipo médico): salto triple,
|
||||||
|
nueva pregunta 6.6, eliminación de "Prefiero no decir", 2.4 eliminada,
|
||||||
|
2.1 a multiple_choice
|
||||||
|
- Next.js actualizado a 15.3.8 (pin exacto)
|
||||||
|
|
||||||
|
### Seguridad
|
||||||
|
- Resuelto CVE-2025-66478 (+ advisory posterior CVE-2025-55183/55184/67779)
|
||||||
|
|
||||||
|
### Infraestructura
|
||||||
|
- Migración completa a Supabase self-hosted en Dokploy
|
||||||
|
- Backups GFS locales con retención de 3 días + domingos 14 días
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0] — 2026-06 (anterior)
|
||||||
|
|
||||||
|
Primera versión en producción con Supabase self-hosted.
|
||||||
|
- Formulario lineal de 44 preguntas
|
||||||
|
- Dashboard con 11 widgets y k-anonimato (k≥5)
|
||||||
|
- Instrumento v2 (44 preguntas, 8 secciones)
|
||||||
141
COMPLIANCE.md
Normal file
141
COMPLIANCE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# COMPLIANCE.md — Cumplimiento, seguridad y auditabilidad
|
||||||
|
|
||||||
|
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
|
||||||
|
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
|
||||||
|
> Última actualización: 2026-06-20 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principio rector de seguridad
|
||||||
|
|
||||||
|
**No existe un sistema "imposible de hackear". El objetivo es defensa en profundidad:**
|
||||||
|
encarecer progresivamente el ataque, minimizar la superficie expuesta, contener el daño
|
||||||
|
si una capa cae, y garantizar trazabilidad para detectar, auditar y reconstruir cualquier
|
||||||
|
incidente. Toda afirmación de inviolabilidad absoluta se considera, en sí misma, una
|
||||||
|
señal de inmadurez en seguridad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Naturaleza de los datos y marco normativo
|
||||||
|
|
||||||
|
### 2.1 Clasificación de datos
|
||||||
|
- **Datos sensibles** (salud, discapacidad): preguntas marcadas `is_sensitive=true`
|
||||||
|
(Sección de prevalencia, condición propia y de familiares).
|
||||||
|
- **Cuasi-identificadores**: departamento, ciudad, sector, rango etario, género.
|
||||||
|
- **Dato confidencial restringido**: franja salarial (analyst-only, nunca en vistas de empresa).
|
||||||
|
- **NO se recogen identificadores directos**: sin nombre, documento, email ni teléfono
|
||||||
|
en la encuesta anónima. La encuesta es anónima por diseño.
|
||||||
|
|
||||||
|
### 2.2 Marco normativo aplicable (Paraguay)
|
||||||
|
- Ley N° 6534/2020 de Protección de Datos Personales Crediticios y normativa de datos
|
||||||
|
personales aplicable en Paraguay.
|
||||||
|
- Principios alineados a estándares internacionales (minimización, finalidad, consentimiento).
|
||||||
|
- **Pendiente de revisión legal formal** con el equipo de Fundación Solidaridad / asesoría legal.
|
||||||
|
|
||||||
|
### 2.3 Consentimiento
|
||||||
|
- Registrado en tablas `consent_records` + `consent_versions` con versión y timestamp.
|
||||||
|
- Doble propósito separado y autorizable independientemente: diagnóstico interno de empresa
|
||||||
|
(requerido) y estudio país (opcional).
|
||||||
|
- Por ser anónima, la encuesta no permite revocación post-envío (no hay forma de localizar
|
||||||
|
una respuesta individual) — comunicado explícitamente al participante en el consentimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Anonimato y protección contra reidentificación
|
||||||
|
|
||||||
|
### 3.1 k-anonimato (k ≥ 5)
|
||||||
|
- Regla no negociable del sistema: ningún corte de datos con menos de 5 respuestas se muestra.
|
||||||
|
- Implementado en las RPCs del dashboard (`SECURITY DEFINER`), no en el frontend.
|
||||||
|
- Estados de supresión: `suppressed` con `n` real solo a nivel agregado.
|
||||||
|
|
||||||
|
### 3.2 Separación de datos sensibles
|
||||||
|
- Franja salarial (3.4 / 8.4 según versión): nunca expuesta en vistas de empresa
|
||||||
|
ni en `/admin/responses`.
|
||||||
|
- Política RLS `deny_sensitive` a nivel de base de datos.
|
||||||
|
- Vista de control `/admin/responses` excluye `is_sensitive=true` y `text_long` a nivel
|
||||||
|
de query, no de presentación (los valores no llegan al servidor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Seguridad — estado actual (v2.1.0)
|
||||||
|
|
||||||
|
### 4.1 Implementado
|
||||||
|
- **Cifrado en tránsito**: TLS/SSL vía Kong + Traefik (Let's Encrypt) en `db.inqualityhq.com`
|
||||||
|
y `economia-cuidado.inqualityhq.com`.
|
||||||
|
- **Soberanía del dato**: Supabase self-hosted en infraestructura propia (VPS controlado),
|
||||||
|
no en nube de terceros. Decisión arquitectónica fundacional.
|
||||||
|
- **RLS (Row Level Security)**: políticas activas en tablas sensibles; el rol `anon` solo
|
||||||
|
puede insertar respuestas, no leer datos de otros.
|
||||||
|
- **Service role aislado**: la clave de servicio nunca llega al bundle del cliente
|
||||||
|
(verificado: vistas server-side, no Client Components con la key).
|
||||||
|
- **Gestión de vulnerabilidades**: CVE-2025-66478 resuelto (Next.js 15.3.8, pin exacto).
|
||||||
|
- **Backups**: GFS local (3 días diarios + domingos 14 días), restore probado.
|
||||||
|
- **Aislamiento de red**: Studio de administración solo accesible vía SSH tunnel, no expuesto.
|
||||||
|
|
||||||
|
### 4.2 Deuda de seguridad registrada (ver TECH_DEBT.md)
|
||||||
|
- **TECH-DEBT-006**: backup remoto S3 — sin réplica off-site, riesgo ante pérdida del VPS.
|
||||||
|
Bloqueante antes del primer dato real de empresa.
|
||||||
|
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
|
||||||
|
pero requiere gate de auth antes de exponer.
|
||||||
|
- **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Auditabilidad y trazabilidad (roadmap)
|
||||||
|
|
||||||
|
> Objetivo: ante una auditoría o un incidente, poder responder "quién accedió a qué,
|
||||||
|
> cuándo, y demostrar que un registro no fue alterado".
|
||||||
|
|
||||||
|
### 5.1 Pendiente de diseño e implementación (BACKLOG — sprint de compliance)
|
||||||
|
- **Registro de auditoría append-only**: log inmutable de operaciones sensibles
|
||||||
|
(accesos a datos sensibles, cambios de configuración, ejecución de migraciones).
|
||||||
|
- **Hashing de integridad (SHA-256)**: hash de los registros de respuesta para demostrar
|
||||||
|
no-alteración, encadenable para detección de tampering. Provee el beneficio probatorio
|
||||||
|
de inmutabilidad SIN los costos de blockchain.
|
||||||
|
- **Cifrado en reposo**: cifrado a nivel de volumen/disco de la base de datos.
|
||||||
|
- **Trazabilidad de cambios de datos**: tablas de historial con triggers, o aprovechamiento
|
||||||
|
del WAL de PostgreSQL para reconstrucción temporal.
|
||||||
|
|
||||||
|
### 5.2 Por qué NO blockchain (decisión registrada)
|
||||||
|
Blockchain aporta valor cuando se requiere registro inmutable verificable entre partes
|
||||||
|
que NO confían entre sí, sin autoridad central legítima. En este sistema la confianza
|
||||||
|
está legítimamente centralizada (InQuality es responsable y operador soberano del dato),
|
||||||
|
por lo que blockchain no resuelve ningún problema real que no esté mejor resuelto por
|
||||||
|
audit logs + hashing. Además, su inmutabilidad absoluta entra en CONFLICTO con el derecho
|
||||||
|
de rectificación y supresión exigido por la normativa de protección de datos. Se descarta
|
||||||
|
por inadecuación técnica y normativa, no por desconocimiento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Roadmap de hardening de seguridad (a evaluar en sprint de compliance)
|
||||||
|
|
||||||
|
> Defensa en profundidad — capas a evaluar e implementar gradualmente.
|
||||||
|
|
||||||
|
- **Capa de aplicación**: validación y sanitización de inputs, protección contra inyección
|
||||||
|
(parametrización ya usada vía PostgREST/Supabase), rate limiting en endpoints públicos,
|
||||||
|
headers de seguridad (CSP, HSTS, X-Frame-Options).
|
||||||
|
- **Capa de autenticación**: gate de auth para vistas administrativas, rotación de secretos,
|
||||||
|
gestión de sesiones robusta cuando se introduzcan roles autenticados.
|
||||||
|
- **Capa de datos**: cifrado en reposo, RLS exhaustivo, principio de mínimo privilegio en roles.
|
||||||
|
- **Capa de infraestructura**: firewall, fail2ban, actualizaciones de SO, monitoreo de
|
||||||
|
intrusiones, segmentación de red.
|
||||||
|
- **Capa de observabilidad**: logs centralizados, alertas ante patrones anómalos,
|
||||||
|
registro de auditoría (sección 5).
|
||||||
|
- **Proceso**: revisión periódica de dependencias (`npm audit`), gestión de CVEs,
|
||||||
|
pruebas de penetración antes de escalar a producción masiva.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Responsabilidades
|
||||||
|
|
||||||
|
- **Responsable del dato**: InQuality (operador de infraestructura y autoridad del dato).
|
||||||
|
- **Aliado institucional**: Fundación Solidaridad.
|
||||||
|
- **Revisión legal**: pendiente de formalizar con asesoría legal especializada en
|
||||||
|
protección de datos en Paraguay.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Historial de revisiones de este documento
|
||||||
|
| Fecha | Versión producto | Cambio |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
|
||||||
@@ -106,9 +106,20 @@ id uuid pk
|
|||||||
response_id uuid fk -> responses(id)
|
response_id uuid fk -> responses(id)
|
||||||
question_id uuid fk -> questions(id)
|
question_id uuid fk -> questions(id)
|
||||||
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
|
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
|
||||||
|
instance_id text not null default 'default'
|
||||||
|
-- 'default' → preguntas normales (la gran mayoría)
|
||||||
|
-- 'familiar_1', 'familiar_2', ... → instancias del mini-wizard de vínculos (4A, Sprint 4)
|
||||||
|
UNIQUE (response_id, question_id, instance_id) -- constraint: answers_response_question_instance_key
|
||||||
```
|
```
|
||||||
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
|
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
|
||||||
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
|
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
|
||||||
|
>
|
||||||
|
> **Patrón `instance_id` (v3.0.0):** la pregunta 4.1 ("¿Tenés familiar con discapacidad/adulto
|
||||||
|
> mayor a cargo?") dispara el mini-wizard. Por cada familiar declarado se genera un conjunto de
|
||||||
|
> respuestas para las preguntas 4.2, 4.3, etc., cada una con su propio `instance_id`. Las RPCs
|
||||||
|
> de dashboard que agregan datos de vínculo operan sobre filas donde `instance_id != 'default'`.
|
||||||
|
> Las RPCs existentes de v2 no se modifican: siguen usando `instance_id = 'default'` implícitamente
|
||||||
|
> (las preguntas de vínculo no estaban en el instrumento v2).
|
||||||
|
|
||||||
### `consent_versions` — textos de consentimiento versionados
|
### `consent_versions` — textos de consentimiento versionados
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
303
SEED_SPEC.md
303
SEED_SPEC.md
@@ -1,152 +1,209 @@
|
|||||||
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v2.1
|
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v3.0.0
|
||||||
|
|
||||||
> Versión 2.1 — incorpora feedback del equipo médico, Etapa 3I2 (Jun 2026). Reemplaza la v2.
|
> Versión 3.0.0 — Sprint 4, Etapa 4B (Jun 2026). Reemplaza la v2.1. Reescritura completa del
|
||||||
> 44 preguntas (se elimina 2.4, se agrega 6.6) · 8 secciones · Códigos únicos corregidos.
|
> instrumento: 10 secciones, 40 preguntas (2 consentimiento + 38 de relevamiento), esquema
|
||||||
|
> declarativo de flujo (ADR-005). Seed LIMPIO — no migra datos de v2.1.
|
||||||
|
>
|
||||||
|
> Fuente de contenido AUTORITATIVA: `Formulario_Mapeo_Corregido.xlsx` (versión corregida,
|
||||||
|
> en rojo = cambios aplicados). El mockup `wizard_formulario_mapeo_v3.html` define estructura
|
||||||
|
> de secciones y mecánica del mini-wizard (4.1→4.2/4.3); donde el Excel difiere en el flujo
|
||||||
|
> condicional de "solo niño sin discapacidad", gana el Excel (decisión de la directora, ver
|
||||||
|
> abajo "Flujo condicional — recorrido soloNino").
|
||||||
|
>
|
||||||
> Claude Code reescribe `seed.sql` a partir de ESTE contrato.
|
> Claude Code reescribe `seed.sql` a partir de ESTE contrato.
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→`rating`,
|
|
||||||
texto libre largo→`text_long`, texto corto→`text_short`.
|
|
||||||
- **`conditional_rules` en la pregunta DEPENDIENTE** (cada pregunta declara su propia condición).
|
|
||||||
- Los 3 triggers (1.1, 1.6, 1.13) son `single_choice` de 2 opciones (Sí · No); solo **"Sí"** dispara
|
|
||||||
el bloque correspondiente.
|
|
||||||
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico (marcadas ✅).
|
|
||||||
- `3.4` (salary) — `is_sensitive=true` + analyst-only (ADR-001 addendum): nunca en vistas org_admin.
|
|
||||||
- RE-INDEX: las preguntas marcadas son candidatas a índice compuesto (metodología TBD Sprint 3).
|
|
||||||
- En preguntas `multiple_choice` el formulario muestra el subtítulo "Podés marcar más de una opción".
|
|
||||||
- "Prefiero no decir" eliminada de todas las preguntas (Cambio 1, Etapa 3I2).
|
|
||||||
|
|
||||||
## Estructura condicional (4 bloques)
|
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, texto libre corto→`text_short`,
|
||||||
- **cond-disc-propia** — 1.1='Sí' → muestra 1.2, 1.3, 1.4, 1.5
|
texto libre largo→`text_long`, sí/no→`yes_no`.
|
||||||
- **cond-familiar** — 1.6='Sí' → muestra 1.7, 1.8, 1.9, 1.10, 1.11, 1.12
|
- Secciones persisten como filas `questions.type='section'`: `prompt`=nombre de sección,
|
||||||
- **cond-adulto-mayor** — 1.13='Sí' → muestra 1.14, 1.15, 1.16
|
`hint`=descripción/"porqué" (Opción C, ver ADR-005 §4.2).
|
||||||
- **salto-triple** — si 1.1='No' Y 1.6='No' Y 1.13='No' (es decir, ninguno de los tres dio
|
- `conditional_rules` vive en la pregunta/sección DEPENDIENTE (auto-target implícito, sin
|
||||||
'Sí'), se ocultan completas las secciones 2, 3, 4, 5 y las preguntas 6.1, 6.2, 6.3. El
|
campo `target`). Vocabulario completo en `supabase/migrations/20260620000002_form_schema_v3.sql`:
|
||||||
formulario salta directo a 6.4. Implementado como `conditional_rules`
|
operadores `eq`, `eq_any`, `not_in`, `contains`, `eq_sole`; acciones `show`, `hide`.
|
||||||
`{"if_question":["1.1","1.6","1.13"],"op":"eq_any","value":"Sí","action":"show"}` en cada
|
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico y en 8.4 (franja salarial,
|
||||||
pregunta del bloque (equivalente: se muestra si AL MENOS uno de los tres es 'Sí').
|
CONFIDENCIAL — analyst-only, nunca en vistas de empresa, igual que 3.4 en v2.1).
|
||||||
- Sección A, 6.4-6.6 y Sección 7 (cierre): **top-level, siempre visibles.**
|
- En preguntas `multiple_choice` el `hint` suele ser "Podés marcar más de una opción".
|
||||||
|
- Mini-wizard de instanciación (ADR-005): 4.1 es `instance_generator=true`; 4.2 y 4.3 tienen
|
||||||
|
`instance_of='4.1'` y se repiten una vez por cada vínculo familiar (opciones 2–7) marcado en 4.1.
|
||||||
|
Las respuestas de cada instancia se guardan en `answers.instance_id` (`'familiar_1'`, `'familiar_2'`...).
|
||||||
|
- Geo: 2.1 (departamento, 18 opciones) y 2.2 (ciudad, 219 opciones filtradas por departamento en
|
||||||
|
el renderer) son cuasi-identificadores. 2.3 (barrio) es `text_short` de texto libre, opcional —
|
||||||
|
NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md); se usa solo como apoyo
|
||||||
|
cualitativo, no agregable.
|
||||||
|
|
||||||
|
## Flujo condicional — las 3 reglas principales
|
||||||
|
|
||||||
|
1. **`r_show_si_s3_1`** — 3.1='Sí' → muestra 3.2, 3.3, 3.4, 3.5 (discapacidad propia).
|
||||||
|
Si 3.1='No', toda la sub-rama queda oculta.
|
||||||
|
2. **`r_hide_ning`** — 4.1 contiene "Ninguna de las anteriores / No tengo vinculación de cuidado"
|
||||||
|
→ oculta S5, S6, S7, S8(header), 8.4, S9(header), 9.1, 9.4. El formulario salta directo a S10.
|
||||||
|
3. **`r_hide_ning_sn`** (Ninguna O soloNino) — además de la condición anterior, también oculta
|
||||||
|
cuando 4.1 tiene como ÚNICO elemento seleccionado "Hijo menor sin discapacidad" (`eq_sole`).
|
||||||
|
Oculta: 5.1, 5.2, 5.4, S6 completa, S7 completa, 8.1, 8.2, 8.3, 9.2, 9.3.
|
||||||
|
|
||||||
|
### Flujo condicional — recorrido "solo niño sin discapacidad" (soloNino)
|
||||||
|
|
||||||
|
Decisión de la directora (Etapa 4B): cuando 4.1 tiene como única respuesta "Hijo menor sin
|
||||||
|
discapacidad", el recorrido visible es **5.3 → 8.4 → 9.1 → 9.4 → fin**, tal como lo especifica
|
||||||
|
el Excel corregido (no el mockup, que marcaba toda la Sección 5 como oculta). Esto es más
|
||||||
|
granular que un simple "salto de sección": 5.3, 8.4, 9.1 y 9.4 quedan visibles porque no
|
||||||
|
dependen de que haya responsabilidad económica o de cuidado activo — preguntan por quién
|
||||||
|
cuida durante el horario laboral (5.3), franja salarial (8.4) y percepción/voluntariado
|
||||||
|
organizacional (9.1, 9.4), que aplican igual a quien solo tiene un hijo menor sin discapacidad.
|
||||||
|
|
||||||
|
- **4.1 = sole "Hijo menor sin discapacidad"**: 4.2 y 4.3 se ocultan (no hay tipo de
|
||||||
|
discapacidad ni dispositivos de apoyo que relevar), 5.1/5.2/5.4 ocultas, 5.3 VISIBLE,
|
||||||
|
S6 y S7 completas ocultas, 8.1–8.3 ocultas, 8.4 VISIBLE, 9.1 VISIBLE, 9.2/9.3 ocultas,
|
||||||
|
9.4 VISIBLE.
|
||||||
|
- **4.1 contiene "Ninguna de las anteriores..."**: salta directo a S10 (todo S5–S9 oculto).
|
||||||
|
- **4.1 = cualquier combinación de opciones 2–7** (con o sin la opción 1): recorrido completo,
|
||||||
|
4.2/4.3 generan una instancia por cada vínculo 2–7 declarado.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sección A — Datos Generales (top-level)
|
## Sección 1 — Consentimiento (`S1`, pos. 10)
|
||||||
| Code | Tipo | Pregunta | Opciones | Notas |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| A1 | single_choice | ¿En qué departamento de Paraguay vivís?* | Asunción (Capital) · Alto Paraguay · Alto Paraná · Amambay · Boquerón · Caaguazú · Caazapá · Canindeyú · Central · Concepción · Cordillera · Guairá · Itapúa · Misiones · Ñeembucú · Paraguarí · Presidente Hayes · San Pedro | qi_zone |
|
|
||||||
| A2 | single_choice | ¿En qué ciudad vivís?* | 219 ciudades (GeoSelectWidget: dependiente de A1) | qi_city — Sprint 3 |
|
|
||||||
| A3 | text_short | ¿En qué barrio vivís? | — (texto libre, opcional) | |
|
|
||||||
| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Directorio · Gerencia · Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector |
|
|
||||||
| A5 | single_choice | ¿Cuántos años tenés? | Menos de 25 años · 25 a 34 años · 35 a 44 años · 45 a 54 años · 55 años o más | qi_age_bucket |
|
|
||||||
| A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | |
|
|
||||||
| A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | |
|
|
||||||
|
|
||||||
## Sección 1 — Prevalencia: cuidado y discapacidad *(sensible)*
|
| Code | Tipo | Pregunta | Requerida | Notas |
|
||||||
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|
|---|---|---|---|---|
|
||||||
|
| 1.1 | yes_no | Declaro que fui informado/a sobre el propósito de esta encuesta y el tratamiento de mis respuestas, y acepto participar de forma voluntaria en el diagnóstico interno de [Empresa]. | Sí | Gate de participación — ver `consent_versions` v2 |
|
||||||
|
| 1.2 | yes_no | Autorizo que mis respuestas anonimizadas se utilicen para el estudio país sobre la economía del cuidado en Paraguay. | No | Scope `macro_n1` — Modo A (CLAUDE.md regla 7) |
|
||||||
|
|
||||||
|
## Sección 2 — Datos generales (`S2`, pos. 20)
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive | Notas |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 1.1 | single_choice | trigger | ¿Vos mismo/a vivís con alguna discapacidad o condición de salud que afecte tu funcionamiento cotidiano?* | Sí · No | ✅ |
|
| 2.1 | single_choice | Departamento | 18 departamentos de Paraguay + Capital | no | qi (cuasi-identificador) |
|
||||||
| 1.2 | multiple_choice | 1.1=Sí | ¿Qué tipo de discapacidad o condición tenés? | Discapacidad física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica que genera limitaciones · Otra | ✅ |
|
| 2.2 | single_choice | Ciudad | 219 ciudades (filtradas por 2.1 en el renderer) | no | qi |
|
||||||
| 1.3 | multiple_choice | 1.1=Sí | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utilizo | ✅ |
|
| 2.3 | text_short | ¿En qué barrio vivís? | — (texto libre) | no | `hint`='Opcional.' — no entra al store analítico crudo |
|
||||||
| 1.4 | single_choice | 1.1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
|
| 2.4 | single_choice | ¿Cuál es tu rango de edad? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ | no | qi |
|
||||||
| 1.5 | single_choice | 1.1=Sí | ¿Tu condición afecta tu desempeño o comodidad en el trabajo? | Sí, de forma significativa · Sí, pero en aspectos puntuales · No, está bien gestionada | ✅ |
|
| 2.5 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | no | qi |
|
||||||
| 1.6 | single_choice | trigger | ¿Tenés algún familiar cercano (hijo/a, pareja, hermano/a, padre/madre) que viva con discapacidad?* | Sí · No | ✅ |
|
| 2.6 | single_choice | ¿En qué nivel organizacional estás? | Directivo · Gerencial · Jefatura · Operativo · Auxiliar · Otro | no | qi |
|
||||||
| 1.7 | multiple_choice | 1.6=Sí | ¿Cuál es tu vínculo con esa persona? | Hijo/a · Cónyuge/pareja · Padre o madre · Hermano/a · Otro familiar | ✅ |
|
| 2.7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido | no | qi |
|
||||||
| 1.8 | multiple_choice | 1.6=Sí | ¿Qué tipo de discapacidad tiene? | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica severa · Otra | ✅ |
|
|
||||||
| 1.9 | multiple_choice | 1.6=Sí | ¿Qué tipo de apoyo o acompañamiento necesita principalmente? | Movilidad física · Comunicación · Apoyo escolar/educativo · Inclusión laboral · Terapias/rehabilitación · Apoyo emocional/psicosocial | ✅ |
|
|
||||||
| 1.10 | multiple_choice | 1.6=Sí | ¿Usa algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utiliza | ✅ |
|
|
||||||
| 1.11 | single_choice | 1.6=Sí | Si es niño/a o adolescente en edad escolar: ¿está escolarizado/a y enfrenta barreras de aprendizaje? | Escolarizado/a, sin barreras importantes · Escolarizado/a, enfrenta barreras o faltan ajustes · No está escolarizado/a · No aplica | ✅ |
|
|
||||||
| 1.12 | single_choice | 1.6=Sí | ¿La persona con discapacidad se encuentra trabajando actualmente? | Sí, está trabajando · No, y está buscando empleo · No, y no está buscando · No aplica | ✅ |
|
|
||||||
| 1.13 | single_choice | trigger | ¿Tenés algún adulto mayor (60+ años) a tu cuidado o bajo tu responsabilidad?* | Sí · No | |
|
|
||||||
| 1.14 | single_choice | 1.13=Sí | ¿Qué nivel de dependencia tiene? (la más representativa) | Independiente, pero necesita supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ |
|
|
||||||
| 1.15 | multiple_choice | 1.13=Sí | ¿Tiene una condición de salud que genere mayor necesidad de cuidado? | Demencia/Alzheimer · ACV/Parálisis · Enfermedad crónica grave · Movilidad reducida · Deterioro cognitivo · Sin condición específica | ✅ |
|
|
||||||
| 1.16 | single_choice | 1.13=Sí | ¿Vive con vos o en otra vivienda? | Vive en mi hogar · Vive en otra vivienda · Está en residencia/institución | |
|
|
||||||
|
|
||||||
## Sección 2 — Intensidad y rol del cuidado (top-level)
|
## Sección 3 — Prevalencia de discapacidad (`S3`, pos. 30) *(sensible)*
|
||||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
|
||||||
|
Cond.: 3.2–3.5 visibles solo si 3.1='Sí' (`r_show_si_s3_1`).
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 3.1 | single_choice | ¿Vos mismo/a tenés alguna discapacidad o condición de salud que pudiera afectar tu independencia o desempeño laboral? | No · Sí | ✅ trigger |
|
||||||
|
| 3.2 | multiple_choice | ¿Qué tipo de discapacidad o condición tenés? | Física/motora · Visual · Auditiva · Intelectual · Mental/psicosocial · Del habla y la comunicación · Múltiple · Vejez/osteoporosis/artrosis/otras · Amputación · Otra | ✅ |
|
||||||
|
| 3.3 | multiple_choice | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón de apoyo · Bastón blanco · Andador · Muletas · Prótesis · Tecnología asistiva · Audífonos · No utilizo | ✅ |
|
||||||
|
| 3.4 | single_choice | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
|
||||||
|
| 3.5 | multiple_choice | ¿Cómo afecta tu discapacidad y/o condición limitante en tu trabajo? | No afecta mi rendimiento · Falta de adecuaciones afecta más que la discapacidad · Transporte · Accesibilidad para participar | ✅ |
|
||||||
|
|
||||||
|
## Sección 4 — Vinculación familiar (`S4`, pos. 40) *(sensible)*
|
||||||
|
|
||||||
|
Mini-wizard: 4.1 `instance_generator=true`; 4.2/4.3 `instance_of='4.1'`, una instancia por
|
||||||
|
vínculo 2–7 declarado en 4.1. Ocultas (4.2/4.3) si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 4.1 | multiple_choice | ¿Cuál de las siguientes vinculaciones familiares tenés? | 1. Hijo menor sin discapacidad · 2. Hijo menor con discapacidad · 3. Hijo adulto con discapacidad · 4. Familiar menor con discapacidad · 5. Familiar mayor con discapacidad · 6. Padre/madre con movilidad reducida o 100% dependiente · 7. Otro/Especificar · 8. Ninguna de las anteriores / No tengo vinculación de cuidado | ✅ generador |
|
||||||
|
| 4.2 | multiple_choice (instancia) | ¿Qué tipo de discapacidades o limitaciones tienen tus familiares vinculados? | (mismo set que 3.2) | ✅ |
|
||||||
|
| 4.3 | multiple_choice (instancia) | ¿Qué tipo de dispositivos de apoyo utilizan tus familiares vinculados? | (mismo set que 3.3, "No utiliza" en 3ra persona) | ✅ |
|
||||||
|
|
||||||
|
## Sección 5 — Responsabilidad y cuidado (`S5`, pos. 50)
|
||||||
|
|
||||||
|
Cond.: oculta completa si Ninguna (`r_hide_ning`). 5.1/5.2/5.4 además ocultas si soloNino;
|
||||||
|
**5.3 permanece visible en soloNino** (ver "recorrido soloNino" arriba).
|
||||||
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 2.1 | multiple_choice ⚠️ TECH-DEBT | ¿Quién es el/la responsable principal del cuidado? | Yo soy el/la principal · Comparto con otro familiar · Hay cuidador/a pagado/a · Está en institución/centro de día |
|
| 5.1 | multiple_choice | ¿Cuál es tu rol en el cuidado de los niños y/o personas con discapacidad o dependientes? | Proveo financieramente · Comparto gastos con familiares · Administro recursos/organizo servicios · Cuido unas horas al salir del trabajo · Me ocupo de todas las gestiones |
|
||||||
| 2.2 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · Entre 1 y 3h · Entre 3 y 6h · Más de 6h · Varía mucho |
|
| 5.2 | multiple_choice | ¿En qué momento del día te ocupás de gestionar/organizar el cuidado? | Antes del trabajo · Después del trabajo · Durante el trabajo (llamadas/emergencias) · En cualquier momento · Fines de semana |
|
||||||
| 2.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Durante horario laboral (llamadas/emergencias) · Al llegar del trabajo · Durante la noche · Los fines de semana · Es continuo sin horario fijo |
|
| 5.3 | multiple_choice | Durante el tiempo que estás en el trabajo, ¿quién se ocupa del cuidado de las personas a tu cargo? | Mi cónyuge · Otro familiar · Cuidador/a contratado/a · Centro de estudios o cuidado · Un/a vecino/a · Otro |
|
||||||
| 2.5 | single_choice | Como cuidador/a primario, ¿presentás signos de agotamiento o estrés crónico? | Sí, frecuentemente · A veces · No, lo llevo bien |
|
| 5.4 | single_choice | ¿Desde hace cuánto cumplís este rol de responsable o cuidador/a? | Menos de 1 año · 1-3 años · 3-5 años · Más de 5 años |
|
||||||
|
|
||||||
> ⚠️ TECH-DEBT (2.1): pasó de `single_choice` a `multiple_choice` — selección múltiple sin orden
|
## Sección 6 — Intensidad del cuidado (`S6`, pos. 60)
|
||||||
> de prioridad. Deuda: implementar ranking (Principal cuidador soy yo > secundario instituto >
|
|
||||||
> principal pagado > secundario familiar). Ver `TECH_DEBT.md`.
|
|
||||||
>
|
|
||||||
> **2.4 eliminada** (Cambio 6, Etapa 3I2): "¿Contás con apoyo de otras personas para el cuidado?"
|
|
||||||
> — feedback del equipo médico: pregunta similar/redundante con 2.1. Ver `OBSERVATIONS.md`.
|
|
||||||
|
|
||||||
## Sección 3 — Impacto económico del cuidado (top-level)
|
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
|
||||||
> 3.4: is_sensitive=true + analyst-only (no exponer a org_admin).
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 6.1 | single_choice | ¿Cuántos días por semana estás a cargo del cuidado directo, en promedio? | 1–2 · 3–4 · 5–6 · Todos los días |
|
||||||
|
| 6.2 | single_choice | Cuando estás a cargo del cuidado, ¿cuántas horas por día dedicás, en promedio? | 1–2 · 3–4 · 5–6 · Más de 6 horas |
|
||||||
|
| 6.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Al llegar del trabajo · Durante la noche · Fines de semana |
|
||||||
|
| 6.4 | single_choice | Además del cuidado directo, ¿cuántas horas al día necesitás estar disponible o de guardia? | 1–2 · 3–4 · 5–6 · Más de 6 horas · Permanente/sin horario |
|
||||||
|
|
||||||
|
## Sección 7 — Impacto en el trabajo (`S7`, pos. 70)
|
||||||
|
|
||||||
|
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 7.1 | single_choice | ¿Cómo describirías tu nivel de descanso cuando te ocupás del cuidado y cómo impacta en tu desempeño? | Descanso bien · Irregular pero lo manejo · Me cuesta, afecta con frecuencia · Descanso muy poco, agotamiento afecta desempeño |
|
||||||
|
| 7.2 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca/casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días |
|
||||||
|
| 7.3 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (1-3/año) · Varias veces (4-10/año) · Con frecuencia (+10/año) |
|
||||||
|
| 7.4 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados por tu rol de cuidador/a? | Tiempo libre · Sueño · Vida social · Vida en pareja/familiar · Salud física · Salud emocional/mental · Desarrollo profesional · Situación económica |
|
||||||
|
| 7.5 | multiple_choice | Por tu rol de cuidado, ¿alguna vez tuviste que…? | Reducir jornada · Rechazar ascenso · Declinar viajes/extras/capacitaciones · Cambiar a puesto menos exigente · Faltar/llegar tarde reiteradamente · Pensar en dejar el trabajo · Dejar un trabajo anterior · Ninguna de las anteriores |
|
||||||
|
| 7.6 | single_choice | Pensando en los próximos 12 meses, ¿qué tan probable es que dejes o reduzcas tu trabajo por razones de cuidado? | Nada probable · Poco probable · Algo probable · Muy probable |
|
||||||
|
|
||||||
|
## Sección 8 — Impacto económico (`S8`, pos. 80)
|
||||||
|
|
||||||
|
Cond.: header y 8.4 ocultos si Ninguna (`r_hide_ning`). 8.1–8.3 además ocultas si soloNino.
|
||||||
|
**8.4 permanece visible en soloNino.**
|
||||||
|
|
||||||
|
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 8.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | 7 franjas, < 1M a > 15M Gs. | no |
|
||||||
|
| 8.2 | multiple_choice | ¿En qué conceptos gastás principalmente el monto expresado? | Cuidador/a particular · Centro de día/institución · Traslados · Equipamiento médico/tecnología asistiva · Servicios terapéuticos/rehabilitación · Medicamentos · Insumos personales · Otros | no |
|
||||||
|
| 8.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Solo con mi empleo · Ingresos extraordinarios · Emprendimiento · Me ayudan familiares · Me ayudan organizaciones sociales · Me ayudan vecinos/amigos · Eventos de recaudación · Subsidios estatales | no |
|
||||||
|
| 8.4 | single_choice | ¿En qué franja salarial mensual te ubicás con tu trabajo en esta empresa? *(CONFIDENCIAL)* | 9 franjas, hasta 3M a 50M+ Gs. | ✅ analyst-only, nunca en vistas de empresa (igual criterio que 3.4 en v2.1) |
|
||||||
|
|
||||||
|
## Sección 9 — Conciencia organizacional (`S9`, pos. 90)
|
||||||
|
|
||||||
|
Cond.: header, 9.1 y 9.4 ocultos si Ninguna (`r_hide_ning`). 9.2/9.3 además ocultas si soloNino.
|
||||||
|
**9.1 y 9.4 permanecen visibles en soloNino.**
|
||||||
|
|
||||||
| Code | Tipo | Pregunta | Opciones | Notas |
|
| Code | Tipo | Pregunta | Opciones | Notas |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 3.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | Menos de Gs. 500.000 · Gs. 500.000–1.500.000 · Gs. 1.500.000–3.000.000 · Gs. 3.000.000–6.000.000 · Más de Gs. 6.000.000 | |
|
| 9.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca hemos tocado el tema | |
|
||||||
| 3.2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? | Terapias especializadas · Cuidador/a particular · Centro de día/institución · Transporte adaptado · Medicación alto costo · Equipamiento médico/tecnología asistiva · No utilizo servicios formales | |
|
| 9.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? | Flexibilidad horaria/remoto · Apoyo económico/convenios · Beneficios terapias/salud · Contención emocional/psicológica · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos | |
|
||||||
| 3.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Con mi empleo de tiempo completo · Con mi empleo de tiempo parcial · Con trabajo independiente/emprendimiento · Me ayudan mis familiares · No puedo sostenerlo con mis ingresos | |
|
| 9.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez · No por ahora · Prefiero mantenerlo en privado | |
|
||||||
| 3.4 | single_choice | ¿En qué franja salarial mensual te ubicás? *(CONFIDENCIAL — analyst-only)* | Sueldo mínimo · Gs. 3.000.000 a 5.000.000 · Gs. 5.000.001–8.000.000 · Gs. 8.000.001–12.000.000 · Gs. 12.000.001–15.000.000 · Gs. 15.000.001–18.000.000 · Gs. 18.000.001–25.000.000 · Gs. 25.000.001–50.000.000 · Gs. 50.000.001 o más | is_sensitive=true; never in org_admin views |
|
| 9.4 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo? | Sí · No | |
|
||||||
| 3.5 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | 1=Sin impacto · 2=Leve · 3=Moderado · 4=Alto · 5=Muy alto | RE-INDEX |
|
|
||||||
|
|
||||||
> **Cambio 7** (3.1): se elimina "Sin gastos significativos" además de "Prefiero no decir".
|
## Sección 10 — Comentarios adicionales (`S10`, pos. 100)
|
||||||
> **Cambio 8** (3.3): "Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis
|
|
||||||
> ingresos". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
|
|
||||||
> **Cambio 9** (3.4): "Sueldo mínimo – Gs. 5.000.000" se divide en "Sueldo mínimo" y
|
|
||||||
> "Gs. 3.000.000 a 5.000.000". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
|
|
||||||
|
|
||||||
## Sección 4 — Tiempo y descanso (top-level)
|
Siempre visible — es el destino del salto "Ninguna de las anteriores" en 4.1.
|
||||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
|
||||||
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 4.1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien, me siento recuperado/a · Descanso de forma irregular, pero lo manejo · Me cuesta descansar, me siento cansado/a con frecuencia · Descanso muy poco, el agotamiento afecta mi día a día | RE-INDEX |
|
|
||||||
| 4.2 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados por tu rol de cuidador/a? | Tiempo libre y ocio · Sueño y descanso · Vida social y amistades · Vida en pareja/familiar · Salud física personal · Salud emocional/mental · Desarrollo profesional · Situación económica | |
|
|
||||||
| 4.3 | rating | En una escala del 1 al 5, ¿cómo evaluás tu bienestar general en este momento? | 1=Muy mal · 2=Mal · 3=Regular · 4=Bien · 5=Muy bien | RE-INDEX |
|
|
||||||
|
|
||||||
## Sección 5 — Impacto en el trabajo (top-level)
|
|
||||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
|
||||||
|
|
||||||
| Code | Tipo | Pregunta | Opciones |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 5.1 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca o casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días | RE-INDEX |
|
|
||||||
| 5.2 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (1–3 veces al año) · Varias veces (4–10 veces al año) · Con frecuencia (más de 10 veces al año) | |
|
|
||||||
|
|
||||||
## Sección 6 — Conciencia organizacional (top-level)
|
|
||||||
> Cond. (6.1-6.3): **salto-triple** — se ocultan si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
|
||||||
> 6.4-6.6: siempre visibles (destino del salto).
|
|
||||||
|
|
||||||
| Code | Tipo | Pregunta | Opciones | Notas |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 6.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente?* | Sí, me siento acompañado/a · Parcialmente, hay cierta comprensión · No, es algo bastante invisible · No, no se puede hablar del tema | RE-INDEX. Cambio 10: última opción reescrita (era "No, nunca se ha hablado del tema"). Breaking change — ver `OBSERVATIONS.md`. |
|
|
||||||
| 6.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? Elegí hasta 3 | Flexibilidad horaria o trabajo remoto · Apoyo económico o convenios/descuentos · Acceso a terapias o salud · Contención/apoyo emocional o psicológico · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos y servicios disponibles | ⚠️ Ranking → multiple_choice para v1. Ranking widget al backlog. |
|
|
||||||
| 6.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez, si hay más personas en la misma situación · No por ahora · Prefiero mantenerlo en privado | |
|
|
||||||
| 6.4 | single_choice | ¿Conocés a algún compañero/a de trabajo que esté al cuidado de una persona con discapacidad o dependiente? | Sí, conozco a varias personas · Sí, conozco a una persona · No conozco a nadie · No estoy seguro/a | Código corregido (era 6.2 duplicado) |
|
|
||||||
| 6.5 | single_choice | ¿Considerás que la empresa debería ofrecer apoyos a quienes cuidan a una persona dependiente? | Sí, definitivamente · Tal vez, dependería del caso · No lo considero necesario · No lo sé | Código corregido (era 6.3 duplicado) |
|
|
||||||
| 6.6 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo a personas con discapacidad y/o cuidadores? | Sí · No · Tal vez | Nueva (Cambio 4b, Etapa 3I2) |
|
|
||||||
|
|
||||||
## Sección 7 — Para cerrar (top-level)
|
|
||||||
| Code | Tipo | Pregunta | Notas |
|
| Code | Tipo | Pregunta | Notas |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 7.1 | text_long | ¿Te gustaría dejar un comentario o sugerencia especial? (opcional) | No entra al store analítico crudo (regla 4 CLAUDE.md) |
|
| 10.1 | text_long | ¿Te gustaría dejar un comentario o sugerencia especial? (opcional) | `hint`='Opcional.' — NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mapeo a cuasi-identificadores (`responses.qi_*`)
|
## Totales
|
||||||
- `qi_age_bucket` ← A5 (ya en bucket)
|
|
||||||
- `qi_sector` ← A4 (área)
|
10 secciones (`type='section'`) + 40 preguntas = **50 filas** en `questions` por encuesta.
|
||||||
- `qi_zone` ← A1 (departamento — poblado en `submit-survey.ts` desde Etapa 2E)
|
Preguntas por sección: S1=2, S2=7, S3=5, S4=3, S5=4, S6=4, S7=6, S8=4, S9=4, S10=1 → 40.
|
||||||
- `qi_city` ← A2: **Sprint 3** — se agrega como columna qi en migration futura.
|
|
||||||
Por ahora A2 se captura en `answers` como single_choice.
|
## Consent versions
|
||||||
|
|
||||||
|
- **v1** (`20000000-0000-0000-0000-000000000001`): texto original v2.1, preservado — no se
|
||||||
|
altera para no invalidar `consent_records` ya firmados contra esa versión (regla 5 CLAUDE.md).
|
||||||
|
- **v2** (`20000000-0000-0000-0000-000000000002`): texto revisado para el instrumento v3.0.0,
|
||||||
|
asignado a las 3 encuestas seed. Scopes: `["operativo","macro_n1"]` (Modo A).
|
||||||
|
|
||||||
|
## Discrepancias mockup vs. Excel resueltas (AP.10)
|
||||||
|
|
||||||
|
Reportadas y resueltas con la directora antes de escribir el seed:
|
||||||
|
|
||||||
|
1. **Wording de preguntas y opciones**: el Excel corregido es la fuente autoritativa en todos
|
||||||
|
los casos donde difiere del mockup (~30 discrepancias menores de texto).
|
||||||
|
2. **Recorrido soloNino**: el mockup marcaba `skipSoloNino:true` en TODA la Sección 5 (incluida
|
||||||
|
5.3). El Excel especifica un recorrido más granular: 5.3 → 8.4 → 9.1 → 9.4 → fin. Resuelto a
|
||||||
|
favor del Excel (ver "Flujo condicional — recorrido soloNino" arriba).
|
||||||
|
3. **8.3 "Eventos de recaudación"**: opción presente en el Excel, ausente en el mockup. Incluida.
|
||||||
|
|
||||||
## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación)
|
## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación)
|
||||||
- **Ranking widget (6.2):** actual es multiple_choice max_select=3. Cuando se implemente el
|
|
||||||
widget de ranking (drag o 3 dropdowns), 6.2 pasa a tipo `ranking` con JSONB `{rank1, rank2, rank3}`.
|
- **Ranking widget (9.2):** actual es `multiple_choice` con `max_select=3`. Si se requiere
|
||||||
- **qi_city:** migration futura para agregar columna en responses y extender whitelist de agg_segment.
|
orden de prioridad, pasa a tipo `ranking` (mismo criterio que 6.2 en v2.1).
|
||||||
Se hace antes de Sprint 3 (dashboard con filtro por ciudad).
|
- **Motor genérico de formularios (Fase 2, BACKLOG-002):** este contrato es el primer caso de
|
||||||
- **Salary analytics:** metodología para calcular "carga económica del cuidado como % del ingreso"
|
prueba; el intérprete declarativo se generaliza después de validar el wizard de cuidadores.
|
||||||
es trabajo de metodología de investigación (fuera del scope del código).
|
- **Migración de datos v2.1 → v3:** NO se ejecuta. v2.1.0-stable permanece intacta; v3 es un
|
||||||
- **RE-INDEX scoring:** algoritmo de composición de los índices (descanso, bienestar, rendimiento,
|
instrumento nuevo con seed limpio.
|
||||||
conciencia) — decisión antes de construir los reportes del dashboard.
|
- **`qi_city`, RE-INDEX scoring, salary analytics:** decisiones heredadas de v2.1, aún abiertas,
|
||||||
- **Formulario de empresa (Relevamiento Empresa):** instrumento separado con PII de contacto.
|
no se resuelven en esta etapa.
|
||||||
Ejecutar offline para las primeras empresas. Diseño del modelo de datos en el sprint del builder.
|
|
||||||
|
|||||||
148
app/globals.css
148
app/globals.css
@@ -482,6 +482,154 @@ body {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
WIZARD (4D) — layout de altura estable (UX 1+2+3)
|
||||||
|
1) viewport estable: enunciado anclado en el tercio superior, no centrado
|
||||||
|
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
|
||||||
|
que cambia de tamaño — nunca "saltan" entre pantallas
|
||||||
|
3) área de respuesta con altura máxima + scroll interno + fade inferior
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
.wizard-shell {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.wizard-topbar-brand {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
|
||||||
|
.wizard-stage {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-section-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.wizard-section-number {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.wizard-section-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.wizard-section-desc {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enunciado: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */
|
||||||
|
.wizard-prompt-block {
|
||||||
|
min-height: 116px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.wizard-instance-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-code {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7A8FA0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-text {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.wizard-prompt-hint {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo. */
|
||||||
|
.wizard-answer-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.wizard-answer-area::after {
|
||||||
|
content: '';
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: block;
|
||||||
|
height: 28px;
|
||||||
|
margin-top: -28px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-bottombar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.wizard-bottombar-hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wizard-prompt-text { font-size: 1.1875rem; }
|
||||||
|
.wizard-section-title { font-size: 1.5rem; }
|
||||||
|
.wizard-answer-area { max-height: 320px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading skeleton ── */
|
/* ── Loading skeleton ── */
|
||||||
.loading-wrapper {
|
.loading-wrapper {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
|
|||||||
32
app/page.tsx
32
app/page.tsx
@@ -1,6 +1,13 @@
|
|||||||
import { createClient } from '@/lib/supabase'
|
import { createClient } from '@/lib/supabase'
|
||||||
import type { BrandConfig, Question, Survey } from '@/lib/types'
|
import type { BrandConfig, Survey } from '@/lib/types'
|
||||||
import { SurveyForm } from '@/components/survey-form'
|
import type { FormSchema, Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||||
|
import { buildFormSchema } from '@/lib/form-engine'
|
||||||
|
import { WizardForm } from '@/components/wizard-form'
|
||||||
|
|
||||||
|
// Preguntas de consentimiento (S1, 1.1, 1.2): las resuelve ConsentScreen, no el
|
||||||
|
// motor de flujo — su texto legal (ADR-003) no vive en el hint de estas filas
|
||||||
|
// todavía. Se excluyen del FormSchema para que el wizard arranque en S2.
|
||||||
|
const CONSENT_CODES = new Set(['S1', '1.1', '1.2'])
|
||||||
|
|
||||||
// Force dynamic rendering — page fetches from Supabase at request time.
|
// Force dynamic rendering — page fetches from Supabase at request time.
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -48,11 +55,12 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch questions ordered by position
|
// 2. Fetch questions ordered by position — incluye hint/instance_generator/instance_of (4D)
|
||||||
const { data: questionRows, error: questionsError } = await supabase
|
const { data: questionRows, error: questionsError } = await supabase
|
||||||
.from('questions')
|
.from('questions')
|
||||||
.select(
|
.select(
|
||||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, max_select, options, conditional_rules'
|
'id, survey_id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||||
|
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||||
)
|
)
|
||||||
.eq('survey_id', surveyRow.id)
|
.eq('survey_id', surveyRow.id)
|
||||||
.order('position', { ascending: true })
|
.order('position', { ascending: true })
|
||||||
@@ -66,9 +74,12 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const questions = questionRows as Question[]
|
// Cast en dos pasos: el cliente Supabase no tiene un Database genérico configurado
|
||||||
|
// (lib/supabase.ts), así que el tipo de fila que infiere para .select() no solapa
|
||||||
|
// directamente con EngineQuestion. Las columnas pedidas arriba sí coinciden 1:1.
|
||||||
|
const engineQuestions = questionRows as unknown as EngineQuestion[]
|
||||||
|
|
||||||
console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`)
|
console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
|
||||||
|
|
||||||
const survey: Survey = {
|
const survey: Survey = {
|
||||||
id: surveyRow.id,
|
id: surveyRow.id,
|
||||||
@@ -79,8 +90,13 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
k_threshold: surveyRow.k_threshold,
|
k_threshold: surveyRow.k_threshold,
|
||||||
consent_version_id: surveyRow.consent_version_id,
|
consent_version_id: surveyRow.consent_version_id,
|
||||||
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
||||||
questions,
|
questions: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SurveyForm survey={survey} questions={questions} />
|
// El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
|
||||||
|
const schema: FormSchema = buildFormSchema(
|
||||||
|
engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
|
||||||
|
)
|
||||||
|
|
||||||
|
return <WizardForm survey={survey} schema={schema} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,69 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
|
import type { AnswerValue, Question, QuestionOption } from '@/lib/form-engine.types'
|
||||||
import { RadioGroup } from './widgets/radio-group'
|
import { RadioGroup } from './widgets/radio-group'
|
||||||
import { CheckboxGroup } from './widgets/checkbox-group'
|
import { CheckboxGroup } from './widgets/checkbox-group'
|
||||||
import { RatingScale } from './widgets/rating-scale'
|
|
||||||
import { TextAreaField } from './widgets/text-area-field'
|
import { TextAreaField } from './widgets/text-area-field'
|
||||||
import { TextShortField } from './widgets/text-short-field'
|
import { TextShortField } from './widgets/text-short-field'
|
||||||
import { GeoSelectWidget } from './widgets/geo-select-widget'
|
|
||||||
|
|
||||||
interface QuestionRendererProps {
|
interface QuestionRendererProps {
|
||||||
question: Question
|
question: Question
|
||||||
answers: FormAnswers
|
value: AnswerValue
|
||||||
onChange: (code: string, value: FormAnswers[string]) => void
|
onChange: (value: AnswerValue) => void
|
||||||
errors: Record<string, string>
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
|
const YES_NO_OPTIONS: QuestionOption[] = [
|
||||||
return Array.isArray(opts)
|
{ value: 'true', label: 'Sí' },
|
||||||
}
|
{ value: 'false', label: 'No' },
|
||||||
|
]
|
||||||
|
|
||||||
function isRatingOptions(opts: unknown): opts is RatingOptions {
|
export function QuestionRenderer({ question, value, onChange, error }: QuestionRendererProps) {
|
||||||
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuestionRenderer({
|
|
||||||
question,
|
|
||||||
answers,
|
|
||||||
onChange,
|
|
||||||
errors,
|
|
||||||
}: QuestionRendererProps) {
|
|
||||||
const { code, type, prompt, required, max_select, options } = question
|
const { code, type, prompt, required, max_select, options } = question
|
||||||
const error = errors[code]
|
const legend = (
|
||||||
|
<>
|
||||||
// A1 (Departamento) renders a combined geo widget that also handles A2 (Ciudad)
|
<span className="q-code">{code} ·</span> {prompt}
|
||||||
// Code badges for A1/A2 are rendered inside GeoSelectWidget.
|
</>
|
||||||
if (code === 'A1') {
|
|
||||||
return (
|
|
||||||
<GeoSelectWidget
|
|
||||||
departmentValue={(answers['A1'] as string) ?? ''}
|
|
||||||
cityValue={(answers['A2'] as string) ?? ''}
|
|
||||||
onDepartmentChange={(v) => onChange('A1', v)}
|
|
||||||
onCityChange={(v) => onChange('A2', v)}
|
|
||||||
departmentError={errors['A1']}
|
|
||||||
cityError={errors['A2']}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
// A2 is rendered inside the GeoSelectWidget above — skip here to avoid duplication
|
|
||||||
if (code === 'A2') return null
|
|
||||||
|
|
||||||
const legend = <><span className="q-code">{code} ·</span> {prompt}</>
|
if (type === 'single_choice' && options) {
|
||||||
|
|
||||||
if (type === 'single_choice' && isArrayOptions(options)) {
|
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name={code}
|
name={code}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
options={options}
|
options={options}
|
||||||
value={answers[code] as string | undefined}
|
value={(value as string) ?? undefined}
|
||||||
required={required}
|
required={required}
|
||||||
onChange={(v) => onChange(code, v)}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'multiple_choice' && isArrayOptions(options)) {
|
if (type === 'multiple_choice' && options) {
|
||||||
return (
|
return (
|
||||||
<CheckboxGroup
|
<CheckboxGroup
|
||||||
name={code}
|
name={code}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
options={options}
|
options={options}
|
||||||
value={(answers[code] as string[]) ?? []}
|
value={(value as string[]) ?? []}
|
||||||
maxSelect={max_select}
|
maxSelect={max_select}
|
||||||
required={required}
|
required={required}
|
||||||
onChange={(v) => onChange(code, v)}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'rating' && isRatingOptions(options)) {
|
// yes_no resuelto con el RadioGroup existente — dos opciones fijas, sin widget nuevo.
|
||||||
|
if (type === 'yes_no') {
|
||||||
return (
|
return (
|
||||||
<RatingScale
|
<RadioGroup
|
||||||
name={code}
|
name={code}
|
||||||
legend={legend}
|
legend={legend}
|
||||||
ratingOptions={options}
|
options={YES_NO_OPTIONS}
|
||||||
value={answers[code] as number | undefined}
|
value={(value as string) ?? undefined}
|
||||||
required={required}
|
required={required}
|
||||||
onChange={(v) => onChange(code, v)}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -98,9 +74,9 @@ export function QuestionRenderer({
|
|||||||
<TextShortField
|
<TextShortField
|
||||||
name={code}
|
name={code}
|
||||||
label={legend}
|
label={legend}
|
||||||
value={(answers[code] as string) ?? ''}
|
value={(value as string) ?? ''}
|
||||||
required={required}
|
required={required}
|
||||||
onChange={(v) => onChange(code, v)}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -111,9 +87,9 @@ export function QuestionRenderer({
|
|||||||
<TextAreaField
|
<TextAreaField
|
||||||
name={code}
|
name={code}
|
||||||
label={legend}
|
label={legend}
|
||||||
value={(answers[code] as string) ?? ''}
|
value={(value as string) ?? ''}
|
||||||
required={required}
|
required={required}
|
||||||
onChange={(v) => onChange(code, v)}
|
onChange={onChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import { useCallback, useEffect, useRef, useState, useTransition } from 'react'
|
|
||||||
import type { FormAnswers, Question, Survey } from '@/lib/types'
|
|
||||||
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
|
|
||||||
import { getHiddenCodes, isQuestionVisible, ruleQuestionCodes } from '@/lib/conditional'
|
|
||||||
import { submitSurvey } from '@/app/actions/submit-survey'
|
|
||||||
import { ConsentScreen } from './consent-screen'
|
|
||||||
import { ProgressBar } from './progress-bar'
|
|
||||||
import { QuestionRenderer } from './question-renderer'
|
|
||||||
|
|
||||||
// Section metadata (code prefix → display title)
|
|
||||||
const SECTIONS: { prefix: string; title: string }[] = [
|
|
||||||
{ prefix: 'A', title: 'Datos Generales' },
|
|
||||||
{ prefix: '1', title: 'Prevalencia: cuidado y discapacidad' },
|
|
||||||
{ prefix: '2', title: 'Intensidad y rol del cuidado' },
|
|
||||||
{ prefix: '3', title: 'Impacto económico del cuidado' },
|
|
||||||
{ prefix: '4', title: 'Tiempo y descanso' },
|
|
||||||
{ prefix: '5', title: 'Impacto en el trabajo' },
|
|
||||||
{ prefix: '6', title: 'Conciencia organizacional' },
|
|
||||||
{ prefix: '7', title: 'Para cerrar' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function getSectionPrefix(code: string): string {
|
|
||||||
const m = code.match(/^([A-Za-z]+|\d+)/)
|
|
||||||
return m ? m[1] : code
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormStage = 'consent' | 'form' | 'success' | 'error'
|
|
||||||
|
|
||||||
interface SurveyFormProps {
|
|
||||||
survey: Survey
|
|
||||||
questions: Question[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SurveyForm({ survey, questions }: SurveyFormProps) {
|
|
||||||
const [stage, setStage] = useState<FormStage>('consent')
|
|
||||||
const [consentOperativo, setConsentOperativo] = useState(false)
|
|
||||||
const [consentMacroN1, setConsentMacroN1] = useState(false)
|
|
||||||
const [answers, setAnswers] = useState<FormAnswers>({})
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
||||||
const [isPending, startTransition] = useTransition()
|
|
||||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
|
||||||
const [hydrated, setHydrated] = useState(false)
|
|
||||||
const prevAnswers = useRef<FormAnswers>({})
|
|
||||||
|
|
||||||
// Hydrate from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const draft = loadDraft(survey.id)
|
|
||||||
if (Object.keys(draft).length > 0) setAnswers(draft)
|
|
||||||
setHydrated(true)
|
|
||||||
}, [survey.id])
|
|
||||||
|
|
||||||
// Persist to localStorage after hydration
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hydrated) return
|
|
||||||
saveDraft(survey.id, answers)
|
|
||||||
prevAnswers.current = answers
|
|
||||||
}, [answers, hydrated, survey.id])
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(code: string, value: FormAnswers[string]) => {
|
|
||||||
setAnswers((prev) => {
|
|
||||||
const updated = { ...prev, [code]: value }
|
|
||||||
const toHide = getHiddenCodes(code, value, questions, prev)
|
|
||||||
for (const hiddenCode of toHide) {
|
|
||||||
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
|
|
||||||
}
|
|
||||||
if (toHide.length > 0) {
|
|
||||||
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
|
|
||||||
} else {
|
|
||||||
const appeared = questions.some(
|
|
||||||
(q) =>
|
|
||||||
q.conditional_rules?.some((r) => ruleQuestionCodes(r).includes(code)) &&
|
|
||||||
isQuestionVisible(q, updated) &&
|
|
||||||
!isQuestionVisible(q, prev)
|
|
||||||
)
|
|
||||||
if (appeared) setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
setErrors((prev) => {
|
|
||||||
if (!prev[code]) return prev
|
|
||||||
const { [code]: _, ...rest } = prev
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[questions]
|
|
||||||
)
|
|
||||||
|
|
||||||
function validate(): boolean {
|
|
||||||
const newErrors: Record<string, string> = {}
|
|
||||||
for (const q of questions) {
|
|
||||||
if (!q.required) continue
|
|
||||||
if (!isQuestionVisible(q, answers)) continue
|
|
||||||
const val = answers[q.code]
|
|
||||||
if (val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0)) {
|
|
||||||
newErrors[q.code] = 'Este campo es obligatorio.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setErrors(newErrors)
|
|
||||||
return Object.keys(newErrors).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (isPending) return
|
|
||||||
if (!validate()) {
|
|
||||||
const firstErrorCode = Object.keys(errors)[0]
|
|
||||||
if (firstErrorCode) document.getElementById(firstErrorCode)?.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const questionMap = Object.fromEntries(questions.map((q) => [q.code, q.id]))
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await submitSurvey({
|
|
||||||
surveyId: survey.id,
|
|
||||||
answers: answers as Record<string, unknown>,
|
|
||||||
questionMap,
|
|
||||||
consentOperativo,
|
|
||||||
consentMacroN1,
|
|
||||||
})
|
|
||||||
if (result.success) {
|
|
||||||
clearDraft(survey.id)
|
|
||||||
setStage('success')
|
|
||||||
} else {
|
|
||||||
setSubmitError(result.error)
|
|
||||||
setStage('error')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: consent ──────────────────────────────────────────
|
|
||||||
if (stage === 'consent') {
|
|
||||||
return (
|
|
||||||
<ConsentScreen
|
|
||||||
brandConfig={survey.brand_config}
|
|
||||||
onAccept={(op, macro) => {
|
|
||||||
setConsentOperativo(op)
|
|
||||||
setConsentMacroN1(macro)
|
|
||||||
setStage('form')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: success ──────────────────────────────────────────
|
|
||||||
if (stage === 'success') {
|
|
||||||
return (
|
|
||||||
<div className="survey-wrapper">
|
|
||||||
<div className="thank-you-container" role="main">
|
|
||||||
<h1 className="thank-you-title">¡Gracias por participar!</h1>
|
|
||||||
<p className="thank-you-body">
|
|
||||||
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
|
|
||||||
forma de vincular tus respuestas con tu identidad.
|
|
||||||
</p>
|
|
||||||
<p className="thank-you-body">
|
|
||||||
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
|
|
||||||
diseñar mejores apoyos para las personas cuidadoras.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: error ────────────────────────────────────────────
|
|
||||||
if (stage === 'error') {
|
|
||||||
return (
|
|
||||||
<div className="survey-wrapper">
|
|
||||||
<div className="error-container" role="main">
|
|
||||||
<h1 className="error-title">No se pudo enviar la encuesta</h1>
|
|
||||||
<p>Hubo un error al guardar tus respuestas ({submitError}). Podés intentarlo de nuevo.</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => { setSubmitError(null); setStage('form') }}
|
|
||||||
>
|
|
||||||
Volver e intentar de nuevo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stage: form ─────────────────────────────────────────────
|
|
||||||
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
|
|
||||||
const answeredCount = visibleQuestions.filter((q) => {
|
|
||||||
const v = answers[q.code]
|
|
||||||
return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
|
|
||||||
}).length
|
|
||||||
|
|
||||||
type Section = { prefix: string; title: string; questions: Question[] }
|
|
||||||
const sectionMap = new Map<string, Section>()
|
|
||||||
for (const q of visibleQuestions) {
|
|
||||||
const prefix = getSectionPrefix(q.code)
|
|
||||||
if (!sectionMap.has(prefix)) {
|
|
||||||
const meta = SECTIONS.find((s) => s.prefix === prefix)
|
|
||||||
sectionMap.set(prefix, { prefix, title: meta?.title ?? prefix, questions: [] })
|
|
||||||
}
|
|
||||||
sectionMap.get(prefix)!.questions.push(q)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bc = survey.brand_config
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="survey-wrapper">
|
|
||||||
<a href="#survey-form" className="skip-link">Ir al formulario</a>
|
|
||||||
|
|
||||||
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
|
|
||||||
{a11yAnnounce}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header contextual con org/branch si está configurado */}
|
|
||||||
{bc?.org_name && (
|
|
||||||
<div className="brand-header" role="note" aria-label="Información de la empresa">
|
|
||||||
<p className="brand-text">
|
|
||||||
Estás respondiendo esto porque pertenecés a <strong>{bc.org_name}</strong>
|
|
||||||
{bc.branch_name && (
|
|
||||||
<> y participás en las oficinas de <strong>{bc.branch_name}</strong>
|
|
||||||
{bc.branch_city && <> en <strong>{bc.branch_city}</strong></>}.</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="survey-header">
|
|
||||||
<h1 className="survey-title">{survey.title}</h1>
|
|
||||||
<p className="survey-anon-note">Esta encuesta es anónima. Tus respuestas no te identifican.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="survey-progress-sticky" aria-label="Barra de progreso">
|
|
||||||
<ProgressBar answered={answeredCount} total={visibleQuestions.length} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main id="survey-form">
|
|
||||||
<form onSubmit={handleSubmit} noValidate aria-label={`Encuesta: ${survey.title}`}>
|
|
||||||
{Array.from(sectionMap.values()).map((section) => (
|
|
||||||
<section
|
|
||||||
key={section.prefix}
|
|
||||||
className="survey-section"
|
|
||||||
aria-labelledby={`section-${section.prefix}`}
|
|
||||||
>
|
|
||||||
<div className="section-header">
|
|
||||||
<h2 id={`section-${section.prefix}`} className="section-title">Sección {section.prefix} · {section.title}</h2>
|
|
||||||
</div>
|
|
||||||
{section.questions.map((q) => (
|
|
||||||
<div key={q.id} className="question-wrapper" data-code={q.code}>
|
|
||||||
<QuestionRenderer
|
|
||||||
question={q}
|
|
||||||
answers={answers}
|
|
||||||
onChange={handleChange}
|
|
||||||
errors={errors}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="form-footer">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={isPending}
|
|
||||||
aria-disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
243
components/wizard-form.tsx
Normal file
243
components/wizard-form.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
'use client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { BrandConfig, Survey } from '@/lib/types'
|
||||||
|
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
||||||
|
import {
|
||||||
|
answerKey,
|
||||||
|
expandInstances,
|
||||||
|
initialState,
|
||||||
|
next,
|
||||||
|
back,
|
||||||
|
resolveScreens,
|
||||||
|
setAnswer,
|
||||||
|
} from '@/lib/form-engine'
|
||||||
|
import { loadDraft, saveDraft } from '@/lib/storage'
|
||||||
|
import { ConsentScreen } from './consent-screen'
|
||||||
|
import { ProgressBar } from './progress-bar'
|
||||||
|
import { QuestionRenderer } from './question-renderer'
|
||||||
|
|
||||||
|
type Stage = 'consent' | 'form'
|
||||||
|
|
||||||
|
interface WizardFormProps {
|
||||||
|
survey: Survey
|
||||||
|
schema: FormSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(v: AnswerValue): boolean {
|
||||||
|
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFor(screen: Screen, answers: FormAnswers): AnswerValue {
|
||||||
|
if (!screen.question) return null
|
||||||
|
const v = answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]
|
||||||
|
return v === undefined ? null : v
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume en la primera pantalla sin responder (o la sección que la precede). */
|
||||||
|
function resumeScreenId(schema: FormSchema, answers: FormAnswers): string {
|
||||||
|
const screens = resolveScreens(schema, answers)
|
||||||
|
const firstUnanswered = screens.findIndex(
|
||||||
|
(s) => s.kind === 'question' && isEmpty(valueFor(s, answers))
|
||||||
|
)
|
||||||
|
if (firstUnanswered <= 0) return screens[0]?.id ?? 'end'
|
||||||
|
const prev = screens[firstUnanswered - 1]
|
||||||
|
return prev.kind === 'section' ? prev.id : screens[firstUnanswered].id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||||
|
const [stage, setStage] = useState<Stage>('consent')
|
||||||
|
const [consentOperativo, setConsentOperativo] = useState(false)
|
||||||
|
const [consentMacroN1, setConsentMacroN1] = useState(false)
|
||||||
|
const [state, setState] = useState(() => initialState(schema))
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const draft = loadDraft(survey.id)
|
||||||
|
if (Object.keys(draft).length > 0) {
|
||||||
|
setState({ answers: draft, currentScreenId: resumeScreenId(schema, draft) })
|
||||||
|
}
|
||||||
|
setHydrated(true)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [survey.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return
|
||||||
|
saveDraft(survey.id, state.answers)
|
||||||
|
}, [state.answers, hydrated, survey.id])
|
||||||
|
|
||||||
|
const screens = resolveScreens(schema, state.answers)
|
||||||
|
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||||
|
const screen = screens[idx] ?? screens[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screen) return
|
||||||
|
if (screen.kind === 'section') setA11yAnnounce(`Sección: ${screen.question?.prompt}`)
|
||||||
|
else if (screen.kind === 'question') setA11yAnnounce(`Pregunta ${screen.question?.code}: ${screen.question?.prompt}`)
|
||||||
|
else setA11yAnnounce('Fin del formulario')
|
||||||
|
}, [screen?.id])
|
||||||
|
|
||||||
|
if (stage === 'consent') {
|
||||||
|
return (
|
||||||
|
<ConsentScreen
|
||||||
|
brandConfig={survey.brand_config}
|
||||||
|
onAccept={(op, macro) => {
|
||||||
|
setConsentOperativo(op)
|
||||||
|
setConsentMacroN1(macro)
|
||||||
|
setStage('form')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screen) return null
|
||||||
|
|
||||||
|
const currentValue = valueFor(screen, state.answers)
|
||||||
|
const questionScreens = screens.filter((s) => s.kind !== 'end')
|
||||||
|
const ordinal = Math.min(idx + 1, questionScreens.length)
|
||||||
|
const sectionScreens = screens.filter((s) => s.kind === 'section')
|
||||||
|
const sectionOrdinal = screen.kind === 'section' ? sectionScreens.findIndex((s) => s.id === screen.id) + 1 : null
|
||||||
|
|
||||||
|
// Contexto de instancia ("Familiar X de N") — derivado, no decide visibilidad ni navegación.
|
||||||
|
let instanceCtx: { label: string; ordinal: number; total: number } | null = null
|
||||||
|
if (screen.instanceId && screen.question?.instance_of) {
|
||||||
|
const generator = schema.questions.find((q) => q.code === screen.question!.instance_of)
|
||||||
|
if (generator) {
|
||||||
|
const instances = expandInstances(generator, state.answers)
|
||||||
|
const ord = instances.findIndex((i) => i.instanceId === screen.instanceId)
|
||||||
|
if (ord >= 0) {
|
||||||
|
instanceCtx = { label: instances[ord].optionValue, ordinal: ord + 1, total: instances.length }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(value: AnswerValue) {
|
||||||
|
if (!screen.question) return
|
||||||
|
const code = screen.question.code
|
||||||
|
const instanceId = screen.instanceId ?? undefined
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
const updated = setAnswer(prev, code, value, instanceId)
|
||||||
|
// Limpia respuestas de pantallas que dejaron de ser visibles tras este cambio.
|
||||||
|
const before = resolveScreens(schema, prev.answers)
|
||||||
|
const after = resolveScreens(schema, updated.answers)
|
||||||
|
const afterIds = new Set(after.map((s) => s.id))
|
||||||
|
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
|
||||||
|
if (disappeared.length === 0) return updated
|
||||||
|
const clearedAnswers = { ...updated.answers }
|
||||||
|
for (const s of disappeared) {
|
||||||
|
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
|
||||||
|
}
|
||||||
|
return { ...updated, answers: clearedAnswers }
|
||||||
|
})
|
||||||
|
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!prev[code]) return prev
|
||||||
|
const { [code]: _, ...rest } = prev
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (screen.kind === 'question' && screen.question?.required && isEmpty(currentValue)) {
|
||||||
|
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setState((s) => next(s, schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
setState((s) => back(s, schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bc: BrandConfig | null = survey.brand_config
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard-shell">
|
||||||
|
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
|
||||||
|
{a11yAnnounce}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wizard-topbar">
|
||||||
|
{bc?.org_name && (
|
||||||
|
<p className="brand-text wizard-topbar-brand">
|
||||||
|
Encuesta de <strong>{bc.org_name}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ProgressBar answered={ordinal} total={questionScreens.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="wizard-stage" id="wizard-stage">
|
||||||
|
{screen.kind === 'section' && screen.question && (
|
||||||
|
<div className="wizard-section-screen" role="region" aria-labelledby="wizard-section-title">
|
||||||
|
<p className="wizard-section-number">
|
||||||
|
Sección {sectionOrdinal} de {sectionScreens.length}
|
||||||
|
</p>
|
||||||
|
<h2 id="wizard-section-title" className="wizard-section-title">
|
||||||
|
{screen.question.prompt}
|
||||||
|
</h2>
|
||||||
|
{screen.question.hint && <p className="wizard-section-desc">{screen.question.hint}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen.kind === 'question' && screen.question && (
|
||||||
|
<>
|
||||||
|
<div className="wizard-prompt-block">
|
||||||
|
{instanceCtx && (
|
||||||
|
<span className="wizard-instance-badge">
|
||||||
|
{instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="wizard-prompt-code">{screen.question.code}</span>
|
||||||
|
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
|
||||||
|
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="wizard-answer-area">
|
||||||
|
<QuestionRenderer
|
||||||
|
question={screen.question}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={errors[screen.question.code]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen.kind === 'end' && (
|
||||||
|
<div className="wizard-section-screen">
|
||||||
|
<h2 className="wizard-section-title">Llegaste al final</h2>
|
||||||
|
<p className="wizard-section-desc">
|
||||||
|
El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus
|
||||||
|
respuestas con "Anterior".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="wizard-bottombar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={idx === 0}
|
||||||
|
aria-disabled={idx === 0}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<span className="wizard-bottombar-hint" aria-hidden="true">
|
||||||
|
{ordinal}/{questionScreens.length}
|
||||||
|
</span>
|
||||||
|
{screen.kind !== 'end' ? (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleNext}>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-primary btn-disabled" disabled aria-disabled="true">
|
||||||
|
Enviar (próximamente)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Binary file not shown.
102
docs/adr/ADR-005-esquema-declarativo-formularios.md
Normal file
102
docs/adr/ADR-005-esquema-declarativo-formularios.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# ADR-005 — Esquema declarativo de formularios y estrategia de dos fases
|
||||||
|
|
||||||
|
> Para agregar a `ARCHITECTURE.md` (o `docs/adr/`). Estado: **Aceptada**.
|
||||||
|
> Fecha: 2026-06-20 · Sprint 4 · Versión objetivo 3.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
El producto es un **motor de formularios/flujos de encuesta**. La aplicación
|
||||||
|
actual (encuesta "Mapeo de Cuidadores") es su primer caso de uso real, pero la
|
||||||
|
arquitectura debe servir al producto, no solo a este formulario.
|
||||||
|
|
||||||
|
El rediseño a wizard v3 introduce lógica de flujo significativamente más compleja
|
||||||
|
que v2: recorridos ramificados (cuidador completo / solo niño / sin vínculo),
|
||||||
|
mini-wizard de instancias de vínculo familiar (preguntas que se repiten N veces,
|
||||||
|
una por familiar), y saltos condicionales de sección.
|
||||||
|
|
||||||
|
Existe una restricción de negocio: dos clientes inminentes en 10-20 días requieren
|
||||||
|
la encuesta funcionando para validación.
|
||||||
|
|
||||||
|
El esquema actual de `questions` ya posee una base declarativa:
|
||||||
|
- `conditional_rules` JSONB con operadores `eq` / `eq_any` y acción `show`
|
||||||
|
- Auto-target implícito (la regla afecta a la pregunta que la porta)
|
||||||
|
- Tipos de pregunta extensibles vía CHECK constraint
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
¿Dónde reside la lógica del flujo del formulario? Tres anti-patrones a evitar:
|
||||||
|
1. **Lógica en el presentador** (frontend con reglas hardcodeadas) — impide
|
||||||
|
reutilización, genera bugs de sincronización de estado, no escala al producto.
|
||||||
|
2. **Lógica dispersa en columnas sueltas** de la base — deuda técnica; no soporta
|
||||||
|
evolución a flujos más complejos sin migrar el esquema entero.
|
||||||
|
3. **Sobre-ingeniería prematura** — construir un motor genérico universal para
|
||||||
|
casos hipotéticos antes de conocerlos, retrasando el deadline real.
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
**La lógica del flujo vive en una capa de definición declarativa (datos),
|
||||||
|
interpretada por un motor en el backend, y renderizada por un frontend sin reglas
|
||||||
|
de negocio.**
|
||||||
|
|
||||||
|
Se adopta una **estrategia de dos fases**:
|
||||||
|
|
||||||
|
### Fase 1 (Sprint 4 — para el deadline)
|
||||||
|
- El formulario de cuidadores se define como **esquema declarativo persistido**,
|
||||||
|
extendiendo el `conditional_rules` JSONB existente y agregando campos
|
||||||
|
declarativos de instanciación a `questions`.
|
||||||
|
- Se construye un motor de resolución de flujo **para este formulario** (interpreta
|
||||||
|
el esquema; calcula qué pantalla sigue desde esquema + estado).
|
||||||
|
- El frontend wizard es un **renderer** del esquema, sin reglas de negocio.
|
||||||
|
- NO se construye el intérprete genérico universal todavía.
|
||||||
|
|
||||||
|
### Fase 2 (sprints posteriores — post-deadline, registrada en BACKLOG)
|
||||||
|
- Se extrae el intérprete a un **motor genérico de formularios**.
|
||||||
|
- El formulario de cuidadores, ya definido como esquema, se vuelve el primer caso
|
||||||
|
de prueba del motor — sin reescribir el contrato.
|
||||||
|
|
||||||
|
### Diseño técnico del esquema (Fase 1)
|
||||||
|
Se extiende `questions` en lugar de crear una tabla de definición separada:
|
||||||
|
- `conditional_rules` extendido con nuevos operadores (`not_in`, acción `hide`)
|
||||||
|
para expresar saltos como "Ninguno → salta a sección final".
|
||||||
|
- Nuevos campos declarativos para instanciación:
|
||||||
|
- `instance_generator` — marca la pregunta que genera instancias (ej. 4.1)
|
||||||
|
- `instance_of` — código del generador del que depende una pregunta (ej. 4.2 → "4.1")
|
||||||
|
- Representación de secciones con nombre y descripción (el "porqué").
|
||||||
|
|
||||||
|
## Alternativas consideradas
|
||||||
|
|
||||||
|
1. **Lógica en el frontend (rechazada):** rápida para este formulario, pero viola
|
||||||
|
el principio de producto, no escala, y reproduce los bugs de sincronización ya
|
||||||
|
vistos en el mockup.
|
||||||
|
2. **Columnas sueltas tipo `is_instanced` (rechazada):** resuelve hoy, genera deuda;
|
||||||
|
no soporta flujos más complejos sin migración de esquema.
|
||||||
|
3. **Motor genérico universal ahora (rechazada):** arquitectónicamente deseable como
|
||||||
|
fin, pero sobre-ingeniería antes del deadline y con un solo caso de uso conocido
|
||||||
|
(diseñar en el vacío). Se difiere a Fase 2 cuando haya más casos que informen el
|
||||||
|
diseño abstracto.
|
||||||
|
4. **Tabla de definición de flujo separada (diferida):** posible mejor forma para el
|
||||||
|
motor genérico, pero para un solo formulario es sobre-ingeniería. Se reevalúa en
|
||||||
|
Fase 2.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
**Positivas:**
|
||||||
|
- Entrega la encuesta en plazo sin generar retrabajo estructural.
|
||||||
|
- La lógica en datos habilita guardado parcial, reanudación, validación consistente
|
||||||
|
y progreso preciso — mejor UX, fuente única de verdad del estado.
|
||||||
|
- La Fase 2 es generalización del intérprete, no reescritura del contrato.
|
||||||
|
- El esquema declarativo es versionable y auditable.
|
||||||
|
|
||||||
|
**Negativas / deuda intencional:**
|
||||||
|
- Fase 1 es más trabajo que parchear el formulario actual.
|
||||||
|
- El motor de Fase 1 es específico a este formulario (no genérico) — deuda
|
||||||
|
registrada en BACKLOG, asumida conscientemente, no oculta.
|
||||||
|
- Extender `conditional_rules` puede requerir reevaluación cuando lleguen flujos
|
||||||
|
con lógica que el vocabulario actual de operadores no exprese.
|
||||||
|
|
||||||
|
## Cumplimiento (cómo se verifica)
|
||||||
|
- Revisión de código: el frontend wizard NO contiene reglas de flujo del negocio.
|
||||||
|
- El flujo es reproducible desde el esquema persistido.
|
||||||
|
- La deuda de Fase 2 figura en `BACKLOG.md`.
|
||||||
@@ -1,155 +1,131 @@
|
|||||||
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||||
## Documento de continuidad — 11 junio 2026
|
## Documento de continuidad — 20 junio 2026
|
||||||
|
|
||||||
> Director: Marcos · Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
|
||||||
|
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. URLs operativas
|
## 1. Versionado y ramas
|
||||||
|
|
||||||
| Recurso | URL |
|
- **Versión en producción:** 2.1.0 (formulario lineal, dashboard multitenante)
|
||||||
|---|---|
|
- **Versión en desarrollo:** 3.0.0 (wizard, en `main`)
|
||||||
| Formulario Demo | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001 |
|
- **Rama de preservación:** `v2.1.0-stable` (código + dump DB) + tag `v2.1.0`
|
||||||
| Formulario InQuality | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000002 |
|
- Convención: SemVer + CHANGELOG.md (raíz) + tags en cada versión estable
|
||||||
| Formulario Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000003 |
|
- ADRs en `docs/adr/`. ADR-005 = esquema declarativo + estrategia dos fases.
|
||||||
| Dashboard — Todos | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=00000000-0000-0000-0000-000000000000 |
|
|
||||||
| Dashboard — InQuality | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000002 |
|
|
||||||
| Dashboard — Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000003 |
|
|
||||||
| Dashboard — Demo | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000001 |
|
|
||||||
| Control de respuestas | https://economia-cuidado.inqualityhq.com/admin/responses |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Infraestructura — OPERATIVA ✅
|
## 2. Sprint 4 — Wizard v3 — EN CURSO
|
||||||
|
|
||||||
- **App:** Next.js 15.3.8 (CVE-2025-66478 resuelto ✅)
|
Arquitectura (ADR-005): el formulario es un **esquema declarativo** (datos); el frontend
|
||||||
- **DB:** Supabase self-hosted en Dokploy v0.29.4
|
es un **renderer** sin reglas de negocio; el motor interpreta el esquema.
|
||||||
- **VPS:** inq-mart-2, IP VPN: 172.20.5.11
|
Estrategia dos fases: Fase 1 (este sprint) formulario-como-esquema; Fase 2 (BACKLOG-002)
|
||||||
- **Repos:**
|
motor genérico universal.
|
||||||
- App: `git.inquality.com.py/marcos/motor-de-encuestas` (rama main)
|
|
||||||
- Supabase stack: `git.inquality.com.py/marcos/supabase-motor-fomularios` (rama master)
|
|
||||||
- **API Supabase:** https://db.inqualityhq.com (Kong + SSL)
|
|
||||||
- **Backups:** `/var/backups/supabase-prod/`, cron 3AM, GFS (3 días diarios + domingos 14 días)
|
|
||||||
- **Studio:** solo vía SSH tunnel → `ssh -L 3001:localhost:3001 administrator@172.20.5.11`
|
|
||||||
|
|
||||||
---
|
### Etapas
|
||||||
|
| Etapa | Nombre | Estado |
|
||||||
## 3. Instrumento — v2.1 ACTIVO
|
|
||||||
|
|
||||||
- **44 preguntas**, 8 secciones
|
|
||||||
- **Salto triple:** 1.1=No AND 1.6=No AND 1.13=No → salta a 6.4 (validado con datos reales ✅)
|
|
||||||
- **Nueva pregunta 6.6:** voluntariado en redes de apoyo (Sí/No/Tal vez)
|
|
||||||
- **2.4 eliminada**, **2.1 → multiple_choice**
|
|
||||||
- "Prefiero no decir" eliminado de todas las preguntas
|
|
||||||
- Hint "Podés marcar más de una opción" en todas las multiple_choice
|
|
||||||
- "Tecnología asistiva" en 1.3 y 1.10
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. DB — estado actual
|
|
||||||
|
|
||||||
| Tabla | Filas |
|
|
||||||
|---|---|
|
|
||||||
| organizations | 3 (Empresa Demo, InQuality, Fundación Solidaridad) |
|
|
||||||
| surveys | 3 (una por organización) |
|
|
||||||
| questions | 132 (44 × 3 surveys) |
|
|
||||||
| responses | 15 (8 InQuality + 7 Demo — todas de prueba) |
|
|
||||||
| answers | ~500 (estimado) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Dashboard — multitenante operativo ✅
|
|
||||||
|
|
||||||
- Selector de empresa en el header: Todos / InQuality / Fundación Solidaridad / Demo
|
|
||||||
- "Todos" usa UUID especial `00000000-0000-0000-0000-000000000000` → pool agregado
|
|
||||||
- Implementado via flag en `_dash_responses` (migración `20260611000001`)
|
|
||||||
- Validado visualmente con n=15: KPIs, heatmap departamental, perfil población ✅
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Vista de control /admin/responses
|
|
||||||
|
|
||||||
- Tabla plana: una fila por answer, agrupada por submission
|
|
||||||
- Colapsar/expandir por submission (Client Component island)
|
|
||||||
- Excluye: `text_long`, `is_sensitive=true` (TECH-DEBT-008 — sin auth)
|
|
||||||
- Sin columna "Tipo"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Método — Sprint 2 cerrado ✅
|
|
||||||
|
|
||||||
- `method/PATTERNS.md` y `method/ANTI_PATTERNS.md` creados en el repo
|
|
||||||
- C.8 promovido a estable con casos reales del proyecto
|
|
||||||
- AP.10 y AP.11 documentados con casos reales
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Deuda técnica abierta
|
|
||||||
|
|
||||||
| ID | Descripción | Bloqueante |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| TECH-DEBT-006 | Backup remoto S3 | SÍ — antes del primer dato real |
|
| 4.0 | Preservación v2 (tag, rama, dump, CHANGELOG) | ✅ |
|
||||||
| TECH-DEBT-007 | auth.jwt() deprecado en GoTrue v2.189+ | No (piloto) |
|
| 4A | Persistencia `instance_id` en answers | ✅ aplicada en prod |
|
||||||
| TECH-DEBT-008 | /admin/responses sin auth | No (URL no publicada) |
|
| 4B | Esquema declarativo + seed v3 (10 secciones, 150 questions) | ✅ aplicada en prod |
|
||||||
| TECH-DEBT-009 | Rotar secrets Supabase post-CVE-2025-66478 | SÍ — antes del primer dato real |
|
| 4C | Motor de resolución de flujo (puro, lib/, 16 tests) | ✅ commiteada |
|
||||||
|
| 4D | Frontend renderer (consume el motor, una preg/pantalla) | ⏳ siguiente |
|
||||||
|
| 4E | Instancias de vínculo (mini-wizard familiares) | pendiente |
|
||||||
|
| 4F | Autocomplete barrios (requiere geo_paraguay_full.json) | pendiente |
|
||||||
|
| 4G | Submit con instance_id + **validación server-side (no negociable)** | pendiente |
|
||||||
|
| 4H | RPCs de vínculo (agregados por instancia) | pendiente |
|
||||||
|
| 4I | Validación e2e + tag v3.0.0 + ADR + BACKLOG Fase 2 | pendiente |
|
||||||
|
|
||||||
|
Orden: 4D → (4E ∥ 4F) → 4G → 4H → 4I
|
||||||
|
|
||||||
|
### Estado técnico v3 en la DB (ya aplicado en prod)
|
||||||
|
- `answers.instance_id text DEFAULT 'default'`, constraint `(response_id, question_id, instance_id)`
|
||||||
|
- `questions` extendida: `instance_generator`, `instance_of`, `hint`
|
||||||
|
- 150 questions (50 × 3 surveys), instrumento v3 de 10 secciones
|
||||||
|
- 4.1 = `instance_generator`; 4.2/4.3 = `instance_of='4.1'`
|
||||||
|
- Datos de prueba v2 borrados (TRUNCATE); v3 arranca limpio
|
||||||
|
|
||||||
|
### Motor de flujo (4C) — lib/form-engine.ts
|
||||||
|
- Módulo puro, sin React/DOM. Tests en `lib/form-engine.test.mts` (16/16).
|
||||||
|
- Operadores: `eq`, `eq_any`, `not_in` (escalares); `contains`, `eq_sole` (array multiple_choice)
|
||||||
|
- Acciones: `show`, `hide` (hide = veto absoluto sobre show del mismo array)
|
||||||
|
- Reglas múltiples en un array = OR
|
||||||
|
- ID de instancia: `familiar_${posición fija de opción}` (determinista, estable)
|
||||||
|
- Secciones = pantallas propias; se ocultan solo si su fila type='section' tiene regla
|
||||||
|
|
||||||
|
### Recorridos del formulario (validados en mockup y tests)
|
||||||
|
- Completo: todos los vínculos → todas las secciones
|
||||||
|
- Solo niño sin discapacidad (`eq_sole` op 1): liviano → 5.3, 8.4, 9.1, 9.4
|
||||||
|
- Ninguna (`contains` op 8): salta a Sección 10
|
||||||
|
- 3.1='No': oculta 3.2-3.5
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Pendientes próxima sesión
|
## 3. Decisiones arquitectónicas clave (no reabrir)
|
||||||
|
|
||||||
1. TECH-DEBT-006 — backup remoto S3
|
1. Lógica de flujo en datos (esquema), no en frontend (ADR-005)
|
||||||
2. TECH-DEBT-009 — rotar secrets Supabase
|
2. Frontend = renderer sin reglas de negocio
|
||||||
3. RE Index — confirmar mapeo G1/G2/G4/G5 → códigos seed con Marcos
|
3. Motor de flujo (4C) corre en cliente, módulo puro testeable
|
||||||
4. Informe descargable — prometido en deck comercial
|
4. **Validación de integridad del submit = server-side, NO negociable (4G)**
|
||||||
5. Onboarding empresas reales — cuando validación esté lista
|
La validación de cliente es UX, no seguridad. Datos de salud sensibles.
|
||||||
|
5. Secciones como pantallas propias (reversible — es comportamiento del motor)
|
||||||
|
6. Se extiende `questions`, no se crea tabla de definición (ADR-005)
|
||||||
|
7. Migraciones vía CLI del VPS (`docker exec -i supabase-db psql`)
|
||||||
|
8. consent_records inmutable (ADR-003); el seed nunca toca datos de respuesta
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Decisiones tomadas — no reabrir
|
## 4. Deuda técnica y seguridad
|
||||||
|
|
||||||
1. Supabase en Dokploy siempre via modo Git
|
| ID | Descripción | Prioridad |
|
||||||
2. POSTGRES_PASSWORD siempre con `openssl rand -hex`
|
|---|---|---|
|
||||||
3. kong.yml: una sola entrada por bloque keyauth_credentials
|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
|
||||||
4. SMTP_PORT=587 siempre explícito aunque SMTP esté desactivado
|
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
|
||||||
5. GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito
|
| SEC-002 | Sprint de compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||||
6. LUA_AUTH_EXPR con prefijo "Bearer " en kong-entrypoint.sh
|
| SEC-003 | **Agujero TRUNCATE evade inmutabilidad ADR-003** — cerrar antes de datos reales | Antes de datos reales |
|
||||||
7. NEXT_PUBLIC_SUPABASE_URL via Build Argument en Dokploy
|
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline |
|
||||||
8. App conecta a https://db.inqualityhq.com
|
|
||||||
9. /admin/responses no muestra is_sensitive=true sin auth (TECH-DEBT-008)
|
COMPLIANCE.md documenta estado de seguridad, roadmap de auditabilidad, y la decisión
|
||||||
10. brand_config.org_name alimenta el nombre de empresa en el formulario
|
fundamentada de NO usar blockchain.
|
||||||
11. Organizaciones separadas para cada encuesta
|
|
||||||
12. conditional_rules referencian códigos de pregunta, no UUIDs
|
|
||||||
13. UUID cero `00000000-...` es el flag p_all del dashboard
|
|
||||||
14. Next.js pinneado a versión exacta `15.3.8` (sin caret)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Contexto comercial
|
## 5. Infraestructura (sin cambios)
|
||||||
|
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
|
||||||
- 2 empresas comprometidas para piloto (InQuality y Fundación Solidaridad en espacio de pruebas)
|
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com`
|
||||||
- Cronograma: JUN inicio · JUL-AGO relevamiento · SEP análisis · OCT informe + evento
|
- Repos Gitea: `git.inquality.com.py/marcos/motor-de-encuestas`
|
||||||
- Meta: 20 empresas pioneras
|
- Backups GFS locales 3AM
|
||||||
- Precios: Gs. 4.5M (≤50) · 7.5M (≤200) · 12M (≤400)
|
|
||||||
- GAPs deck vs producto: RE Index no implementado, informe descargable no existe
|
|
||||||
- Contacto Fundación: Valeria Arce — ddff@fundacionsolidaridad.org.py · +595 991 854333
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Prompt para nueva sesión
|
## 6. Contexto comercial
|
||||||
|
- 2 clientes inminentes en 10-20 días (motivan el deadline de v3)
|
||||||
Pegá al inicio de la nueva conversación:
|
- InQuality y Fundación Solidaridad ya en joint venture del producto
|
||||||
|
- Datos de prueba descartables (existe dump en v2.1.0-stable)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Sos el copiloto técnico del proyecto "Datos País sobre el Cuidado" — plataforma soberana
|
## 7. Próximo paso inmediato
|
||||||
de encuestas sobre economía del cuidado en Paraguay. Tenemos múltiples sesiones de trabajo.
|
**Etapa 4D — Frontend renderer.** El componente consume el motor (4C) y renderiza
|
||||||
|
una pregunta por pantalla. Incluye la tarea 4.5 pospuesta de 4C (query de app/page.tsx
|
||||||
|
con hint/instance_generator/instance_of). Reemplaza lib/conditional.ts y la lógica
|
||||||
|
de secciones hardcodeada de survey-form.tsx v2.
|
||||||
|
|
||||||
Stack: Next.js 15.3.8 + Supabase self-hosted + Dokploy v0.29.4.
|
---
|
||||||
URL: https://economia-cuidado.inqualityhq.com
|
|
||||||
Repos en Gitea: git.inquality.com.py/marcos/
|
|
||||||
|
|
||||||
El estado completo está en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` en el repo.
|
## 8. Prompt para nueva sesión
|
||||||
Los prompts de etapas pasadas están en `sprints/`.
|
```
|
||||||
Las reglas no negociables están en `CLAUDE.md`.
|
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de
|
||||||
|
formularios soberano (Paraguay). Yo soy el product owner. Estamos en Sprint 4
|
||||||
|
construyendo el formulario wizard v3 (esquema declarativo, ADR-005).
|
||||||
|
|
||||||
Próximos pasos: [COMPLETAR CON LO QUE VAS A HACER]
|
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md en el repo.
|
||||||
|
Sprint: sprints/sprint-4/ (BRIEF + prompts de etapa + reportes).
|
||||||
|
Completadas: 4.0, 4A, 4B, 4C. Siguiente: 4D (frontend renderer).
|
||||||
|
|
||||||
|
El motor de flujo ya existe y está testeado (lib/form-engine.ts, 16 tests).
|
||||||
|
La lógica vive en datos; el frontend es renderer sin reglas.
|
||||||
|
|
||||||
|
Próximo paso: [COMPLETAR]
|
||||||
|
```
|
||||||
344
lib/form-engine.test.mts
Normal file
344
lib/form-engine.test.mts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
// Tests Node puros (sin navegador) del motor de resolución de flujo v3.
|
||||||
|
// Ejecutar: node --test lib/form-engine.test.mts
|
||||||
|
//
|
||||||
|
// Extensión .mts (no .ts): así Node ejecuta el archivo como ESM nativo con
|
||||||
|
// soporte de TypeScript sin configuración adicional, y el glob "**/*.ts" del
|
||||||
|
// tsconfig.json del proyecto (usado por `next build`/`tsc`) no lo incluye —
|
||||||
|
// evita el conflicto entre "Node necesita extensión .ts explícita en los
|
||||||
|
// imports" y "tsc rechaza imports con extensión .ts sin allowImportingTsExtensions".
|
||||||
|
import { test, describe } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
buildFormSchema,
|
||||||
|
evaluateRule,
|
||||||
|
resolveVisibility,
|
||||||
|
resolveScreens,
|
||||||
|
initialState,
|
||||||
|
setAnswer,
|
||||||
|
next,
|
||||||
|
back,
|
||||||
|
} from './form-engine.ts'
|
||||||
|
import type { ConditionalRule, FormAnswers, Question } from './form-engine.types.ts'
|
||||||
|
|
||||||
|
// ── Fixture: réplica fiel de S3–S10 del seed v3 (supabase/seed.sql) ──────────
|
||||||
|
// S1/S2 no tienen conditional_rules y no aportan a estos tests; se omiten.
|
||||||
|
|
||||||
|
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
|
||||||
|
return {
|
||||||
|
id: partial.code,
|
||||||
|
is_sensitive: false,
|
||||||
|
required: false,
|
||||||
|
hint: null,
|
||||||
|
options: null,
|
||||||
|
max_select: null,
|
||||||
|
conditional_rules: null,
|
||||||
|
instance_generator: false,
|
||||||
|
instance_of: null,
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VINCULOS = [
|
||||||
|
'Hijo menor sin discapacidad',
|
||||||
|
'Hijo menor con discapacidad',
|
||||||
|
'Hijo adulto con discapacidad',
|
||||||
|
'Familiar menor con discapacidad',
|
||||||
|
'Familiar mayor con discapacidad',
|
||||||
|
'Padre/madre con movilidad reducida o 100% dependiente',
|
||||||
|
'Otro / Especificar',
|
||||||
|
'Ninguna de las anteriores / No tengo vinculación de cuidado',
|
||||||
|
] as const
|
||||||
|
const NINGUNA = VINCULOS[7]
|
||||||
|
const SOLO_NINO = VINCULOS[0]
|
||||||
|
|
||||||
|
const r_show_si_s3_1: ConditionalRule[] = [
|
||||||
|
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
|
||||||
|
]
|
||||||
|
const r_hide_ning: ConditionalRule[] = [
|
||||||
|
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
|
||||||
|
]
|
||||||
|
const r_hide_ning_sn: ConditionalRule[] = [
|
||||||
|
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
|
||||||
|
{ if_question: '4.1', op: 'eq_sole', value: SOLO_NINO, action: 'hide' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildFixtureSchema() {
|
||||||
|
const rows: Question[] = [
|
||||||
|
// S3 — Prevalencia de discapacidad
|
||||||
|
q({ code: 'S3', type: 'section', prompt: 'Prevalencia de discapacidad', position: 30 }),
|
||||||
|
q({ code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31 }),
|
||||||
|
q({ code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_s3_1 }),
|
||||||
|
q({ code: '3.3', type: 'multiple_choice', prompt: 'Dispositivos', position: 33, conditional_rules: r_show_si_s3_1 }),
|
||||||
|
q({ code: '3.4', type: 'single_choice', prompt: 'Certificado', position: 34, conditional_rules: r_show_si_s3_1 }),
|
||||||
|
q({ code: '3.5', type: 'multiple_choice', prompt: 'Impacto', position: 35, conditional_rules: r_show_si_s3_1 }),
|
||||||
|
|
||||||
|
// S4 — Vinculación familiar (4.1 generador; 4.2/4.3 instancia)
|
||||||
|
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
|
||||||
|
q({
|
||||||
|
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41,
|
||||||
|
instance_generator: true,
|
||||||
|
options: VINCULOS.map((v) => ({ value: v, label: v })),
|
||||||
|
}),
|
||||||
|
q({ code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1', conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '4.3', type: 'multiple_choice', prompt: 'Dispositivos familiar', position: 43, instance_of: '4.1', conditional_rules: r_hide_ning_sn }),
|
||||||
|
|
||||||
|
// S5 — Responsabilidad y cuidado
|
||||||
|
q({ code: 'S5', type: 'section', prompt: 'Responsabilidad y cuidado', position: 50, conditional_rules: r_hide_ning }),
|
||||||
|
q({ code: '5.1', type: 'multiple_choice', prompt: 'Rol', position: 51, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '5.2', type: 'multiple_choice', prompt: 'Momento', position: 52, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '5.3', type: 'multiple_choice', prompt: 'Quién cuida en horario laboral', position: 53, conditional_rules: r_hide_ning }),
|
||||||
|
q({ code: '5.4', type: 'single_choice', prompt: 'Desde cuándo', position: 54, conditional_rules: r_hide_ning_sn }),
|
||||||
|
|
||||||
|
// S6 — Intensidad del cuidado
|
||||||
|
q({ code: 'S6', type: 'section', prompt: 'Intensidad del cuidado', position: 60, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '6.1', type: 'single_choice', prompt: 'Días/semana', position: 61, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '6.2', type: 'single_choice', prompt: 'Horas/día', position: 62, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '6.3', type: 'multiple_choice', prompt: 'Momentos', position: 63, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '6.4', type: 'single_choice', prompt: 'Disponibilidad', position: 64, conditional_rules: r_hide_ning_sn }),
|
||||||
|
|
||||||
|
// S7 — Impacto en el trabajo
|
||||||
|
q({ code: 'S7', type: 'section', prompt: 'Impacto en el trabajo', position: 70, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.1', type: 'single_choice', prompt: 'Descanso', position: 71, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.2', type: 'single_choice', prompt: 'Concentración', position: 72, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.3', type: 'single_choice', prompt: 'Ausencias', position: 73, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.4', type: 'multiple_choice', prompt: 'Aspectos afectados', position: 74, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.5', type: 'multiple_choice', prompt: 'Consecuencias laborales', position: 75, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '7.6', type: 'single_choice', prompt: 'Probabilidad de dejar', position: 76, conditional_rules: r_hide_ning_sn }),
|
||||||
|
|
||||||
|
// S8 — Impacto económico
|
||||||
|
q({ code: 'S8', type: 'section', prompt: 'Impacto económico', position: 80, conditional_rules: r_hide_ning }),
|
||||||
|
q({ code: '8.1', type: 'single_choice', prompt: 'Gasto mensual', position: 81, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '8.2', type: 'multiple_choice', prompt: 'Conceptos', position: 82, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '8.3', type: 'multiple_choice', prompt: 'Cómo sostiene', position: 83, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '8.4', type: 'single_choice', prompt: 'Franja salarial', position: 84, is_sensitive: true, conditional_rules: r_hide_ning }),
|
||||||
|
|
||||||
|
// S9 — Conciencia organizacional
|
||||||
|
q({ code: 'S9', type: 'section', prompt: 'Conciencia organizacional', position: 90, conditional_rules: r_hide_ning }),
|
||||||
|
q({ code: '9.1', type: 'single_choice', prompt: 'Conciencia empresa', position: 91, conditional_rules: r_hide_ning }),
|
||||||
|
q({ code: '9.2', type: 'multiple_choice', prompt: 'Apoyos', position: 92, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '9.3', type: 'single_choice', prompt: 'Grupo de apoyo', position: 93, conditional_rules: r_hide_ning_sn }),
|
||||||
|
q({ code: '9.4', type: 'single_choice', prompt: 'Voluntariado', position: 94, conditional_rules: r_hide_ning }),
|
||||||
|
|
||||||
|
// S10 — Comentarios adicionales
|
||||||
|
q({ code: 'S10', type: 'section', prompt: 'Comentarios adicionales', position: 100 }),
|
||||||
|
q({ code: '10.1', type: 'text_long', prompt: 'Comentario', position: 101 }),
|
||||||
|
]
|
||||||
|
return buildFormSchema(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
function codesOf(screens: ReturnType<typeof resolveScreens>): string[] {
|
||||||
|
return screens.filter((s) => s.kind !== 'end').map((s) => s.question!.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Recorrido completo ────────────────────────────────────────────────
|
||||||
|
test('recorrido completo: todos los vínculos (2-7) muestran S5-S9 enteras + 12 pantallas de instancia', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const answers: FormAnswers = {
|
||||||
|
'4.1:default': VINCULOS.slice(1, 7), // opciones 2..7, sin soloNino ni Ninguna
|
||||||
|
}
|
||||||
|
const screens = resolveScreens(schema, answers)
|
||||||
|
const codes = codesOf(screens)
|
||||||
|
|
||||||
|
for (const expected of ['S5', '5.1', '5.2', '5.3', '5.4', 'S6', '6.1', '6.4', 'S7', '7.1', '7.6', 'S8', '8.1', '8.4', 'S9', '9.1', '9.4']) {
|
||||||
|
assert.ok(codes.includes(expected), `falta ${expected} en recorrido completo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceScreens = screens.filter((s) => s.instanceId !== null)
|
||||||
|
assert.equal(instanceScreens.length, 12, '6 familiares x 2 preguntas (4.2+4.3) = 12 pantallas')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. Solo niño (eq_sole) ───────────────────────────────────────────────
|
||||||
|
test('solo niño sin discapacidad (eq_sole): recorrido liviano 5.3, 8.4, 9.1, 9.4', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const answers: FormAnswers = { '4.1:default': [SOLO_NINO] }
|
||||||
|
const screens = resolveScreens(schema, answers)
|
||||||
|
const codes = codesOf(screens)
|
||||||
|
|
||||||
|
// visibles: S5(header)+5.3, S8(header)+8.4, S9(header)+9.1+9.4
|
||||||
|
for (const expected of ['S5', '5.3', 'S8', '8.4', 'S9', '9.1', '9.4']) {
|
||||||
|
assert.ok(codes.includes(expected), `falta ${expected} en soloNino`)
|
||||||
|
}
|
||||||
|
// ocultas
|
||||||
|
for (const hidden of ['5.1', '5.2', '5.4', 'S6', '6.1', 'S7', '7.1', '8.1', '8.2', '8.3', '9.2', '9.3']) {
|
||||||
|
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer en soloNino`)
|
||||||
|
}
|
||||||
|
// 4.2/4.3 no se muestran (la única instancia generada queda oculta por r_hide_ning_sn)
|
||||||
|
assert.equal(screens.filter((s) => s.instanceId !== null).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. Ninguna de las anteriores (contains) ──────────────────────────────
|
||||||
|
test('"Ninguna de las anteriores" (contains): salta directo a S10, incluso combinada con otra opción', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const answers: FormAnswers = { '4.1:default': [VINCULOS[2], NINGUNA] } // marcada junto con otra
|
||||||
|
const screens = resolveScreens(schema, answers)
|
||||||
|
const codes = codesOf(screens)
|
||||||
|
|
||||||
|
for (const hidden of ['S5', 'S6', 'S7', 'S8', '8.4', 'S9', '9.1', '9.4']) {
|
||||||
|
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer con Ninguna marcada`)
|
||||||
|
}
|
||||||
|
assert.ok(codes.includes('S10') && codes.includes('10.1'))
|
||||||
|
// el índice de S4 (4.1) debe ser inmediatamente anterior a S10 en la lista
|
||||||
|
const idxS4 = codes.indexOf('4.1')
|
||||||
|
const idxS10 = codes.indexOf('S10')
|
||||||
|
assert.equal(idxS10, idxS4 + 1, 'S10 debe seguir inmediatamente a 4.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. 3.1='No' oculta 3.2-3.5 ────────────────────────────────────────────
|
||||||
|
test("3.1='No' oculta 3.2-3.5; 3.1='Sí' las muestra", () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
|
||||||
|
const codesNo = codesOf(resolveScreens(schema, { '3.1:default': 'No' }))
|
||||||
|
for (const hidden of ['3.2', '3.3', '3.4', '3.5']) assert.ok(!codesNo.includes(hidden))
|
||||||
|
assert.ok(codesNo.includes('3.1'))
|
||||||
|
|
||||||
|
const codesSi = codesOf(resolveScreens(schema, { '3.1:default': 'Sí' }))
|
||||||
|
for (const shown of ['3.2', '3.3', '3.4', '3.5']) assert.ok(codesSi.includes(shown))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. 3 familiares → 6 pantallas de instancia en orden por familiar ─────
|
||||||
|
test('3 familiares (seleccionados fuera de orden) generan 6 pantallas en orden de esquema, no de selección', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
// Selección deliberadamente "fuera de orden" respecto a VINCULOS: opción 6, luego 2, luego 4.
|
||||||
|
const answers: FormAnswers = { '4.1:default': [VINCULOS[5], VINCULOS[1], VINCULOS[3]] }
|
||||||
|
const screens = resolveScreens(schema, answers)
|
||||||
|
const instanceScreens = screens.filter((s) => s.instanceId !== null)
|
||||||
|
|
||||||
|
assert.equal(instanceScreens.length, 6)
|
||||||
|
// Orden esperado por posición FIJA en options (índices 1, 3, 5 → familiar_2, familiar_4, familiar_6)
|
||||||
|
assert.deepEqual(
|
||||||
|
instanceScreens.map((s) => s.id),
|
||||||
|
[
|
||||||
|
'question:4.2:familiar_2', 'question:4.3:familiar_2',
|
||||||
|
'question:4.2:familiar_4', 'question:4.3:familiar_4',
|
||||||
|
'question:4.2:familiar_6', 'question:4.3:familiar_6',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('IDs de instancia son estables: agregar un vínculo nuevo no reindexa los existentes', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
const before = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[4]] })
|
||||||
|
.filter((s) => s.instanceId !== null)
|
||||||
|
.map((s) => s.id)
|
||||||
|
assert.deepEqual(before, [
|
||||||
|
'question:4.2:familiar_2', 'question:4.3:familiar_2',
|
||||||
|
'question:4.2:familiar_5', 'question:4.3:familiar_5',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Se agrega un vínculo ANTERIOR en el orden de opciones (índice 2) — los ids de
|
||||||
|
// familiar_2 y familiar_5 deben seguir siendo exactamente los mismos.
|
||||||
|
const after = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[2], VINCULOS[4]] })
|
||||||
|
.filter((s) => s.instanceId !== null)
|
||||||
|
.map((s) => s.id)
|
||||||
|
assert.deepEqual(after, [
|
||||||
|
'question:4.2:familiar_2', 'question:4.3:familiar_2',
|
||||||
|
'question:4.2:familiar_3', 'question:4.3:familiar_3',
|
||||||
|
'question:4.2:familiar_5', 'question:4.3:familiar_5',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 6. Navegación next/back coherente con instancias ─────────────────────
|
||||||
|
describe('navegación next/back', () => {
|
||||||
|
test('next/back recorren secuencialmente section→pregunta→instancias→fin', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
let state = initialState(schema)
|
||||||
|
assert.equal(state.currentScreenId, 'section:S3')
|
||||||
|
|
||||||
|
state = next(state, schema)
|
||||||
|
assert.equal(state.currentScreenId, 'question:3.1')
|
||||||
|
state = setAnswer(state, '3.1', 'No')
|
||||||
|
// 3.2-3.5 ocultas → next desde 3.1 debe saltar directo a S4
|
||||||
|
state = next(state, schema)
|
||||||
|
assert.equal(state.currentScreenId, 'section:S4')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('next/back son estables cuando cambian las instancias activas', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
let state = initialState(schema)
|
||||||
|
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[4]]) // familiar_2, familiar_5
|
||||||
|
state.currentScreenId = 'question:4.1'
|
||||||
|
|
||||||
|
// Avanza a la primera pregunta de instancia
|
||||||
|
state = next(state, schema)
|
||||||
|
assert.equal(state.currentScreenId, 'question:4.2:familiar_2')
|
||||||
|
state = next(state, schema)
|
||||||
|
assert.equal(state.currentScreenId, 'question:4.3:familiar_2')
|
||||||
|
|
||||||
|
// Mientras está parado en 4.3:familiar_2, el usuario (vía 4D) agrega un
|
||||||
|
// vínculo anterior en el esquema (índice 2) sin moverse de pantalla.
|
||||||
|
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[2], VINCULOS[4]])
|
||||||
|
|
||||||
|
// El ID actual sigue existiendo y sigue siendo válido — no se desincroniza.
|
||||||
|
const next1 = next(state, schema)
|
||||||
|
assert.equal(next1.currentScreenId, 'question:4.2:familiar_3', 'avanza al nuevo familiar_3 insertado en medio')
|
||||||
|
|
||||||
|
const back1 = back(state, schema)
|
||||||
|
assert.equal(back1.currentScreenId, 'question:4.2:familiar_2', 'retrocede a familiar_2, sin verse afectado por el nuevo familiar_3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('back() desde S10 tras "Ninguna" vuelve a 4.1, saltando las secciones ocultas', () => {
|
||||||
|
const schema = buildFixtureSchema()
|
||||||
|
let state = initialState(schema)
|
||||||
|
state = setAnswer(state, '4.1', [NINGUNA])
|
||||||
|
state.currentScreenId = 'section:S10'
|
||||||
|
|
||||||
|
state = back(state, schema)
|
||||||
|
assert.equal(state.currentScreenId, 'question:4.1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Operadores: cobertura unitaria directa (vocabulario completo) ───────
|
||||||
|
describe('evaluateRule — los 5 operadores', () => {
|
||||||
|
const answers: FormAnswers = {
|
||||||
|
'3.1:default': 'Sí',
|
||||||
|
'4.1:default': ['Hijo menor con discapacidad', 'Otro / Especificar'],
|
||||||
|
'2.6:default': 'Jefatura',
|
||||||
|
}
|
||||||
|
|
||||||
|
test('eq', () => {
|
||||||
|
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }, answers), true)
|
||||||
|
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'No', action: 'show' }, answers), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('eq_any', () => {
|
||||||
|
const rule: ConditionalRule = { if_question: ['3.1', '2.6'], op: 'eq_any', value: 'Jefatura', action: 'show' }
|
||||||
|
assert.equal(evaluateRule(rule, answers), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not_in', () => {
|
||||||
|
const rule: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Directivo', 'Gerencial'], action: 'hide' }
|
||||||
|
assert.equal(evaluateRule(rule, answers), true)
|
||||||
|
const rule2: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Jefatura'], action: 'hide' }
|
||||||
|
assert.equal(evaluateRule(rule2, answers), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('contains', () => {
|
||||||
|
const rule: ConditionalRule = { if_question: '4.1', op: 'contains', value: 'Otro / Especificar', action: 'hide' }
|
||||||
|
assert.equal(evaluateRule(rule, answers), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('eq_sole', () => {
|
||||||
|
const rule: ConditionalRule = { if_question: '4.1', op: 'eq_sole', value: 'Otro / Especificar', action: 'hide' }
|
||||||
|
assert.equal(evaluateRule(rule, answers), false) // hay 2 elementos, no es el único
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Semántica show/hide combinados ───────────────────────────────────────
|
||||||
|
describe('resolveVisibility — combinación show/hide', () => {
|
||||||
|
test('hide gana siempre sobre show, aunque show también coincida', () => {
|
||||||
|
const mixed: ConditionalRule[] = [
|
||||||
|
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
|
||||||
|
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
|
||||||
|
]
|
||||||
|
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: mixed })
|
||||||
|
const answers: FormAnswers = { '3.1:default': 'Sí', '4.1:default': [NINGUNA] }
|
||||||
|
assert.equal(resolveVisibility(question, answers), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sin reglas hide, show gana si coincide', () => {
|
||||||
|
const onlyShow: ConditionalRule[] = [{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }]
|
||||||
|
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: onlyShow })
|
||||||
|
assert.equal(resolveVisibility(question, { '3.1:default': 'Sí' }), true)
|
||||||
|
assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false)
|
||||||
|
})
|
||||||
|
})
|
||||||
197
lib/form-engine.ts
Normal file
197
lib/form-engine.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Motor de resolución de flujo v3 (ADR-005) — módulo puro, sin React ni DOM.
|
||||||
|
// Reemplaza conceptualmente a lib/conditional.ts (v2); el componente (4D) decide
|
||||||
|
// cuándo migrar. Entrada→salida determinista: dado un FormSchema y un FormAnswers,
|
||||||
|
// siempre resuelve la misma lista de pantallas.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnswerValue,
|
||||||
|
ConditionalRule,
|
||||||
|
FormAnswers,
|
||||||
|
FormSchema,
|
||||||
|
Question,
|
||||||
|
Screen,
|
||||||
|
WizardState,
|
||||||
|
} from './form-engine.types'
|
||||||
|
|
||||||
|
const DEFAULT_INSTANCE = 'default'
|
||||||
|
|
||||||
|
export function answerKey(code: string, instanceId: string = DEFAULT_INSTANCE): string {
|
||||||
|
return `${code}:${instanceId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswer(answers: FormAnswers, code: string, instanceId = DEFAULT_INSTANCE): AnswerValue {
|
||||||
|
const v = answers[answerKey(code, instanceId)]
|
||||||
|
return v === undefined ? null : v
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evalúa una regla individual. `if_question` siempre se resuelve contra
|
||||||
|
* instance_id='default': en el vocabulario v3 los disparadores (3.1, 4.1)
|
||||||
|
* nunca son preguntas de instancia. Si en el futuro una regla necesitara
|
||||||
|
* compararse contra la respuesta de la PROPIA instancia, este es el punto
|
||||||
|
* a extender (hoy no hace falta — no hay caso de uso en el seed v3).
|
||||||
|
*/
|
||||||
|
export function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
|
||||||
|
switch (rule.op) {
|
||||||
|
case 'eq':
|
||||||
|
return getAnswer(answers, rule.if_question) === rule.value
|
||||||
|
case 'eq_any':
|
||||||
|
return rule.if_question.some((code) => getAnswer(answers, code) === rule.value)
|
||||||
|
case 'not_in': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
if (typeof v !== 'string') return false
|
||||||
|
return !rule.value.includes(v)
|
||||||
|
}
|
||||||
|
case 'contains': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
return Array.isArray(v) && v.includes(rule.value)
|
||||||
|
}
|
||||||
|
case 'eq_sole': {
|
||||||
|
const v = getAnswer(answers, rule.if_question)
|
||||||
|
return Array.isArray(v) && v.length === 1 && v[0] === rule.value
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semántica de combinación show/hide (ver reporte en la conversación):
|
||||||
|
* 1. hide es veto absoluto — si CUALQUIER regla hide coincide, se oculta.
|
||||||
|
* 2. Si ninguna hide coincide y hay reglas show, se muestra solo si
|
||||||
|
* CUALQUIERA show coincide (OR); si hay show pero ninguna coincide, se oculta.
|
||||||
|
* 3. Si no hay ninguna regla show (solo hide, o array vacío/null), se
|
||||||
|
* muestra por defecto.
|
||||||
|
*/
|
||||||
|
export function resolveVisibility(question: Question, answers: FormAnswers): boolean {
|
||||||
|
const rules = question.conditional_rules
|
||||||
|
if (!rules || rules.length === 0) return true
|
||||||
|
|
||||||
|
const hideRules = rules.filter((r) => r.action === 'hide')
|
||||||
|
const showRules = rules.filter((r) => r.action === 'show')
|
||||||
|
|
||||||
|
if (hideRules.some((r) => evaluateRule(r, answers))) return false
|
||||||
|
if (showRules.length > 0) return showRules.some((r) => evaluateRule(r, answers))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceRef {
|
||||||
|
instanceId: string
|
||||||
|
optionValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deriva las instancias activas desde la respuesta del generador.
|
||||||
|
* El orden e id de cada instancia se basan en la POSICIÓN FIJA de la opción
|
||||||
|
* dentro de `generator.options` (definida por el esquema), nunca en el orden
|
||||||
|
* de selección del usuario ni en un índice del array de respuesta — así una
|
||||||
|
* instancia existente conserva su id estable aunque se agregue o quite otro
|
||||||
|
* vínculo (lección del mockup: el índice se desincroniza con las instancias).
|
||||||
|
*/
|
||||||
|
export function expandInstances(generator: Question, answers: FormAnswers): InstanceRef[] {
|
||||||
|
const selected = getAnswer(answers, generator.code)
|
||||||
|
if (!Array.isArray(selected) || !generator.options) return []
|
||||||
|
const refs: InstanceRef[] = []
|
||||||
|
generator.options.forEach((opt, idx) => {
|
||||||
|
if (selected.includes(opt.value)) {
|
||||||
|
refs.push({ instanceId: `familiar_${idx + 1}`, optionValue: opt.value })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFormSchema(rows: Question[]): FormSchema {
|
||||||
|
return { questions: [...rows].sort((a, b) => a.position - b.position) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve la lista ordenada de pantallas visibles dado el esquema y el
|
||||||
|
* estado de respuestas actual. Pura: mismo input → mismo output siempre.
|
||||||
|
* Las preguntas instance_of se expanden agrupadas POR INSTANCIA (no por
|
||||||
|
* pregunta), para reproducir el orden familiar1: 4.2→4.3, familiar2: 4.2→4.3...
|
||||||
|
*/
|
||||||
|
export function resolveScreens(schema: FormSchema, answers: FormAnswers): Screen[] {
|
||||||
|
const screens: Screen[] = []
|
||||||
|
const consumedGroups = new Set<string>()
|
||||||
|
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
|
||||||
|
|
||||||
|
for (const q of schema.questions) {
|
||||||
|
if (q.instance_of) {
|
||||||
|
if (consumedGroups.has(q.instance_of)) continue
|
||||||
|
consumedGroups.add(q.instance_of)
|
||||||
|
|
||||||
|
const generator = byCode.get(q.instance_of)
|
||||||
|
if (!generator) continue
|
||||||
|
|
||||||
|
const instances = expandInstances(generator, answers)
|
||||||
|
const groupRows = schema.questions
|
||||||
|
.filter((r) => r.instance_of === q.instance_of)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
for (const row of groupRows) {
|
||||||
|
if (resolveVisibility(row, answers)) {
|
||||||
|
screens.push({
|
||||||
|
id: `question:${row.code}:${instance.instanceId}`,
|
||||||
|
kind: 'question',
|
||||||
|
question: row,
|
||||||
|
instanceId: instance.instanceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.type === 'section') {
|
||||||
|
if (resolveVisibility(q, answers)) {
|
||||||
|
screens.push({ id: `section:${q.code}`, kind: 'section', question: q, instanceId: null })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveVisibility(q, answers)) {
|
||||||
|
screens.push({ id: `question:${q.code}`, kind: 'question', question: q, instanceId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screens.push({ id: 'end', kind: 'end', question: null, instanceId: null })
|
||||||
|
return screens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialState(schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, {})
|
||||||
|
return { answers: {}, currentScreenId: screens[0]?.id ?? 'end' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAnswer(
|
||||||
|
state: WizardState,
|
||||||
|
code: string,
|
||||||
|
value: AnswerValue,
|
||||||
|
instanceId: string = DEFAULT_INSTANCE
|
||||||
|
): WizardState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
answers: { ...state.answers, [answerKey(code, instanceId)]: value },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
|
||||||
|
* respuestas actuales y busca la pantalla actual por ID (nunca por índice
|
||||||
|
* guardado de antes) — así, si el cambio de respuesta ocultó/agregó
|
||||||
|
* pantallas, next() sigue aterrizando en la pantalla correcta.
|
||||||
|
*/
|
||||||
|
export function next(state: WizardState, schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, state.answers)
|
||||||
|
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||||
|
const nextIdx = Math.max(Math.min(idx + 1, screens.length - 1), 0)
|
||||||
|
return { ...state, currentScreenId: screens[nextIdx].id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function back(state: WizardState, schema: FormSchema): WizardState {
|
||||||
|
const screens = resolveScreens(schema, state.answers)
|
||||||
|
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||||
|
const prevIdx = idx <= 0 ? 0 : idx - 1
|
||||||
|
return { ...state, currentScreenId: screens[prevIdx]?.id ?? state.currentScreenId }
|
||||||
|
}
|
||||||
72
lib/form-engine.types.ts
Normal file
72
lib/form-engine.types.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Tipos del motor de resolución de flujo v3 (ADR-005).
|
||||||
|
// Módulo aislado de lib/types.ts (v2) a propósito — el componente de
|
||||||
|
// producción (survey-form.tsx) no importa nada de aquí todavía. 4D decide
|
||||||
|
// cómo conviven o se unifican los dos juegos de tipos.
|
||||||
|
|
||||||
|
export type ConditionalAction = 'show' | 'hide'
|
||||||
|
|
||||||
|
export type ConditionalRule =
|
||||||
|
| { if_question: string; op: 'eq'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string[]; op: 'eq_any'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'not_in'; value: string[]; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'contains'; value: string; action: ConditionalAction }
|
||||||
|
| { if_question: string; op: 'eq_sole'; value: string; action: ConditionalAction }
|
||||||
|
|
||||||
|
export type QuestionType =
|
||||||
|
| 'text_short' | 'text_long' | 'single_choice' | 'multiple_choice'
|
||||||
|
| 'rating' | 'nps' | 'matrix' | 'ranking' | 'date' | 'slider' | 'yes_no' | 'section'
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
type: QuestionType
|
||||||
|
prompt: string
|
||||||
|
position: number
|
||||||
|
is_sensitive: boolean
|
||||||
|
required: boolean
|
||||||
|
hint: string | null
|
||||||
|
options: QuestionOption[] | null
|
||||||
|
max_select: number | null
|
||||||
|
conditional_rules: ConditionalRule[] | null
|
||||||
|
/** true solo en la pregunta que genera instancias (4.1 en v3). */
|
||||||
|
instance_generator: boolean
|
||||||
|
/** code de la pregunta generadora de la que depende esta fila (4.2/4.3 → '4.1'); null si no aplica. */
|
||||||
|
instance_of: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSchema {
|
||||||
|
/** Filas de `questions` ordenadas por position; incluye type='section'. */
|
||||||
|
questions: Question[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valor de una respuesta: string (single_choice/text/yes_no) o array (multiple_choice). */
|
||||||
|
export type AnswerValue = string | string[] | number | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Respuestas keyed por `${code}:${instance_id}`. Las preguntas normales usan
|
||||||
|
* instance_id='default'; las preguntas de instancia (instance_of) usan el id
|
||||||
|
* de su instancia (ej. 'familiar_1'), igual que `answers.instance_id` (Etapa 4A).
|
||||||
|
*/
|
||||||
|
export type FormAnswers = Record<string, AnswerValue>
|
||||||
|
|
||||||
|
export type ScreenKind = 'section' | 'question' | 'end'
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
/** ID estable. Nunca usar el índice de este array para identificar la pantalla actual. */
|
||||||
|
id: string
|
||||||
|
kind: ScreenKind
|
||||||
|
/** null solo para kind='end'. */
|
||||||
|
question: Question | null
|
||||||
|
/** id de instancia si la pantalla es una pregunta de instancia; null si no aplica. */
|
||||||
|
instanceId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
answers: FormAnswers
|
||||||
|
currentScreenId: string
|
||||||
|
}
|
||||||
143
sprints/sprint-4/BRIEF.md
Normal file
143
sprints/sprint-4/BRIEF.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# BRIEF — Sprint 4: Formulario Wizard v3 (esquema declarativo)
|
||||||
|
|
||||||
|
> Versión objetivo: **3.0.0** · Rama: `main` · Preservación: `v2.1.0-stable`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo en una frase
|
||||||
|
Construir el formulario de cuidadores como un wizard pregunta-por-pantalla cuyo
|
||||||
|
flujo, instancias y condiciones se definen en un **esquema declarativo** (datos),
|
||||||
|
renderizado por un frontend sin lógica de negocio — funcionando para los dos
|
||||||
|
clientes inminentes, y en la dirección arquitectónica del motor de formularios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisión arquitectónica rectora (ADR pendiente de registrar)
|
||||||
|
|
||||||
|
**El motor de formularios es el producto; la encuesta de cuidadores es su primer
|
||||||
|
caso de uso.** Por lo tanto, la lógica del flujo del formulario vive en una capa
|
||||||
|
de **definición declarativa** (datos), no en el presentador ni dispersa en columnas
|
||||||
|
sueltas de la base.
|
||||||
|
|
||||||
|
### Estrategia en dos fases (deuda intencional, registrada — no oculta)
|
||||||
|
|
||||||
|
**Fase 1 (este sprint, para el deadline de 10-20 días):**
|
||||||
|
Se construye el formulario de cuidadores con su flujo definido como esquema
|
||||||
|
declarativo persistido. El frontend es un renderer de ese esquema — sin reglas
|
||||||
|
hardcodeadas. La resolución de "qué pantalla sigue" se calcula desde esquema +
|
||||||
|
estado. NO se construye todavía el intérprete genérico universal.
|
||||||
|
|
||||||
|
**Fase 2 (sprints posteriores, post-deadline):**
|
||||||
|
Se extrae el intérprete a un motor genérico de formularios. Como el formulario de
|
||||||
|
cuidadores ya está definido como esquema declarativo, se vuelve el primer caso de
|
||||||
|
prueba del motor sin reescribir el contrato. Registrado en `BACKLOG.md`.
|
||||||
|
|
||||||
|
**Por qué esta estrategia:** entrega valor en plazo sin generar retrabajo. La
|
||||||
|
lógica en datos (no en presentación) es lo que habilita guardado parcial,
|
||||||
|
reanudación, validación consistente y progreso preciso — la mejor UX — y lo que
|
||||||
|
hace que la Fase 2 sea generalización, no reescritura.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alcance (qué entra)
|
||||||
|
- Esquema declarativo del formulario de cuidadores (flujo, instancias, condiciones como datos)
|
||||||
|
- Persistencia del esquema (en seed/DB, versionado)
|
||||||
|
- `instance_id` en `answers` (Etapa 4A — ya aplicada)
|
||||||
|
- Motor de resolución de flujo para este formulario (interpreta el esquema)
|
||||||
|
- Frontend wizard como renderer del esquema (sin reglas de negocio)
|
||||||
|
- Mini-wizard de vínculos familiares (instancias declaradas en el esquema)
|
||||||
|
- Autocomplete de barrios desde dataset INE/DGEEC
|
||||||
|
- Subtítulos del "porqué" por sección
|
||||||
|
- Submit con `instance_id` por answer
|
||||||
|
- Barra de progreso dinámica (calculada desde el esquema + estado)
|
||||||
|
- RPCs para preguntas de vínculo con instancias
|
||||||
|
- Seed v3 limpio (sin migración de datos v2)
|
||||||
|
|
||||||
|
## Fuera de alcance (explícito)
|
||||||
|
- ❌ Motor genérico universal de formularios (Fase 2 — BACKLOG)
|
||||||
|
- ❌ Backend de construcción/edición de formularios (sprint posterior)
|
||||||
|
- ❌ Migración de datos v2.1 → v3 (se decide cuando v3 esté estable)
|
||||||
|
- ❌ Tocar la rama `v2.1.0-stable`
|
||||||
|
- ❌ RE Index, informe descargable, auth de /admin/responses, backup S3
|
||||||
|
|
||||||
|
## Decisiones ya tomadas (no renegociar)
|
||||||
|
1. v3 en `main`; v2 preservado en `v2.1.0-stable` + tag `v2.1.0`.
|
||||||
|
2. Lógica de flujo en esquema declarativo (datos), NO en el frontend.
|
||||||
|
3. Frontend wizard = renderer sin reglas de negocio.
|
||||||
|
4. Estrategia dos fases: formulario-como-esquema ahora, motor genérico después.
|
||||||
|
5. v3 arranca con seed limpio, sin datos migrados.
|
||||||
|
6. `instance_id` ya implementado (4A). Instancias declaradas en el esquema.
|
||||||
|
7. Wizard pregunta-por-pantalla para todo el formulario tras consentimiento.
|
||||||
|
8. Mini-wizard de familiares: una pregunta por pantalla, badge "Familiar X de N".
|
||||||
|
9. Numeración desde Sección 1.
|
||||||
|
10. Barrio: autocomplete con datos INE, texto libre para el resto.
|
||||||
|
11. Texto de consentimiento: el del Excel del instrumento v3.
|
||||||
|
12. SemVer + CHANGELOG + tags en cada versión estable.
|
||||||
|
13. Migraciones se aplican vía CLI del VPS (`docker exec -i supabase-db psql`).
|
||||||
|
14. **Motor de flujo (4C): corre en el cliente** como módulo puro y aislado (entrada→salida
|
||||||
|
determinista, testeable con Node). Interpreta el esquema declarativo; no embebe lógica
|
||||||
|
en el componente React.
|
||||||
|
15. **Validación de integridad server-side en el submit (4G): REQUISITO NO NEGOCIABLE.**
|
||||||
|
El servidor valida contra el esquema que: las preguntas existen, los valores son opciones
|
||||||
|
válidas, las instancias corresponden a un generador real, y no se inyectan respuestas a
|
||||||
|
preguntas que el flujo nunca debió mostrar. La validación de cliente es UX, NO seguridad.
|
||||||
|
Para datos sensibles de salud, confiar solo en validación de cliente sería una falla de
|
||||||
|
seguridad. Esto NO se difiere a Fase 2.
|
||||||
|
|
||||||
|
## Criterios de éxito (checklist)
|
||||||
|
- [ ] El flujo del formulario está definido como esquema declarativo persistido
|
||||||
|
- [ ] El frontend no contiene reglas de negocio del flujo (verificable por revisión)
|
||||||
|
- [ ] El wizard funciona end-to-end, una pregunta por pantalla
|
||||||
|
- [ ] El mini-wizard de familiares genera instancias según el esquema
|
||||||
|
- [ ] Los datos se guardan con `instance_id` correcto por familiar
|
||||||
|
- [ ] El dashboard sigue funcionando (preguntas no-instancia con `instance_id='default'`)
|
||||||
|
- [ ] El autocomplete de barrios funciona para ciudades principales
|
||||||
|
- [ ] La validación visual coincide con el mockup aprobado
|
||||||
|
- [ ] `v2.1.0-stable` permanece intacta
|
||||||
|
- [ ] La deuda de Fase 2 está registrada en BACKLOG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Etapas del Sprint 4
|
||||||
|
|
||||||
|
| Etapa | Nombre | Entregable | Estado |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **4.0** | Preservación v2 | Tag, rama stable, dump, CHANGELOG | ✅ Completada |
|
||||||
|
| **4A** | Persistencia instance_id | Migración + DATA_MODEL | ✅ Completada |
|
||||||
|
| **4B** | Esquema declarativo + seed v3 | Esquema del formulario como datos + geo JSON | — |
|
||||||
|
| **4C** | Motor de resolución de flujo | Intérprete del esquema (este formulario) | 4B |
|
||||||
|
| **4D** | Frontend renderer | Wizard que renderiza el esquema, una preg/pantalla | 4C |
|
||||||
|
| **4E** | Instancias de vínculo | Mini-wizard de familiares desde el esquema | 4D |
|
||||||
|
| **4F** | Autocomplete barrios | Campo barrio con dataset INE | 4D |
|
||||||
|
| **4G** | Submit con instance_id | Guardado multi-instancia | 4E |
|
||||||
|
| **4H** | RPCs de vínculo | Agregados por instancia en dashboard | 4G |
|
||||||
|
| **4I** | Validación e2e + tag v3.0.0 | QA visual + release + ADR + BACKLOG Fase 2 | todas |
|
||||||
|
|
||||||
|
## Orden de ejecución
|
||||||
|
4B → 4C → 4D → (4E ∥ 4F) → 4G → 4H → 4I
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definición del esquema declarativo (guía para 4B)
|
||||||
|
|
||||||
|
El esquema describe el formulario como datos. Estructura conceptual:
|
||||||
|
|
||||||
|
```
|
||||||
|
formulario:
|
||||||
|
version, secciones[]
|
||||||
|
seccion:
|
||||||
|
numero, nombre, descripcion (el "porqué")
|
||||||
|
preguntas[]
|
||||||
|
pregunta:
|
||||||
|
codigo, tipo, enunciado, hint, opciones[], required, sensitive
|
||||||
|
flujo:
|
||||||
|
condiciones_visibilidad[] (cuándo se muestra — evaluado por el motor)
|
||||||
|
genera_instancias? (si esta pregunta crea instancias, ej. 4.1)
|
||||||
|
pertenece_a_instancia_de? (de qué generador depende, ej. 4.2/4.3 → 4.1)
|
||||||
|
validaciones[]
|
||||||
|
```
|
||||||
|
|
||||||
|
La forma técnica exacta de persistir esto (columnas en `questions` +
|
||||||
|
`conditional_rules` JSONB extendido, o tabla de definición de flujo separada)
|
||||||
|
la decide la Etapa 4B según el esquema actual. Principio: declarativo, versionado,
|
||||||
|
interpretable por el motor sin conocer el caso de uso.
|
||||||
104
sprints/sprint-4/ETAPA_4A_PROMPT.md
Normal file
104
sprints/sprint-4/ETAPA_4A_PROMPT.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# ETAPA 4A — Modelo de datos: instance_id en answers
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
Agregar la columna `instance_id` a la tabla `answers` y cambiar la constraint
|
||||||
|
única para soportar múltiples respuestas a la misma pregunta (una por instancia
|
||||||
|
de vínculo familiar). Es el cambio de base que habilita todo el wizard v3.
|
||||||
|
|
||||||
|
## 2. Contexto previo
|
||||||
|
- v3 se desarrolla en `main`. v2 está preservado en `v2.1.0-stable`.
|
||||||
|
- La tabla `answers` actual tiene constraint `UNIQUE (response_id, question_id)`.
|
||||||
|
- El wizard v3 genera múltiples respuestas para las preguntas 4.2 y 4.3 (una por
|
||||||
|
cada familiar declarado en 4.1). Sin `instance_id`, esas respuestas colisionan.
|
||||||
|
- Las preguntas no-vinculadas (la gran mayoría) usan `instance_id = 'default'`.
|
||||||
|
|
||||||
|
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||||
|
- Nombre de la columna: `instance_id`, tipo `text`, `DEFAULT 'default'`, NOT NULL.
|
||||||
|
- Nueva constraint: `UNIQUE (response_id, question_id, instance_id)`.
|
||||||
|
- Para preguntas normales: `instance_id = 'default'`.
|
||||||
|
- Para instancias de vínculo: `instance_id = 'familiar_1'`, `'familiar_2'`, etc.
|
||||||
|
- Esto es una migración nueva, NO modifica migraciones existentes.
|
||||||
|
- NO se migran datos v2 — el seed v3 arranca limpio (etapa posterior).
|
||||||
|
|
||||||
|
## 4. Tareas
|
||||||
|
|
||||||
|
### 4.1 Nueva migración
|
||||||
|
Crear `supabase/migrations/20260620000001_add_instance_id.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Sprint 4 (v3.0.0) — instance_id para respuestas multi-instancia
|
||||||
|
-- Habilita el mini-wizard de vínculos familiares (una respuesta por familiar)
|
||||||
|
|
||||||
|
-- 1. Agregar la columna con default para filas existentes
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_id text NOT NULL DEFAULT 'default';
|
||||||
|
|
||||||
|
-- 2. Eliminar la constraint única vieja (nombre real verificado en DB)
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
DROP CONSTRAINT IF EXISTS answers_response_id_question_id_key;
|
||||||
|
|
||||||
|
-- 3. Crear la nueva constraint que incluye instance_id
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
ADD CONSTRAINT answers_response_question_instance_key
|
||||||
|
UNIQUE (response_id, question_id, instance_id);
|
||||||
|
|
||||||
|
-- 4. Índice para consultas por instancia (análisis de vínculos)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_answers_instance
|
||||||
|
ON public.answers (question_id, instance_id)
|
||||||
|
WHERE instance_id != 'default';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Verificar el nombre real de la constraint actual
|
||||||
|
ANTES de escribir el DROP, verificar el nombre exacto de la constraint única
|
||||||
|
actual de `answers`. El nombre asumido es `answers_response_id_question_id_key`
|
||||||
|
(convención Postgres), pero confirmar con:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT conname FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.answers'::regclass AND contype = 'u';
|
||||||
|
```
|
||||||
|
|
||||||
|
Si el nombre difiere, ajustar el `DROP CONSTRAINT` en la migración.
|
||||||
|
NO ejecutar la migración en producción — solo crear el archivo.
|
||||||
|
|
||||||
|
### 4.3 Actualizar DATA_MODEL.md
|
||||||
|
Documentar el cambio en `DATA_MODEL.md`: la tabla `answers` ahora tiene
|
||||||
|
`instance_id` y la nueva constraint. Explicar el patrón 'default' vs 'familiar_N'.
|
||||||
|
|
||||||
|
## 5. Entregables (DoD)
|
||||||
|
- [ ] Migración `20260620000001_add_instance_id.sql` creada.
|
||||||
|
- [ ] Nombre real de la constraint actual verificado y usado en el DROP.
|
||||||
|
- [ ] `DATA_MODEL.md` actualizado con el nuevo esquema de `answers`.
|
||||||
|
- [ ] La migración es idempotente (`IF EXISTS` / `IF NOT EXISTS`).
|
||||||
|
- [ ] NO ejecutada en producción.
|
||||||
|
|
||||||
|
## 6. Qué reportar
|
||||||
|
```
|
||||||
|
Verificado:
|
||||||
|
- Migración creada ✓
|
||||||
|
- Nombre real de constraint actual: [nombre]
|
||||||
|
- DATA_MODEL.md actualizado ✓
|
||||||
|
- Idempotencia confirmada ✓
|
||||||
|
|
||||||
|
Asumido:
|
||||||
|
- [supuestos sobre el esquema]
|
||||||
|
|
||||||
|
NO ejecutado en producción (pendiente aprobación del director).
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Qué NO hacer
|
||||||
|
- ❌ No ejecutar la migración en la DB de producción.
|
||||||
|
- ❌ No modificar migraciones existentes.
|
||||||
|
- ❌ No tocar el seed todavía (es la etapa 4B).
|
||||||
|
- ❌ No migrar datos de v2.
|
||||||
|
- ❌ No tocar la rama v2.1.0-stable.
|
||||||
|
|
||||||
|
## 8. Versionado
|
||||||
|
Commit sugerido al cerrar:
|
||||||
|
```
|
||||||
|
feat(data): agregar instance_id a answers para multi-instancia [4A]
|
||||||
|
|
||||||
|
Habilita respuestas múltiples por pregunta (mini-wizard de vínculos).
|
||||||
|
Nueva constraint UNIQUE(response_id, question_id, instance_id).
|
||||||
|
Parte del Sprint 4 hacia v3.0.0.
|
||||||
|
```
|
||||||
111
sprints/sprint-4/ETAPA_4B_PROMPT.md
Normal file
111
sprints/sprint-4/ETAPA_4B_PROMPT.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ETAPA 4B — Esquema declarativo del formulario v3 + seed
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
Definir el formulario de cuidadores v3 como un esquema declarativo persistido:
|
||||||
|
extender el modelo de `questions` para soportar instanciación y nuevos operadores
|
||||||
|
de flujo, y cargar el instrumento completo (10 secciones) en el seed. La lógica del
|
||||||
|
flujo vive en estos datos, no en el frontend (ver ADR-005).
|
||||||
|
|
||||||
|
## 2. Contexto previo
|
||||||
|
- ADR-005 define la arquitectura: esquema declarativo, frontend renderer sin reglas.
|
||||||
|
- `instance_id` ya existe en `answers` (Etapa 4A).
|
||||||
|
- El esquema actual de `questions` ya tiene `conditional_rules` JSONB con operadores
|
||||||
|
`eq` / `eq_any` y acción `show`, auto-target implícito.
|
||||||
|
- El contenido exacto del instrumento v3 está validado en el mockup aprobado
|
||||||
|
(`wizard_formulario_mapeo_v3.html`) y en el Excel `Formulario_Mapeo_Corregido.xlsx`.
|
||||||
|
- El dataset de barrios está en `geo_paraguay_full.json` (va a `public/data/`).
|
||||||
|
|
||||||
|
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||||
|
- Se EXTIENDE el modelo existente, no se crea tabla de definición separada (ADR-005).
|
||||||
|
- Nuevos campos declarativos en `questions`:
|
||||||
|
- `instance_generator boolean DEFAULT false` — marca la pregunta que genera instancias (4.1)
|
||||||
|
- `instance_of text DEFAULT NULL` — código del generador del que depende (4.2/4.3 → '4.1')
|
||||||
|
- Nuevos operadores en `conditional_rules`: agregar acción `hide` y operador para
|
||||||
|
expresar el salto "Ninguna de las anteriores → salta a sección final".
|
||||||
|
- Secciones con nombre y descripción (el "porqué"): representación a definir por Claude Code
|
||||||
|
según lo más limpio (tabla `sections` o registros con metadata) — proponer antes de ejecutar.
|
||||||
|
- v3 arranca con seed LIMPIO, sin datos migrados.
|
||||||
|
- El contenido de preguntas es EXACTAMENTE el del mockup validado.
|
||||||
|
|
||||||
|
## 4. Tareas
|
||||||
|
|
||||||
|
### 4.1 Migración de esquema declarativo
|
||||||
|
Crear `supabase/migrations/20260620000002_form_schema_v3.sql`:
|
||||||
|
- Agregar `instance_generator boolean NOT NULL DEFAULT false` a `questions`.
|
||||||
|
- Agregar `instance_of text` a `questions` (nullable).
|
||||||
|
- Documentar (comentario SQL) el vocabulario extendido de `conditional_rules`:
|
||||||
|
operadores `eq`, `eq_any`, `not_in`; acciones `show`, `hide`.
|
||||||
|
- Idempotente (`IF NOT EXISTS`).
|
||||||
|
|
||||||
|
### 4.2 Representación de secciones
|
||||||
|
Proponer e implementar cómo persistir las 10 secciones con su nombre y descripción
|
||||||
|
("Texto de ayuda" del Excel). Reportar la propuesta antes de ejecutar si implica
|
||||||
|
cambio de esquema. Las descripciones de sección están en el BRIEF y el mockup.
|
||||||
|
|
||||||
|
### 4.3 Seed v3 del instrumento
|
||||||
|
Reescribir el seed del survey (o crear seed v3 separado) con las 10 secciones y todas
|
||||||
|
las preguntas EXACTAMENTE como el mockup validado:
|
||||||
|
- Sección 1: Consentimiento (2 checkboxes: requerido + opcional)
|
||||||
|
- Sección 2: Datos generales (2.1-2.7)
|
||||||
|
- Sección 3: Prevalencia discapacidad propia (3.1-3.5; 3.1='No' oculta 3.2-3.5)
|
||||||
|
- Sección 4: Vinculación familiar (4.1 generador de instancias; 4.2 y 4.3 instance_of='4.1')
|
||||||
|
- Sección 5: Responsabilidad (5.1-5.4; ocultas si "solo niño sin discapacidad")
|
||||||
|
- Sección 6: Intensidad (6.1-6.4; idem)
|
||||||
|
- Sección 7: Impacto laboral (7.1-7.6; idem)
|
||||||
|
- Sección 8: Impacto económico (8.1-8.4)
|
||||||
|
- Sección 9: Conciencia organizacional (9.1-9.4)
|
||||||
|
- Sección 10: Comentarios (10.1)
|
||||||
|
- Flujo "Ninguna de las anteriores" en 4.1 → salta a Sección 10.
|
||||||
|
- Todas las opciones, hints ("Podés marcar más de una opción"), y flags is_sensitive
|
||||||
|
exactamente como el mockup.
|
||||||
|
|
||||||
|
### 4.4 Dataset geográfico
|
||||||
|
Copiar `geo_paraguay_full.json` a `public/data/geo_paraguay.json` del repo.
|
||||||
|
|
||||||
|
### 4.5 Actualizar SEED_SPEC.md
|
||||||
|
Documentar el instrumento v3 completo como contrato (Principio 4 / AP.11).
|
||||||
|
|
||||||
|
## 5. Entregables (DoD)
|
||||||
|
- [ ] Migración de esquema declarativo creada (campos de instanciación).
|
||||||
|
- [ ] Propuesta de representación de secciones reportada (y aplicada si se aprueba).
|
||||||
|
- [ ] Seed v3 con las 10 secciones, contenido exacto del mockup.
|
||||||
|
- [ ] `instance_generator` y `instance_of` correctamente seteados en 4.1/4.2/4.3.
|
||||||
|
- [ ] Flujo declarativo: 3.1='No' oculta 3.2-3.5; "Ninguno" en 4.1 salta a S10;
|
||||||
|
"solo niño" oculta S5-S7.
|
||||||
|
- [ ] `geo_paraguay.json` en `public/data/`.
|
||||||
|
- [ ] `SEED_SPEC.md` actualizado.
|
||||||
|
- [ ] NO ejecutado en producción (solo archivos).
|
||||||
|
|
||||||
|
## 6. Qué reportar
|
||||||
|
```
|
||||||
|
Verificado:
|
||||||
|
- Migración de esquema creada ✓
|
||||||
|
- Representación de secciones: [propuesta]
|
||||||
|
- Seed v3: N preguntas, 10 secciones ✓
|
||||||
|
- instance_generator/instance_of correctos ✓
|
||||||
|
- Reglas de flujo declarativas: [listar las 3 reglas principales]
|
||||||
|
- geo JSON en public/data ✓
|
||||||
|
- SEED_SPEC.md actualizado ✓
|
||||||
|
|
||||||
|
Asumido:
|
||||||
|
- [supuestos sobre representación de secciones y operadores]
|
||||||
|
|
||||||
|
NO ejecutado en producción.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Qué NO hacer
|
||||||
|
- ❌ No ejecutar en producción (lo aplica el director vía CLI del VPS).
|
||||||
|
- ❌ No poner lógica de flujo en el frontend (es la etapa 4D y será renderer).
|
||||||
|
- ❌ No migrar datos v2.
|
||||||
|
- ❌ No tocar v2.1.0-stable.
|
||||||
|
- ❌ No construir el motor genérico (Fase 2).
|
||||||
|
|
||||||
|
## 8. Versionado
|
||||||
|
Commit sugerido:
|
||||||
|
```
|
||||||
|
feat(schema): esquema declarativo + seed v3 del formulario [4B]
|
||||||
|
|
||||||
|
Extiende questions con instance_generator/instance_of.
|
||||||
|
Instrumento v3 completo (10 secciones) como datos declarativos.
|
||||||
|
Flujo del formulario en el esquema, no en presentación (ADR-005).
|
||||||
|
```
|
||||||
124
sprints/sprint-4/ETAPA_4C_PROMPT.md
Normal file
124
sprints/sprint-4/ETAPA_4C_PROMPT.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# ETAPA 4C — Motor de resolución de flujo (módulo puro, cliente)
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
Construir el motor que interpreta el esquema declarativo del formulario y resuelve
|
||||||
|
la secuencia de pantallas según el estado de respuestas. Es un módulo puro y aislado
|
||||||
|
en `lib/`, sin dependencias de React ni del DOM — entrada→salida determinista,
|
||||||
|
testeable con Node. El componente (Etapa 4D) lo consume; el motor no conoce al componente.
|
||||||
|
|
||||||
|
## 2. Contexto previo
|
||||||
|
- ADR-005: la lógica de flujo vive en el esquema (datos). El motor la interpreta.
|
||||||
|
- El esquema v3 está en la DB: questions con `type`, `conditional_rules`,
|
||||||
|
`instance_generator`, `instance_of`, `hint`, más filas `type='section'`.
|
||||||
|
- Decisión confirmada: el motor corre en el CLIENTE (interpretación de flujo, no validación
|
||||||
|
de seguridad — esa va server-side en 4G).
|
||||||
|
- Decisión confirmada: las secciones (`type='section'`) son PANTALLAS propias en el grafo.
|
||||||
|
- La lógica de v2 (`lib/conditional.ts`, `survey-form.tsx`) es INCOMPATIBLE y se reemplaza:
|
||||||
|
- v2 evalúa AND (`rules.every`); v3 necesita OR entre condiciones de ocultamiento.
|
||||||
|
- v2 solo conoce `eq`/`eq_any` y acción `show`; v3 necesita `contains`, `eq_sole`, `not_in`, acción `hide`.
|
||||||
|
- v2 infiere secciones por regex de prefijo; v3 las tiene como datos.
|
||||||
|
- El comportamiento esperado está validado en el mockup `wizard_formulario_mapeo_v3.html`
|
||||||
|
(función `buildVisible` y navegación) — usar como referencia de comportamiento, NO de arquitectura.
|
||||||
|
|
||||||
|
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||||
|
- Motor puro en `lib/` (proponé el nombre: ej. `lib/form-engine.ts`), sin React/DOM.
|
||||||
|
- Vocabulario de operadores: `eq`, `eq_any`, `not_in` (escalares), `contains`, `eq_sole` (array).
|
||||||
|
- Acciones: `show`, `hide`.
|
||||||
|
- Combinación: múltiples reglas en el array de una pregunta se evalúan en OR.
|
||||||
|
(Una pregunta se oculta si CUALQUIER regla `hide` aplica; se muestra si CUALQUIER regla `show` aplica.
|
||||||
|
Definí la semántica precisa de show+hide combinados y documentala.)
|
||||||
|
- Secciones = pantallas propias en el grafo.
|
||||||
|
- Instancias: 4.1 (`instance_generator=true`) genera N instancias; las preguntas con
|
||||||
|
`instance_of='4.1'` se repiten una vez por instancia activa, en orden por familiar
|
||||||
|
(familiar1: 4.2→4.3, familiar2: 4.2→4.3, ...) como en el mockup.
|
||||||
|
- Estado de respuestas con clave compuesta `question_code:instance_id` (instancias) o
|
||||||
|
`question_code` con `instance_id='default'` (normales).
|
||||||
|
|
||||||
|
## 4. Tareas
|
||||||
|
|
||||||
|
### 4.1 Tipos del motor (`lib/types.ts` ampliado o nuevo)
|
||||||
|
Definir/ampliar:
|
||||||
|
- `ConditionalRule`: operadores y acciones completos del vocabulario v3.
|
||||||
|
- `FormSchema`: la representación del formulario que el motor consume (secciones, preguntas,
|
||||||
|
reglas, instanciación) — derivada de las filas de `questions`.
|
||||||
|
- `WizardState`: respuestas (con instancias), instancia activa, pantalla actual.
|
||||||
|
- `Screen`: una pantalla resoluble (sección-intro | pregunta | pregunta-de-instancia | fin).
|
||||||
|
|
||||||
|
### 4.2 Evaluador de reglas
|
||||||
|
Reescribir la evaluación (reemplaza `lib/conditional.ts`):
|
||||||
|
- Implementar los 5 operadores y 2 acciones.
|
||||||
|
- `contains` / `eq_sole` operan sobre respuestas array (multiple_choice).
|
||||||
|
- Semántica OR documentada y correcta para el caso `r_hide_ning_sn`.
|
||||||
|
- Función pura: `(rule, answers) => boolean` y `(question, answers) => 'show'|'hide'`.
|
||||||
|
|
||||||
|
### 4.3 Resolución del grafo de pantallas
|
||||||
|
Función pura que dado el esquema + estado devuelve la lista ordenada de pantallas visibles:
|
||||||
|
- Expande instancias (4.2/4.3 por cada familiar activo declarado en 4.1).
|
||||||
|
- Aplica visibilidad (oculta secciones/preguntas según reglas).
|
||||||
|
- Incluye las pantallas de sección.
|
||||||
|
- Equivale conceptualmente al `buildVisible()` del mockup, pero como módulo puro.
|
||||||
|
|
||||||
|
### 4.4 Navegación
|
||||||
|
Funciones puras `next(state, schema)` y `back(state, schema)` que devuelven el nuevo
|
||||||
|
estado (pantalla actual actualizada), resolviendo saltos. Identificar la pantalla actual
|
||||||
|
por ID estable, no por índice (lección del mockup: el índice se desincroniza cuando
|
||||||
|
cambian las instancias).
|
||||||
|
|
||||||
|
### 4.5 Carga del esquema
|
||||||
|
Actualizar la query (`app/page.tsx`) para traer las 3 columnas nuevas
|
||||||
|
(`hint`, `instance_generator`, `instance_of`). Transformar las filas de `questions`
|
||||||
|
en el `FormSchema` que consume el motor.
|
||||||
|
|
||||||
|
### 4.6 Tests del motor (Node, sin navegador)
|
||||||
|
Tests puros que verifiquen:
|
||||||
|
- Recorrido completo (todos los vínculos) genera las pantallas esperadas.
|
||||||
|
- "Solo niño sin discapacidad" (eq_sole) → recorrido liviano correcto (5.3, 8.4, 9.1, 9.4).
|
||||||
|
- "Ninguna de las anteriores" (contains) → salta a sección 10.
|
||||||
|
- 3.1='No' oculta 3.2-3.5.
|
||||||
|
- 3 familiares → 6 pantallas de instancia en orden por familiar.
|
||||||
|
- Navegación next/back coherente cuando cambian las instancias.
|
||||||
|
|
||||||
|
## 5. Entregables (DoD)
|
||||||
|
- [ ] Motor puro en `lib/` sin dependencias de React/DOM.
|
||||||
|
- [ ] Los 5 operadores y 2 acciones implementados y testeados.
|
||||||
|
- [ ] Resolución de grafo con instancias y secciones-pantalla.
|
||||||
|
- [ ] Navegación por ID estable, no por índice.
|
||||||
|
- [ ] Query actualizada con las 3 columnas nuevas.
|
||||||
|
- [ ] Tests Node que cubren los recorridos del punto 4.6, todos en verde.
|
||||||
|
- [ ] NO se toca el componente todavía (es 4D) — el motor se entrega standalone + tests.
|
||||||
|
|
||||||
|
## 6. Qué reportar
|
||||||
|
```
|
||||||
|
Verificado:
|
||||||
|
- Motor en [archivo], puro (sin React/DOM) ✓
|
||||||
|
- Operadores implementados: eq, eq_any, not_in, contains, eq_sole ✓
|
||||||
|
- Acciones: show, hide ✓ — semántica de combinación: [descripción]
|
||||||
|
- Tests: N/N en verde
|
||||||
|
- recorrido completo ✓
|
||||||
|
- solo niño (eq_sole) → 5.3/8.4/9.1/9.4 ✓
|
||||||
|
- ninguna (contains) → salta S10 ✓
|
||||||
|
- 3.1=No oculta 3.2-3.5 ✓
|
||||||
|
- 3 familiares → 6 pantallas en orden ✓
|
||||||
|
- next/back coherente ✓
|
||||||
|
|
||||||
|
Asumido:
|
||||||
|
- [supuestos de diseño del motor]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Qué NO hacer
|
||||||
|
- ❌ No poner lógica de flujo en un componente React (el motor es puro, lib/).
|
||||||
|
- ❌ No tocar el componente de formulario todavía (es 4D).
|
||||||
|
- ❌ No implementar validación de seguridad acá (es 4G, server-side).
|
||||||
|
- ❌ No usar índices numéricos para la pantalla actual — usar ID estable.
|
||||||
|
- ❌ No tocar v2.1.0-stable ni el seed.
|
||||||
|
|
||||||
|
## 8. Versionado
|
||||||
|
Commit sugerido:
|
||||||
|
```
|
||||||
|
feat(engine): motor de resolución de flujo declarativo [4C]
|
||||||
|
|
||||||
|
Módulo puro que interpreta el esquema v3 y resuelve pantallas.
|
||||||
|
5 operadores (eq, eq_any, not_in, contains, eq_sole), acciones show/hide.
|
||||||
|
Instancias de vínculo y secciones-pantalla. Navegación por ID estable.
|
||||||
|
Tests Node cubren los 3 recorridos. Reemplaza lib/conditional.ts de v2.
|
||||||
|
```
|
||||||
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# ETAPA 4D — Frontend renderer: wizard pregunta-por-pantalla
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
Reemplazar el formulario de una sola página (v2) por un wizard que consume el motor
|
||||||
|
de flujo (4C) y muestra una pregunta por pantalla, con layout de altura estable.
|
||||||
|
El componente es un RENDERER: dibuja la pantalla que el motor resuelve; no contiene
|
||||||
|
lógica de flujo (ADR-005).
|
||||||
|
|
||||||
|
## 2. Contexto previo
|
||||||
|
- Motor de flujo listo y testeado: `lib/form-engine.ts` (resolveScreens, next, back,
|
||||||
|
resolveVisibility) + `lib/form-engine.types.ts`. 16/16 tests.
|
||||||
|
- Widgets de input AISLADOS y reutilizables en `components/widgets/`:
|
||||||
|
`radio-group`, `checkbox-group`, `text-short-field`, `text-area-field`
|
||||||
|
(interfaz genérica value/onChange/error — se reutilizan tal cual).
|
||||||
|
- `question-renderer.tsx` despacha por `type` — reutilizable con 2 cambios (ver tareas).
|
||||||
|
- `survey-form.tsx` (una sola página, secciones por regex) se REESCRIBE entero.
|
||||||
|
- `app/page.tsx` query NO trae `hint`/`instance_generator`/`instance_of` (tarea 4.5 pospuesta de 4C).
|
||||||
|
- Gaps detectados: falta widget `yes_no`; hay hardcode A1/A2 en question-renderer a quitar.
|
||||||
|
- Comportamiento de referencia (NO de arquitectura): mockup `wizard_formulario_mapeo_v3.html`.
|
||||||
|
|
||||||
|
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||||
|
- El componente consume el motor; NO reimplementa visibilidad/navegación/instancias.
|
||||||
|
- Una pregunta por pantalla. Secciones = pantallas propias (intro con número + descripción).
|
||||||
|
- Reutilizar los widgets de `components/widgets/` existentes.
|
||||||
|
- Identificación de pantalla por ID estable (el motor ya lo provee).
|
||||||
|
- **Layout de altura estable (UX 1+2+3), parte de esta etapa:**
|
||||||
|
- (1) Viewport de altura estable: el enunciado se ancla en una posición consistente
|
||||||
|
(tercio superior), NO centrado vertical. La caja no crece/encoge con el contenido.
|
||||||
|
- (2) Header y footer FIJOS: progreso arriba, navegación (Anterior/Siguiente) abajo,
|
||||||
|
ambos fuera del área que cambia de tamaño. No "saltan" entre pantallas.
|
||||||
|
- (3) Área de respuesta con altura máxima y SCROLL INTERNO cuando hay muchas opciones,
|
||||||
|
con affordance visual (degradado sutil en borde inferior) que indica "hay más abajo".
|
||||||
|
- Usar los tokens de marca existentes (el verde RE, variables CSS/Tailwind del proyecto).
|
||||||
|
NO inventar colores nuevos.
|
||||||
|
|
||||||
|
## 4. Tareas
|
||||||
|
|
||||||
|
### 4.1 Ampliar la query y construir el FormSchema (app/page.tsx)
|
||||||
|
- Agregar `hint, instance_generator, instance_of` al `.select(...)`.
|
||||||
|
- Transformar las filas de `questions` (incluidas las `type='section'`) en el `FormSchema`
|
||||||
|
que consume el motor.
|
||||||
|
- Pasar el schema al nuevo componente wizard.
|
||||||
|
|
||||||
|
### 4.2 Nuevo componente wizard (reemplaza survey-form.tsx)
|
||||||
|
- Mantiene el `WizardState` del motor (respuestas con instancias, pantalla actual).
|
||||||
|
- En cada render: pide al motor la pantalla actual y la dibuja.
|
||||||
|
- Navegación: botones Anterior/Siguiente llaman `next`/`back` del motor.
|
||||||
|
- Maneja los tipos de pantalla: sección-intro, pregunta, pregunta-de-instancia, fin.
|
||||||
|
- Para pantallas de instancia: muestra el badge de contexto ("Familiar X de N").
|
||||||
|
|
||||||
|
### 4.3 Layout de altura estable (UX 1+2+3)
|
||||||
|
Implementar el layout descrito en las decisiones. Estructura sugerida:
|
||||||
|
```
|
||||||
|
[header fijo: logo + progreso] ← no scrollea
|
||||||
|
[viewport estable]
|
||||||
|
[sección-header (si aplica)]
|
||||||
|
[enunciado anclado: código + pregunta + hint] ← posición consistente
|
||||||
|
[área de respuesta: max-height + scroll interno + fade inferior]
|
||||||
|
[footer fijo: Anterior | indicador | Siguiente] ← no scrollea
|
||||||
|
```
|
||||||
|
- El enunciado aparece en el mismo lugar pantalla tras pantalla.
|
||||||
|
- El área de respuesta scrollea internamente; header/footer nunca se mueven.
|
||||||
|
|
||||||
|
### 4.4 question-renderer: ajustes
|
||||||
|
- Quitar el hardcode A1/A2 (no aplica a v3; 2.1/2.2 son single_choice genéricos).
|
||||||
|
- Agregar el caso `yes_no` (lo usan 1.1/1.2). Crear `widgets/yes-no-field.tsx` si no existe,
|
||||||
|
o resolver yes_no con el RadioGroup existente si es lo más simple — proponé cuál.
|
||||||
|
- Migrar las shapes de `lib/types.ts` (v2) a `lib/form-engine.types.ts` (v3) donde el
|
||||||
|
renderer reciba datos del motor.
|
||||||
|
|
||||||
|
### 4.5 Progreso
|
||||||
|
- La barra/indicador de progreso se calcula desde el motor (pantalla actual / total visible),
|
||||||
|
no desde "preguntas respondidas". Reutilizar `progress-bar.tsx` si encaja.
|
||||||
|
|
||||||
|
### 4.6 Consentimiento
|
||||||
|
- Reutilizar `consent-screen.tsx` como pantalla 1 (Sección 1), o integrarlo como la primera
|
||||||
|
pantalla del wizard — proponé cuál es más limpio dado que ahora todo es wizard.
|
||||||
|
|
||||||
|
## 5. Entregables (DoD)
|
||||||
|
- [ ] Wizard funcional una-pregunta-por-pantalla consumiendo el motor.
|
||||||
|
- [ ] Layout de altura estable: header/footer fijos, enunciado anclado, scroll interno + fade.
|
||||||
|
- [ ] Query ampliada con las 3 columnas; FormSchema construido y pasado al componente.
|
||||||
|
- [ ] yes_no renderiza; hardcode A1/A2 removido.
|
||||||
|
- [ ] Progreso calculado desde el motor.
|
||||||
|
- [ ] Widgets de v2 reutilizados (no reescritos).
|
||||||
|
- [ ] **Validación visual del director** contra el mockup + criterios de UX estable.
|
||||||
|
- [ ] Build sin errores; sin lógica de flujo en el componente (solo consume el motor).
|
||||||
|
|
||||||
|
## 6. Qué reportar
|
||||||
|
```
|
||||||
|
Verificado:
|
||||||
|
- Wizard consume el motor (resolveScreens/next/back) ✓ — sin lógica de flujo propia
|
||||||
|
- Layout estable: header/footer fijos ✓, enunciado anclado ✓, scroll interno + fade ✓
|
||||||
|
- Query ampliada (hint/instance_generator/instance_of) ✓
|
||||||
|
- yes_no resuelto vía [RadioGroup | widget nuevo] ✓
|
||||||
|
- Hardcode A1/A2 removido ✓
|
||||||
|
- Widgets reutilizados: [lista]
|
||||||
|
- Build: [resultado]
|
||||||
|
- Capturas/descripción visual de 3 pantallas: pregunta corta, pregunta con muchas opciones
|
||||||
|
(scroll interno), sección-intro
|
||||||
|
|
||||||
|
Asumido / Decisiones de diseño:
|
||||||
|
- [consentimiento como pantalla / yes_no / etc.]
|
||||||
|
|
||||||
|
Hallazgos no esperados:
|
||||||
|
- [lo que aparezca]
|
||||||
|
```
|
||||||
|
Reporte con datos concretos, no "se ve bien" (Patrón C.4). Describí el comportamiento del
|
||||||
|
layout estable: ¿el enunciado queda en el mismo lugar entre una pregunta de 3 opciones y
|
||||||
|
una de 15? ¿El footer se mantiene fijo?
|
||||||
|
|
||||||
|
## 7. Qué NO hacer
|
||||||
|
- ❌ No poner lógica de flujo/visibilidad/instancias en el componente (vive en el motor).
|
||||||
|
- ❌ No reescribir los widgets de input (reutilizarlos).
|
||||||
|
- ❌ No implementar el submit todavía (es 4G) ni la validación server-side.
|
||||||
|
- ❌ No implementar el autocomplete de barrios (es 4F) — barrio queda como text_short por ahora.
|
||||||
|
- ❌ No inventar colores fuera de los tokens de marca.
|
||||||
|
- ❌ No tocar v2.1.0-stable, el motor (4C), ni el seed.
|
||||||
|
|
||||||
|
## 8. Versionado
|
||||||
|
Commit sugerido:
|
||||||
|
```
|
||||||
|
feat(wizard): renderer pregunta-por-pantalla con layout estable [4D]
|
||||||
|
|
||||||
|
Reemplaza el formulario de una página por wizard que consume el motor (4C).
|
||||||
|
Layout de altura estable: header/footer fijos, enunciado anclado, scroll
|
||||||
|
interno con affordance. Reutiliza widgets de input. Agrega yes_no, quita
|
||||||
|
hardcode A1/A2. Query ampliada con columnas declarativas de v3.
|
||||||
|
```
|
||||||
95
sprints/sprint-4/PASO_0_PRESERVACION.md
Normal file
95
sprints/sprint-4/PASO_0_PRESERVACION.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# PASO 0 — Preservación de v2.1.0 antes de arrancar Sprint 4
|
||||||
|
|
||||||
|
> Ejecutar ANTES de tocar nada de v3. Estos pasos preservan el estado actual
|
||||||
|
> estable como punto de retorno seguro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
1. Crear tag `v2.1.0` sobre el commit actual de `main`.
|
||||||
|
2. Crear rama `v2.1.0-stable` que preserva el código de v2.
|
||||||
|
3. Generar un dump de la base de datos de producción y versionarlo en esa rama.
|
||||||
|
4. Crear `CHANGELOG.md` en `main`.
|
||||||
|
5. Dejar `main` listo para desarrollo de v3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secuencia de comandos
|
||||||
|
|
||||||
|
### 1. Asegurarse de estar en main actualizado
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
git status # debe estar limpio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Crear el tag de la versión estable actual
|
||||||
|
```bash
|
||||||
|
git tag -a v2.1.0 -m "Versión 2.1.0 — formulario lineal, dashboard multitenante, instrumento v2.1"
|
||||||
|
git push origin v2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Crear la rama de preservación
|
||||||
|
```bash
|
||||||
|
git checkout -b v2.1.0-stable
|
||||||
|
git push -u origin v2.1.0-stable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Generar el dump de la base de datos (en el VPS)
|
||||||
|
```bash
|
||||||
|
# En el VPS inq-mart-2:
|
||||||
|
docker exec supabase-db pg_dump -U postgres --clean --if-exists postgres \
|
||||||
|
| gzip > ~/dump_v2.1.0_$(date +%Y%m%d).sql.gz
|
||||||
|
|
||||||
|
# Verificar tamaño
|
||||||
|
ls -lh ~/dump_v2.1.0_*.sql.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Traer el dump a la máquina local y agregarlo a la rama
|
||||||
|
```bash
|
||||||
|
# En tu Mac, desde el repo, estando en la rama v2.1.0-stable:
|
||||||
|
mkdir -p db-snapshots
|
||||||
|
scp administrator@172.20.5.11:~/dump_v2.1.0_*.sql.gz db-snapshots/
|
||||||
|
|
||||||
|
git add db-snapshots/
|
||||||
|
git commit -m "chore(v2.1.0): snapshot de base de datos de producción
|
||||||
|
|
||||||
|
Dump completo de la DB de producción al momento del corte v2.1.0.
|
||||||
|
Incluye: 3 organizaciones, 3 surveys, 132 questions, 15 responses, ~500 answers.
|
||||||
|
Preservado antes de iniciar el desarrollo de v3 (wizard)."
|
||||||
|
git push origin v2.1.0-stable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Volver a main y crear el CHANGELOG
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
# Crear CHANGELOG.md (contenido en el archivo adjunto)
|
||||||
|
git add CHANGELOG.md
|
||||||
|
git commit -m "docs: agregar CHANGELOG con versionado semántico
|
||||||
|
|
||||||
|
Establece la convención SemVer para el proyecto.
|
||||||
|
v2.1.0 documentada como versión estable actual.
|
||||||
|
v3.0.0 (wizard) marcada como en desarrollo."
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificación final
|
||||||
|
```bash
|
||||||
|
git tag -l # debe mostrar v2.1.0
|
||||||
|
git branch -a # debe mostrar main y v2.1.0-stable
|
||||||
|
git log --oneline -3 # en main, último commit es el CHANGELOG
|
||||||
|
```
|
||||||
|
|
||||||
|
A partir de acá, todo el trabajo de v3 va en `main`. La rama `v2.1.0-stable`
|
||||||
|
queda congelada como referencia y punto de retorno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota sobre el dump
|
||||||
|
El dump en `db-snapshots/` permite recrear la DB de v2.1.0 si fuera necesario
|
||||||
|
volver atrás. Para restaurarlo:
|
||||||
|
```bash
|
||||||
|
gunzip -c dump_v2.1.0_YYYYMMDD.sql.gz | docker exec -i supabase-db psql -U postgres -d postgres
|
||||||
|
```
|
||||||
BIN
sprints/sprint-4/referencias/Formulario_Mapeo_Corregido.xlsx
Normal file
BIN
sprints/sprint-4/referencias/Formulario_Mapeo_Corregido.xlsx
Normal file
Binary file not shown.
481
sprints/sprint-4/referencias/wizard_formulario_mapeo_v3.html
Normal file
481
sprints/sprint-4/referencias/wizard_formulario_mapeo_v3.html
Normal file
File diff suppressed because one or more lines are too long
22
supabase/migrations/20260620000001_add_instance_id.sql
Normal file
22
supabase/migrations/20260620000001_add_instance_id.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Sprint 4 (v3.0.0) — instance_id para respuestas multi-instancia
|
||||||
|
-- Habilita el mini-wizard de vínculos familiares (una respuesta por familiar)
|
||||||
|
-- Nombre original de constraint verificado: answers_response_id_question_id_key
|
||||||
|
-- (inline UNIQUE en CREATE TABLE, migración 20260531000001_create_tables.sql, sin renombrado posterior)
|
||||||
|
|
||||||
|
-- 1. Agregar la columna con default para filas existentes
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_id text NOT NULL DEFAULT 'default';
|
||||||
|
|
||||||
|
-- 2. Eliminar la constraint única vieja
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
DROP CONSTRAINT IF EXISTS answers_response_id_question_id_key;
|
||||||
|
|
||||||
|
-- 3. Crear la nueva constraint que incluye instance_id
|
||||||
|
ALTER TABLE public.answers
|
||||||
|
ADD CONSTRAINT answers_response_question_instance_key
|
||||||
|
UNIQUE (response_id, question_id, instance_id);
|
||||||
|
|
||||||
|
-- 4. Índice para consultas por instancia (análisis de vínculos)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_answers_instance
|
||||||
|
ON public.answers (question_id, instance_id)
|
||||||
|
WHERE instance_id != 'default';
|
||||||
47
supabase/migrations/20260620000002_form_schema_v3.sql
Normal file
47
supabase/migrations/20260620000002_form_schema_v3.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Sprint 4 (v3.0.0) — Esquema declarativo del formulario wizard
|
||||||
|
-- Extiende questions con tres campos para soporte de instanciación y secciones.
|
||||||
|
-- Ver ADR-005: la lógica del flujo vive en datos, el frontend es un renderer.
|
||||||
|
|
||||||
|
-- 1. instance_generator: marca la pregunta que genera instancias del mini-wizard.
|
||||||
|
-- true en pregunta 4.1 (vínculos familiares); false en todas las demás.
|
||||||
|
ALTER TABLE public.questions
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_generator boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- 2. instance_of: código de la pregunta generadora de la que depende esta pregunta.
|
||||||
|
-- '4.1' en preguntas 4.2 y 4.3 (se repiten una vez por familiar declarado en 4.1).
|
||||||
|
-- NULL en todas las preguntas que no forman parte del mini-wizard.
|
||||||
|
ALTER TABLE public.questions
|
||||||
|
ADD COLUMN IF NOT EXISTS instance_of text;
|
||||||
|
|
||||||
|
-- 3. hint: texto auxiliar que el renderer muestra bajo el enunciado.
|
||||||
|
-- Para type='section': descripción del "porqué" de la sección (ADR-005 §4.2, Opción C).
|
||||||
|
-- Para preguntas regulares: hint de ayuda ("Podés marcar más de una opción", etc.).
|
||||||
|
-- NULL cuando no aplica.
|
||||||
|
ALTER TABLE public.questions
|
||||||
|
ADD COLUMN IF NOT EXISTS hint text;
|
||||||
|
|
||||||
|
-- ── Vocabulario extendido de conditional_rules (referencia para el motor de flujo) ──
|
||||||
|
--
|
||||||
|
-- Formato de cada regla:
|
||||||
|
-- { "if_question": CODE | CODE[], "op": OPERATOR, "value": VALUE, "action": ACTION }
|
||||||
|
--
|
||||||
|
-- Operadores:
|
||||||
|
-- "eq" — igualdad simple: la respuesta a if_question == value
|
||||||
|
-- "eq_any" — OR lógico: AL MENOS UNO de los if_question[] == value
|
||||||
|
-- "not_in" — la respuesta a if_question NO está en value (array de strings)
|
||||||
|
-- "contains" — la respuesta (multiple_choice) CONTIENE value como uno de los
|
||||||
|
-- elementos seleccionados; útil para detectar "Ninguna de las anteriores"
|
||||||
|
-- "eq_sole" — la respuesta (multiple_choice) tiene VALUE como ÚNICO elemento
|
||||||
|
-- seleccionado; usado para el recorrido "solo niño sin discapacidad"
|
||||||
|
--
|
||||||
|
-- Acciones:
|
||||||
|
-- "show" — mostrar esta pregunta/sección cuando la condición se cumple
|
||||||
|
-- "hide" — ocultar esta pregunta/sección cuando la condición se cumple
|
||||||
|
--
|
||||||
|
-- Semántica de auto-target: la regla afecta a la pregunta/sección que la porta.
|
||||||
|
-- Sin campo "target": una pregunta declara su propia condición de visibilidad.
|
||||||
|
--
|
||||||
|
-- Ejemplo — salto "Ninguna de las anteriores" en 4.1 oculta secciones S5-S7:
|
||||||
|
-- { "if_question": "4.1", "op": "not_in",
|
||||||
|
-- "value": ["Discapacidad propia", "Familiar con discapacidad", "Adulto mayor"],
|
||||||
|
-- "action": "hide" }
|
||||||
2208
supabase/seed.sql
2208
supabase/seed.sql
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user