Compare commits

...

4 Commits

Author SHA1 Message Date
markosbenitez
ed524af16b 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
(hide = veto absoluto). Instancias con ID estable por posición de opción,
secciones-pantalla. Navegación por ID estable. 16/16 tests Node en verde.
No reemplaza aún lib/conditional.ts de v2 (lo hace 4D al migrar el componente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:21:03 -03:00
markosbenitez
5987502664 feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint.
Instrumento v3 completo (10 secciones, 50 preguntas) como datos declarativos.
Operadores de flujo para multiple_choice: contains, eq_sole.
Flujo del formulario en el esquema, no en presentación (ADR-005).
Seed solo reemplaza catálogo; no toca evidencia de consentimiento (ADR-003).
2026-06-22 21:05:11 -03:00
markosbenitez
aa893368cd feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint.
Instrumento v3 completo (10 secciones, 50 preguntas) como datos declarativos.
Operadores de flujo para multiple_choice: contains, eq_sole.
Flujo del formulario en el esquema, no en presentación (ADR-005).
Seed solo reemplaza catálogo; no toca evidencia de consentimiento (ADR-003).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:03:01 -03:00
markosbenitez
7e4991f960 feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint y documenta
los operadores contains/eq_sole en conditional_rules (ADR-005).

Reescribe seed.sql con el instrumento v3.0.0 completo: 10 secciones,
40 preguntas (2 consentimiento + 38), contenido 100% desde
Formulario_Mapeo_Corregido.xlsx (versión autoritativa). Flujo
declarativo en datos, no en presentación: 3.1='No' oculta 3.2-3.5;
"Ninguna" en 4.1 salta a S10; "solo niño sin discapacidad" deja
visibles 5.3, 8.4, 9.1, 9.4 y oculta el resto (decisión confirmada
sobre el Excel corregido).

SEED_SPEC.md reescrito como contrato v3.0.0.

No ejecutado en producción — solo archivos de migración y seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:21:11 -03:00
12 changed files with 2389 additions and 1853 deletions

View File

@@ -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 27) 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.18.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 S5S9 oculto).
- **4.1 = cualquier combinación de opciones 27** (con o sin la opción 1): recorrido completo,
4.2/4.3 generan una instancia por cada vínculo 27 declarado.
--- ---
## Sección ADatos Generales (top-level) ## Sección 1Consentimiento (`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 2Intensidad y rol del cuidado (top-level) ## Sección 3Prevalencia 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.23.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 27 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? | 12 · 34 · 56 · 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? | 12 · 34 · 56 · 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? | 12 · 34 · 56 · 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.18.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.0001.500.000 · Gs. 1.500.0003.000.000 · Gs. 3.000.0006.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.0018.000.000 · Gs. 8.000.00112.000.000 · Gs. 12.000.00115.000.000 · Gs. 15.000.00118.000.000 · Gs. 18.000.00125.000.000 · Gs. 25.000.00150.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 (13 veces al año) · Varias veces (410 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.

344
lib/form-engine.test.mts Normal file
View 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 S3S10 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
View 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
View 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
}

96
sprints/sprint-4/BRIEF.md Normal file
View File

@@ -0,0 +1,96 @@
# BRIEF — Sprint 4: Formulario Wizard v3
> Versión objetivo: **3.0.0** · Rama de trabajo: `main` · Preservación: `v2.1.0-stable`
---
## Objetivo en una frase
Reescribir el formulario de encuesta como un wizard pregunta-por-pantalla con
mini-wizard anidado para vínculos familiares, sobre un nuevo instrumento de 10
secciones, habilitando análisis de datos multi-instancia.
---
## Alcance (qué entra)
- Nuevo modelo de datos: columna `instance_id` en `answers`, nueva constraint única
- Nuevo instrumento v3 (10 secciones, ~38 preguntas base + preguntas por instancia)
- Motor de wizard: grafo de pantallas, navegación, estado, instancias dinámicas
- Componente de pantalla única (una pregunta por pantalla)
- Mini-wizard anidado para vínculos familiares (pregunta 4.1 → instancias)
- Autocomplete de barrios desde dataset INE/DGEEC (`geo_paraguay.json`)
- Subtítulos del "porqué" por sección
- Submit actualizado para enviar `instance_id` por answer
- Barra de progreso dinámica
- RPCs nuevas para preguntas de vínculo con instancias
- Seed v3 limpio (sin migración de datos v2)
---
## Fuera de alcance (explícito)
- ❌ Migración de datos v2.1 → v3 (se decide más adelante, cuando v3 esté estable)
- ❌ Tocar la rama `v2.1.0-stable`
- ❌ RE Index (sprint posterior)
- ❌ Informe descargable (sprint posterior)
- ❌ Autenticación de `/admin/responses` (TECH-DEBT-008)
- ❌ Backup remoto S3 (TECH-DEBT-006)
---
## Decisiones ya tomadas (no renegociar)
1. v3 se desarrolla en `main`. v2 queda preservado en `v2.1.0-stable` + tag `v2.1.0`.
2. v3 arranca con seed limpio — sin datos migrados.
3. `instance_id text DEFAULT 'default'` en `answers`. Constraint:
`UNIQUE (response_id, question_id, instance_id)`.
4. Wizard pregunta-por-pantalla para TODO el formulario tras el consentimiento.
5. Mini-wizard de familiares: una pregunta por pantalla, con badge de contexto
"Familiar X de N".
6. Numeración desde Sección 1 (no "Sección A").
7. Barrio: autocomplete para ciudades con datos INE, texto libre para el resto.
8. Texto de consentimiento: el del Excel del instrumento v3 (Sección 1).
9. Versionado SemVer + CHANGELOG + tags de Git en cada versión estable.
10. 2-3 familiares es el caso típico; soportar N sin límite duro.
---
## Criterios de éxito (checklist)
- [ ] El formulario funciona end-to-end como wizard, una pregunta por pantalla
- [ ] El mini-wizard de familiares genera instancias correctamente (verificable en DB)
- [ ] Los datos se guardan con `instance_id` correcto por familiar
- [ ] El dashboard sigue funcionando (las preguntas no-instancia usan `instance_id='default'`)
- [ ] Las nuevas RPCs agregan datos de vínculo por instancia
- [ ] El autocomplete de barrios funciona para las ciudades principales
- [ ] La validación visual del wizard coincide con el mockup aprobado
- [ ] `v2.1.0-stable` permanece intacta y restaurable
---
## Etapas del Sprint 4
| Etapa | Nombre | Entregable | Dependencia |
|---|---|---|---|
| **4.0** | Preservación v2 | Tag v2.1.0, rama stable, dump, CHANGELOG | — |
| **4A** | Modelo de datos | Migración `instance_id` + constraint | 4.0 |
| **4B** | Seed v3 | Instrumento v3 completo en seed.sql + geo JSON | 4A |
| **4C** | Motor del wizard | Grafo de pantallas, estado, navegación (lógica) | 4B |
| **4D** | Componente de pantalla | UI una-pregunta-por-pantalla + secciones | 4C |
| **4E** | Mini-wizard instancias | Vínculos 4.1 + pantallas por familiar | 4D |
| **4F** | Autocomplete barrios | Campo barrio con dataset INE | 4D |
| **4G** | Submit con instance_id | Guardado correcto multi-instancia | 4E |
| **4H** | RPCs de vínculo | Agregados de datos por instancia en dashboard | 4G |
| **4I** | Validación e2e + tag v3.0.0 | QA visual completo + release | todas |
---
## Orden de ejecución
4.0 → 4A → 4B → 4C → 4D → (4E ∥ 4F) → 4G → 4H → 4I
Las etapas 4E y 4F pueden ir en paralelo si hay capacidad; si no, 4E primero
(es la más crítica), 4F después.
---
## Notas de versionado por etapa
- Cada etapa cierra con un commit versionado descriptivo.
- Las versiones `3.0.0-alpha.N` se pueden usar para hitos intermedios si se
despliega a un entorno de prueba.
- El tag `v3.0.0` se crea solo en la etapa 4I, cuando todo está validado.

View 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.
```

View 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).
```

View 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
```

File diff suppressed because one or more lines are too long

View 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" }

File diff suppressed because it is too large Load Diff