refactor completo + truncate de base de datos + reingenieria dashboard

This commit is contained in:
markosbenitez
2026-06-05 17:13:45 -03:00
parent a17e810ea8
commit 307a54dabc
43 changed files with 1320 additions and 92 deletions

View File

@@ -24,10 +24,10 @@
## Sección A — Datos Generales (top-level)
| Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---|
| A1 | text_short | País* | — (texto libre) | qi_country (futuro) |
| A2 | text_short | Ciudad* | — (texto libre) | qi_city (futuro) |
| A3 | text_short | Sucursal o sede | — (texto libre) | opcional |
| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector |
| 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 · Prefiero no decir | qi_age_bucket |
| A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir | |
| A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | |
@@ -102,15 +102,15 @@
## Mapeo a cuasi-identificadores (`responses.qi_*`)
- `qi_age_bucket` ← A5 (ya en bucket)
- `qi_sector` ← A4 (área)
- `qi_zone`NULL (no aplica en este instrumento)
- `qi_city` / `qi_country` ← A2/A1: **Sprint 3** — se agregan como columnas qi en migration futura.
Por ahora A1/A2/A3 se capturan en `answers` como text_short.
- `qi_zone`A1 (departamento — poblado en `submit-survey.ts` desde Etapa 2E)
- `qi_city` ← A2: **Sprint 3** — se agrega como columna qi en migration futura.
Por ahora A2 se captura en `answers` como single_choice.
## ⚠️ 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}`.
- **qi_city / qi_country:** migration 008 para agregar columnas y extender whitelist de agg_segment.
Se hace antes de Sprint 3 (dashboard).
- **qi_city:** migration futura para agregar columna en responses y extender whitelist de agg_segment.
Se hace antes de Sprint 3 (dashboard con filtro por ciudad).
- **Salary analytics:** metodología para calcular "carga económica del cuidado como % del ingreso"
es trabajo de metodología de investigación (fuera del scope del código).
- **RE-INDEX scoring:** algoritmo de composición de los índices (descanso, bienestar, rendimiento,

60
TECH_DEBT.md Normal file
View File

@@ -0,0 +1,60 @@
# TECH_DEBT.md — Deuda técnica registrada
> Ítems identificados durante el desarrollo. Cada ítem documenta qué falta, por qué
> no se hizo ahora, y qué trigger habilitaría su resolución.
---
## [TECH-DEBT-001] Ranking widget para 6.2
- **Estado:** 6.2 es actualmente `multiple_choice` max_select=3. El widget muestra
frecuencia simple (top 8).
- **Deuda:** cuando el tipo pase a `ranking` con JSONB `{rank1, rank2, rank3}`, el
widget debe reescribirse para mostrar ponderación por posición.
- **Trigger:** decisión del director sobre el tipo de 6.2 en próxima versión del
instrumento (anotada en SEED_SPEC.md § Decisiones pendientes).
---
## [TECH-DEBT-002] KPI NPS Cuidador
- **Estado:** el instrumento actual no tiene pregunta NPS. El KPI fue eliminado del
dashboard en Etapa 2E.
- **Para implementar:** agregar en próxima versión del instrumento:
"En una escala de 0 a 10, ¿qué tan probable es que recomiendes esta empresa a otra
persona cuidadora?"
- **Trigger:** decisión de incluir NPS en el instrumento v2.
---
## [TECH-DEBT-003] Brecha de políticas (oferta vs. demanda)
- **Estado:** el instrumento captura solo demanda (6.2 = apoyos que ayudarían).
No captura qué políticas la empresa ya ofrece. W6 muestra solo demanda.
- **Para implementar:** agregar en próxima versión del instrumento:
"¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las mismas
opciones de 6.2 + "Ninguno" + "No lo sé").
- **Trigger:** decisión de agregar la pregunta de oferta al instrumento.
---
## [TECH-DEBT-004] Intención directa de salida laboral
- **Estado:** el instrumento no pregunta directamente por intención de dejar el
empleo. W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación".
- **Para implementar:** agregar en próxima versión:
"En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas
al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir).
- **Trigger:** validación metodológica de que el indicador compuesto no es suficiente
para medir intención de salida.
---
## [TECH-DEBT-005] qi_city / qi_country — migración pendiente
- **Estado:** A1 (Departamento) y A2 (Ciudad) se guardan en `answers`. Las columnas
`qi_city` y `qi_country` en `responses` son NULL. Sprint 3 las necesita para
segmentación avanzada.
- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert
y extienda la whitelist de `agg_segment`.
- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad).

View File

@@ -66,6 +66,8 @@ export async function submitSurvey(payload: {
typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null
const qi_sector =
typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null
const qi_zone =
typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null
// 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md)
const { error: rErr } = await supabase
@@ -79,7 +81,7 @@ export async function submitSurvey(payload: {
consent_record_id: consentId,
qi_age_bucket: qi_age_bucket || null,
qi_sector: qi_sector || null,
qi_zone: null,
qi_zone: qi_zone || null,
})
if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` }

View File

@@ -10,12 +10,12 @@ const RPC_MAP: Record<string, string> = {
kpis: 'rpc_dashboard_kpis',
prevalencia: 'rpc_prevalencia_por_depto',
heatmap: 'rpc_heatmap_intensidad',
distribucion: 'rpc_distribucion_cuidado',
politicas: 'rpc_politicas_gap',
distribucion: 'rpc_perfil_poblacion',
apoyos: 'rpc_apoyos_demandados',
score: 'rpc_score_organizacional',
piramide: 'rpc_piramide_demografica',
intensidad: 'rpc_intensidad_vs_productividad',
riesgo: 'rpc_riesgo_retencion',
intensidad: 'rpc_intensidad_vs_bienestar',
saturacion: 'rpc_saturacion_cuidador',
}
function getSupabaseServer() {

View File

@@ -7,9 +7,9 @@ import { DonutCuidados } from '@/components/dashboard/donut-cuidados'
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
import { PoliticasBar } from '@/components/dashboard/politicas-bar'
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
import { TablaRiesgo } from '@/components/dashboard/tabla-riesgo'
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import type { DashboardFilters } from '@/lib/dashboard-types'
@@ -60,15 +60,15 @@ export default function DashboardPage() {
<PiramideDemografica surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: Barras políticas (6) + Hexbin intensidad/bienestar (6) */}
<section className="db-grid db-section" aria-label="Políticas e impacto">
<PoliticasBar surveyId={surveyId} filters={filters} />
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
<section className="db-grid db-section" aria-label="Apoyos e impacto">
<ApoyosBar surveyId={surveyId} filters={filters} />
<IntensidadProductividad surveyId={surveyId} filters={filters} />
</section>
{/* Fila 5: Tabla riesgo (12) */}
<section className="db-grid db-section" aria-label="Riesgo de retención">
<TablaRiesgo surveyId={surveyId} filters={filters} />
{/* Fila 5: Saturación del cuidador (12) */}
<section className="db-grid db-section" aria-label="Saturación del cuidador">
<TablaSaturacion surveyId={surveyId} filters={filters} />
</section>
</div>
</div>

View File

@@ -32,6 +32,7 @@
/* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--color-bg);
@@ -39,7 +40,12 @@ body {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* El contenido entre header y footer crece para empujar el footer al fondo */
.layout-content { flex: 1; }
/* ── Skip link ── */
.skip-link {
@@ -608,13 +614,25 @@ body {
transition: box-shadow 0.3s;
}
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
/* Título de KPI: mayúsculas, bold, siempre visible */
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #E7E6E5; text-transform: uppercase; letter-spacing: 0.04em; }
/* Subtítulo de KPI: código de pregunta + descripción */
.db-kpi-subtitle { font-size: 0.6875rem; color: #7A8FA0; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
.db-kpi-value {
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
font-variant-numeric: tabular-nums;
font-variant-numeric: tabular-nums; margin-top: 4px;
}
.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; }
.db-kpi-note { font-size: 0.75rem; color: #9ca3af; }
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; }
/* Compat — se puede quitar si ya no se usa */
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; }
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; }
/* Subtítulo de widget-shell */
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.db-widget-subtitle { font-size: 0.75rem; color: #7A8FA0; font-weight: 400; margin: 0; line-height: 1.4; }
/* ── Mapa tile ── */
.db-map-wrapper { position: relative; }
.db-map-tooltip {

BIN
assets/re/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,29 +1,29 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type PoliticasData } from '@/lib/dashboard-types'
import { isSuppressed, type ApoyosData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
export function PoliticasBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PoliticasData | null>(null)
export function ApoyosBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ApoyosData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.politicas(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PoliticasData) })
queries.apoyos(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ApoyosData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="📈 Políticas más demandadas" status={status} n={data?.n_total} colSpan={6}>
<WidgetShell title="Apoyos más demandados" subtitle="6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían" status={status} n={data?.n_total} colSpan={6}>
{data && (
<ul className="db-bar-list" aria-label="Políticas demandadas">
<ul className="db-bar-list" aria-label="Apoyos más demandados">
{data.data.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por política (n&lt;5 en todas).</li>
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n&lt;5 en todos).</li>
)}
{data.data.map(item => (
<li key={item.politica} className="db-bar-item">

View File

@@ -41,7 +41,7 @@ export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters
}) : []
return (
<WidgetShell title="🍩 Tipo de cuidado" status={status} n={data?.n} colSpan={3}>
<WidgetShell title="Perfil de la población" subtitle="1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-donut-wrapper">
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">

View File

@@ -31,7 +31,7 @@ export function GaugeOrganizacional({ surveyId, filters }: { surveyId: string; f
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
return (
<WidgetShell title="🎯 Madurez organizacional" status={status} n={data?.n} colSpan={3}>
<WidgetShell title="Madurez organizacional" subtitle="6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0100" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-gauge-wrapper">
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>

View File

@@ -31,7 +31,7 @@ export function HeatmapWidget({ surveyId, filters }: { surveyId: string; filters
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c]))
return (
<WidgetShell title="🔥 Horas de cuidado × Ausencias laborales" status={status} n={data?.n_total} colSpan={6}>
<WidgetShell title="Horas de cuidado × Frecuencia de ausentismo" subtitle="2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
<div className="db-heatmap-grid"

View File

@@ -33,7 +33,7 @@ export function IntensidadProductividad({ surveyId, filters }: { surveyId: strin
}
return (
<WidgetShell title="💸 Intensidad de cuidado vs. Bienestar (bins N≥5)" status={status} n={data?.n_total} colSpan={6}>
<WidgetShell title="Intensidad de cuidado vs. Bienestar" subtitle="2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-hexbin-wrapper">
<p className="db-hexbin-note">

View File

@@ -4,14 +4,57 @@ import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashbo
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
interface KpiCardProps { label: string; value: string; sub?: string }
const KPI_DEFS = [
{
title: 'CUIDADORES ACTIVOS',
subtitle: '1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros',
getValue: (d: KpiData) => `${d.pct_cuidadores}%`,
getN: (d: KpiData) => `n = ${d.n}`,
},
{
title: 'FRECUENCIA DE AUSENTISMO',
subtitle: '5.2 · % con ausencias laborales recurrentes (4 o más veces al año)',
getValue: (d: KpiData) => `${d.pct_ausencias}%`,
getN: () => 'ausencias frecuentes',
},
{
title: 'BIENESTAR',
subtitle: '4.3 · autorreporte de bienestar general · escala 15',
getValue: (d: KpiData) => d.score_bienestar !== null ? d.score_bienestar.toFixed(1) : '—',
getN: () => '/ 5',
},
] as const
function KpiCard({ label, value, sub }: KpiCardProps) {
interface KpiCardProps {
title: string
subtitle: string
value: string
valueNote: string
suppressed: boolean
loading: boolean
}
function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) {
return (
<div className="db-kpi-card">
<span className="db-kpi-value" aria-live="polite">{value}</span>
<span className="db-kpi-label">{label}</span>
{sub && <span className="db-kpi-sub">{sub}</span>}
<span className="db-kpi-title">{title}</span>
<span className="db-kpi-subtitle">{subtitle}</span>
{loading && (
<div className="db-skeleton-block" style={{ height: 36, margin: '10px 0 4px', borderRadius: 6 }} />
)}
{!loading && suppressed && (
<div className="db-kpi-suppressed-block" role="status">
<span className="db-suppressed-icon" aria-hidden="true">🔒</span>
<span className="db-kpi-value db-kpi-value-suppressed">n &lt; 5</span>
</div>
)}
{!loading && !suppressed && (
<>
<span className="db-kpi-value" aria-live="polite">{value}</span>
<span className="db-kpi-note">{valueNote}</span>
</>
)}
</div>
)
}
@@ -30,33 +73,24 @@ export function KpiCards({ surveyId, filters }: Props) {
}, [surveyId, JSON.stringify(filters)])
if (error) return <p className="db-error">Error al cargar KPIs</p>
if (!data) {
return (
<div className="db-kpi-row">
{[1,2,3,4].map(i => <div key={i} className="db-kpi-card db-skeleton-block" style={{height:96}} />)}
</div>
)
}
if (isSuppressed(data)) {
return (
<div className="db-kpi-row">
{[1,2,3,4].map(i => (
<div key={i} className="db-kpi-card db-suppressed-card">
<span className="db-suppressed-icon">🔒</span>
<span className="db-kpi-sub">n &lt; 5</span>
</div>
))}
</div>
)
}
const d = data as KpiData
const loading = data === null
const suppressed = !loading && isSuppressed(data)
const ok = !loading && !suppressed
return (
<div className="db-kpi-row">
<KpiCard label="Cuidadores activos" value={`${d.pct_cuidadores}%`} sub={`n = ${d.n}`} />
<KpiCard label="Score bienestar (0-100)" value={d.score_bienestar !== null ? `${d.score_bienestar}` : '—'} />
<KpiCard label="Ausencias frecuentes" value={`${d.pct_ausencias}%`} />
<KpiCard label="Total respuestas" value={d.n.toLocaleString('es-PY')} sub="en este corte" />
{KPI_DEFS.map((def) => (
<KpiCard
key={def.title}
title={def.title}
subtitle={def.subtitle}
value={ok ? def.getValue(data as KpiData) : '—'}
valueNote={ok ? def.getN(data as KpiData) : ''}
suppressed={suppressed}
loading={loading}
/>
))}
</div>
)
}

View File

@@ -62,7 +62,7 @@ export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) {
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
return (
<WidgetShell title="🗺️ Prevalencia de cuidadores por departamento" status={status} n={n} colSpan={6}>
<WidgetShell title="Prevalencia de cuidadores por departamento" subtitle="A1 · % cuidadores activos por departamento" status={status} n={n} colSpan={6}>
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
{tooltip && (
<div className="db-map-tooltip" aria-live="polite">

View File

@@ -31,7 +31,7 @@ export function PiramideDemografica({ surveyId, filters }: { surveyId: string; f
const generos = ['Femenino','Masculino','No binario/Otro']
return (
<WidgetShell title="👥 Pirámide demográfica" status={status} n={data?.n_total} colSpan={6}>
<WidgetShell title="Pirámide demográfica" subtitle="A5 × A6 · rango etario por género (celdas con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-piramide">
<div className="db-piramide-legend" aria-label="Leyenda">

View File

@@ -1,44 +1,44 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type RiesgoData } from '@/lib/dashboard-types'
import { isSuppressed, type SaturacionData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const RIESGO_COLOR: Record<string, string> = {
Alto: '#ef4444',
Medio: '#F8AD13',
Bajo: '#43BBC8',
const SATURACION_COLOR: Record<string, string> = {
'Alta saturación': '#ef4444',
'Saturación media': '#F8AD13',
'Baja saturación': '#43BBC8',
}
export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<RiesgoData | null>(null)
export function TablaSaturacion({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<SaturacionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.riesgo(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as RiesgoData) })
queries.saturacion(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as SaturacionData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="📋 Tabla de riesgo de retención" status={status} n={data?.n_total} colSpan={12}>
<WidgetShell title="Saturación del cuidador" subtitle="4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)" status={status} n={data?.n_total} colSpan={12}>
{data && (
<div className="db-table-wrapper">
<p className="db-table-note">
Alto: ausencias frecuentes + empresa no comprende. Medio: una condición. Bajo: ninguna.
La columna de departamento fue eliminada para evitar cruces reidentificables.
Alta: bienestar 2, rendimiento afectado casi todos los días y ausentismo frecuente.
Media: dos de los tres criterios. Baja: uno o ninguno.
</p>
<table className="db-table" aria-label="Tabla de riesgo de retención">
<table className="db-table" aria-label="Tabla de saturación del cuidador">
<thead>
<tr>
<th>Segmento</th>
<th>N</th>
<th>% del total</th>
<th>Política más demandada</th>
<th>Apoyo más demandado</th>
</tr>
</thead>
<tbody>
@@ -46,14 +46,16 @@ export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters:
<tr key={seg.segmento}>
<td>
<span className="db-risk-badge"
style={{ background: RIESGO_COLOR[seg.segmento] ?? '#6b7280' }}>
style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}>
{seg.segmento}
</span>
</td>
<td>{seg.suppressed ? '—' : seg.n}</td>
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
<td>{seg.suppressed ? <span className="db-suppressed-hint">Datos insuficientes (n&lt;5)</span>
: seg.politica ?? '—'}</td>
<td>{seg.suppressed
? <span className="db-suppressed-hint">Datos insuficientes (n&lt;5)</span>
: seg.apoyo ?? '—'}
</td>
</tr>
))}
</tbody>

View File

@@ -3,17 +3,21 @@ import type { WidgetStatus } from '@/lib/dashboard-types'
interface WidgetShellProps {
title: string
subtitle?: string
status: WidgetStatus
n?: number
children: React.ReactNode
colSpan?: number
}
export function WidgetShell({ title, status, n, children, colSpan = 6 }: WidgetShellProps) {
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) {
return (
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
<div className="db-widget-header">
<h3 className="db-widget-title">{title}</h3>
<div className="db-widget-title-group">
<h3 className="db-widget-title">{title}</h3>
{subtitle && <p className="db-widget-subtitle">{subtitle}</p>}
</div>
{n !== undefined && status === 'ok' && (
<span className="db-widget-n">n = {n.toLocaleString('es-PY')}</span>
)}

View File

@@ -16,9 +16,9 @@ export const queries = {
prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f),
heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f),
distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f),
politicas: (sid: string, f: DashboardFilters) => fetchWidget('politicas', sid, f),
apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f),
score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f),
piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f),
intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f),
riesgo: (sid: string, f: DashboardFilters) => fetchWidget('riesgo', sid, f),
saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f),
}

View File

@@ -14,7 +14,7 @@ export interface KpiData {
status: 'ok'
n: number
pct_cuidadores: number
score_bienestar: number | null
score_bienestar: number | null // avg raw 4.3 (1.05.0)
pct_ausencias: number
}
@@ -34,8 +34,8 @@ export interface DistribucionData {
disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number
}
export interface PoliticaItem { politica: string; n: number; pct: number }
export interface PoliticasData { status: 'ok'; n_total: number; data: PoliticaItem[] }
export interface ApoyoItem { politica: string; n: number; pct: number }
export interface ApoyosData { status: 'ok'; n_total: number; data: ApoyoItem[] }
export interface ScoreData { status: 'ok'; n: number; score: number; zona: 'baja' | 'desarrollo' | 'consciente' }
@@ -45,8 +45,14 @@ export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCel
export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean }
export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] }
export interface RiesgoSegmento { segmento: string; n: number | null; pct: number | null; politica: string | null; suppressed: boolean }
export interface RiesgoData { status: 'ok'; n_total: number; segmentos: RiesgoSegmento[] }
export interface SaturacionSegmento {
segmento: string
n: number | null
pct: number | null
apoyo: string | null
suppressed: boolean
}
export interface SaturacionData { status: 'ok'; n_total: number; segmentos: SaturacionSegmento[] }
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }

View File

@@ -0,0 +1,127 @@
# ETAPA 2D — Dashboard: títulos limpios, subtítulos descriptivos y fix de KPI cards
## 1. Objetivo
Pulir el lenguaje visual del dashboard alineándolo al patrón del equipo (Fundación):
**título limpio sin emojis + subtítulo descriptivo con código de pregunta**.
Además, arreglar el bug de KPI cards que no muestran su label en estado suppressed.
**Esta etapa es 100% cosmética y reversible.** No toca lógica de datos, RPC, RLS,
filtros, ni esquema. Solo componentes de presentación.
---
## 2. Contexto previo
- Etapa 3 completada (dashboard funcional con k-anonimato estructural y 9 widgets).
- El director validó visualmente el estado suppressed y detectó tres hallazgos
(KPI sin label, emojis en títulos, falta de subtítulos).
- Referencia de lenguaje visual: dashboard del equipo de Fundación Solidaridad
("Mapeo de Vinculación") — patrón título + `card-sub` con código de pregunta.
---
## 3. DECISIONES YA TOMADAS (no consultar de nuevo)
- Cero emojis en títulos de widgets y de KPI cards.
- Subtítulos descriptivos en TODAS las cards (KPI y widgets), formato:
`"<código(s) de pregunta> · <descripción corta en lenguaje natural>"`
- En estado `suppressed`, las KPI cards muestran título + subtítulo + candado + "n < 5".
El título NUNCA se oculta — comunica qué se está suprimiendo.
- Paleta y tipografías existentes se mantienen.
- Layout y grid se mantienen.
---
## 4. Tareas
### 4.1 KPI cards — labels visibles en cualquier estado
Modificar `components/dashboard/kpi-cards.tsx` (o equivalente) para que el componente
KPICard renderice SIEMPRE el título y subtítulo, en los 3 estados:
```
┌─────────────────────────────┐
│ CUIDADORES ACTIVOS │ ← título (siempre visible)
│ 1.1 + 1.6 + 1.13 · % … │ ← subtítulo (siempre visible)
│ │
│ [valor o 🔒] │ ← cambia según estado
│ 34,2% o n<5 │
└─────────────────────────────┘
```
Los 4 KPI cards con sus textos definitivos:
| # | Título | Subtítulo |
|---|---|---|
| 1 | CUIDADORES ACTIVOS | 1.1 + 1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado |
| 2 | AUSENCIA PROMEDIO | 3.1 · días por mes asociados al cuidado |
| 3 | BIENESTAR | 5.1 · autorreporte de productividad y energía |
| 4 | NPS CUIDADOR | 6.5 · recomendaría la empresa a otra persona cuidadora |
### 4.2 Widgets — limpiar títulos y agregar subtítulos
Para los 9 widgets (no-KPI), aplicar exactamente estos textos. Eliminar el emoji del
título. El subtítulo va inmediatamente debajo del título, con estilo secundario
(color tenue, una línea, peso regular).
| Widget actual | Título nuevo | Subtítulo |
|---|---|---|
| 🗺️ Prevalencia de cuidadores por departamento | Prevalencia de cuidadores por departamento | A1 · % con responsabilidades de cuidado, por departamento |
| 🍩 Tipo de cuidado | Tipo de cuidado | 1.1 / 1.6 / 1.13 · composición de la carga (no excluyentes) |
| 🎯 Madurez organizacional | Madurez organizacional | 6.1 + 6.3 + 6.5 · score combinado 0100 |
| 🔥 Horas de cuidado × Ausencias laborales | Horas de cuidado × Ausencias laborales | 2.1 × 3.1 · correlación intensidad / ausentismo |
| 👥 Pirámide demográfica | Pirámide demográfica | A5 × A6 · rango etario por género (celdas con k≥5) |
| 📈 Políticas más demandadas | Políticas más demandadas | 6.4 vs 6.2 · brecha entre necesidad y oferta |
| 🦋 Intensidad de cuidado vs. Bienestar (bins N≥5) | Intensidad de cuidado vs. Bienestar | 2.1 × 5.1 · cruce agregado por bins (k≥5) |
| 📋 Tabla de riesgo de retención | Tabla de riesgo de retención | 5.2 + 6.3 · segmentos por riesgo de abandono laboral |
### 4.3 Estilos
- **Título de card:** tipografía actual, bold, sin cambios de tamaño.
- **Subtítulo:** una línea por debajo del título, separación visual mínima (46px),
color `#7A8FA0` (var --color-text-muted o equivalente), tamaño 0.85× del título,
peso regular. No usar itálica.
- Si el componente `widget-shell.tsx` ya existe, el subtítulo se agrega ahí una sola
vez y se hereda en todos los widgets.
### 4.4 Verificaciones
Antes de declarar terminado:
- `grep -rE '[🗺🍩🎯🔥👥📈🦋📋]' components/dashboard/ app/dashboard/` debe
retornar vacío.
- Inspección manual: las 4 KPI cards muestran título y subtítulo en estado
suppressed (no solo el candado).
- `git diff --name-only HEAD` solo en `components/dashboard/` y posiblemente
`app/dashboard/page.tsx` si el wrapper se modifica.
---
## 5. Entregables (DoD)
- [ ] Cero emojis en títulos de widgets ni KPI cards (`grep` verificado).
- [ ] 4 KPI cards muestran título + subtítulo en los 3 estados (loading/ok/suppressed).
- [ ] 9 widgets muestran título limpio + subtítulo descriptivo con código de pregunta.
- [ ] Build sin errores (`next build`).
- [ ] `git diff --name-only HEAD` — solo archivos de presentación del dashboard.
- [ ] Captura del resultado en estado suppressed compartida con el director.
## 6. Qué reportar
```
Verificado:
- archivos en diff: [lista]
- emojis residuales (grep): [0 o lista]
- 4 KPI cards con label visible en suppressed: confirmado
- 9 widgets con subtítulo aplicado: lista
- build exitoso
Asumido:
- los gráficos siguen siendo SVG inline (de Etapa 3); interacción no
evaluable hasta tener N≥5 en al menos un widget
Decisiones proactivas (Nivel 2):
- [si las hubo]
```
## 7. Qué NO hacer
- ❌ No tocar RPC, RLS, esquema ni migraciones.
- ❌ No modificar `lib/dashboard-queries.ts` ni `lib/dashboard-types.ts` salvo
para extender los tipos del componente KPI si fuera necesario.
- ❌ No cambiar la lógica de filtros del sidebar.
- ❌ No agregar widgets nuevos ni cambiar layout.
- ❌ No agregar dependencias npm.
- ❌ No reemplazar SVG inline por una librería (eso es Etapa 2E si hace falta,
decisión Nivel 3).
- ❌ No resolver ítems de TECH_DEBT.md.

View File

@@ -0,0 +1,292 @@
# ETAPA 2E — Refactorización del dashboard alineada al instrumento real
## 1. Objetivo
Corregir la deuda semántica de Etapa 2D: las RPC, KPIs y subtítulos del dashboard se
construyeron a partir de códigos de pregunta inventados en el prompt original, no de los
códigos reales de `sprints/sprint-1/SEED_SPEC.md`. Esto generó métricas mal etiquetadas
que el dashboard mostraría como verdaderas cuando lleguen datos reales.
**Esta etapa toca la capa de datos (Nivel 3, autorizado por el director).** Reescribe
RPCs, agrega una pregunta nueva al instrumento, elimina un KPI sin sustento, redefine
un widget y renombra otro.
---
## 2. Contexto previo
- Etapa 2D entregada con subtítulos basados en códigos inventados.
- El director auditó subtítulo por subtítulo contra `SEED_SPEC.md` y encontró que
11 de 12 widgets/KPIs tenían discrepancias.
- El instrumento real (`SEED_SPEC.md` v2) tiene 44 preguntas. Está en el repo.
- En base de Cloud hay respuestas de testing — se truncan en esta etapa.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
### 3.1 Truncado de datos de testing
Antes de aplicar migraciones nuevas, **borrar todas las respuestas de testing** del
survey piloto. Mantener `surveys`, `questions`, `consent_versions`. Borrar:
- `responses` del survey `30000000-0000-0000-0000-000000000001`
- `answers` asociadas
- `consent_records` asociados
### 3.2 Nueva pregunta del instrumento — A1b Departamento
Agregar al seed, **antes** de A2 (Ciudad):
```
A1b · single_choice · "¿En qué departamento de Paraguay vivís?" *
Opciones (18 departamentos):
Asunción · Central · Alto Paraná · Itapúa · Caaguazú · San Pedro ·
Cordillera · Paraguarí · Concepción · Guairá · Caazapá · Misiones ·
Ñeembucú · Amambay · Canindeyú · Presidente Hayes · Boquerón · Alto Paraguay
```
- `qi_zone` ← A1b (cuasi-identificador para el k-anonimato).
- A1 (País) **se mantiene** por ahora (lo decidiremos en BACKLOG).
### 3.3 KPI 4 (NPS) — ELIMINADO
El instrumento no tiene pregunta NPS. Eliminar el KPI completamente del layout. El
grid de KPIs queda en **3 cards**, no 4. Quitar `rpc_nps_*` si existe.
### 3.4 Widget 6 (Políticas) — REDEFINIDO
El instrumento no captura "políticas que la empresa ya ofrece". Eliminar el componente
de comparación. El widget muestra **solo demanda** desde 6.2 (apoyos que ayudarían más,
multi-select, top 8).
### 3.5 Widget 8 — RENOMBRADO
"Tabla de riesgo de retención" → **"Saturación del cuidador"**. El instrumento no
captura intención de salida directa; el indicador compuesto mide saturación, no
intención de irse. Cambiar título y subtítulo.
### 3.6 Mapeo final widget → códigos del seed real
**KPI Cards (3, sin NPS)**
| # | Título | Definición y código real | Subtítulo |
|---|---|---|---|
| K1 | CUIDADORES ACTIVOS | % con `1.6='Sí'` OR `1.13='Sí'` | `1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros` |
| K2 | FRECUENCIA DE AUSENTISMO | % con `5.2 IN ('Varias veces (410 veces al año)', 'Con frecuencia (más de 10 veces al año)')` | `5.2 · % con ausencias laborales recurrentes (4 o más veces al año)` |
| K3 | BIENESTAR | Promedio del rating `4.3` (escala 15, mostrar como /5 o como %) | `4.3 · autorreporte de bienestar general · escala 15` |
**Widgets (9, con W6 redefinido y W8 renombrado)**
| # | Título | Códigos reales | Subtítulo |
|---|---|---|---|
| W1 | Prevalencia de cuidadores por departamento | `A1b` (nueva) cruzado con `1.6 1.13` | `A1b · % cuidadores activos por departamento` |
| W2 | Perfil de la población | `1.1 + 1.6 + 1.13` (no excluyentes) | `1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)` |
| W3 | Madurez organizacional | Score = 0.7 × `6.1_norm` + 0.3 × `6.5_norm`, ambas normalizadas 0100 | `6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0100` |
| W4 | Horas de cuidado × Frecuencia de ausentismo | `2.2 × 5.2` | `2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente` |
| W5 | Pirámide demográfica | `A5 × A6` | `A5 × A6 · rango etario por género (celdas con k ≥ 5)` |
| W6 | Apoyos más demandados | `6.2` (multi-select, max 3) | `6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían` |
| W7 | Intensidad de cuidado vs. Bienestar | `2.2 × 4.3` | `2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k ≥ 5)` |
| W8 | **Saturación del cuidador** | Compuesto: `4.3 ≤ 2` AND `5.1 = "Casi todos los días"` AND `5.2 ≥ "Varias veces (410 veces al año)"` | `4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)` |
**Definición de segmentos para W8 (Saturación):**
- **Alta saturación:** los 3 criterios se cumplen
- **Saturación media:** 2 de los 3 criterios
- **Baja saturación:** 1 o ninguno
Columnas de la tabla: Segmento · N · % del total · Apoyo más demandado del segmento
(de 6.2). Cada segmento con N < 5 se muestra como "—" (suprimido).
### 3.7 Normalización para Madurez (W3)
`6.1` tiene 4 opciones, normalizar:
- "Sí, me siento acompañado/a" = 100
- "Parcialmente, hay cierta comprensión" = 66
- "No, es algo bastante invisible" = 33
- "No, nunca se ha hablado del tema" = 0
`6.5` tiene 4 opciones, normalizar:
- "Sí, definitivamente" = 100
- "Tal vez, dependería del caso" = 66
- "No lo considero necesario" = 33
- "No lo sé" = 0 (o NULL — decidir en RPC, ver tarea 4.3)
Score final = 0.7 × promedio(6.1_norm) + 0.3 × promedio(6.5_norm)
---
## 4. Tareas
### 4.1 Migración 009 — Truncado + nueva pregunta A1b
Crear `supabase/migrations/0YYYYMMDDHHMMSS_009_add_departamento_paraguay.sql`:
1. `BEGIN;`
2. `TRUNCATE answers, responses, consent_records CASCADE;` (de Cloud)
3. INSERT en `questions` la pregunta A1b con sus 18 opciones (`question_options`).
Posición: entre A1 y A2.
4. Actualizar el orden de las preguntas de la sección A si es necesario.
5. `COMMIT;`
Validación post-migración:
- `SELECT COUNT(*) FROM answers;` → 0
- `SELECT code, text FROM questions WHERE code='A1b';` → 1 fila
- `SELECT COUNT(*) FROM question_options qo JOIN questions q ON qo.question_id=q.id WHERE q.code='A1b';` → 18
### 4.2 Migración 010 — Refactor de RPCs del dashboard
Crear `supabase/migrations/0YYYYMMDDHHMMSS_010_dashboard_rpc_v2.sql`.
DROP y recreate todas las funciones del dashboard con los nuevos mapeos. Lista:
- `rpc_dashboard_kpis` — devuelve 3 KPIs (sin NPS), con los códigos correctos
- `rpc_prevalencia_por_depto` — usa A1b, NO A1
- `rpc_perfil_poblacion` (antes `rpc_distribucion_cuidado`) — 1.1 + 1.6 + 1.13
- `rpc_score_organizacional` — nueva fórmula 70/30
- `rpc_heatmap_intensidad` — 2.2 × 5.2
- `rpc_piramide_demografica` — sin cambios estructurales
- `rpc_apoyos_demandados` (antes `rpc_politicas_gap`) — solo 6.2, sin comparación
- `rpc_intensidad_vs_bienestar` (antes `rpc_intensidad_vs_productividad`) — 2.2 × 4.3
- `rpc_saturacion_cuidador` (antes `rpc_riesgo_retencion`) — 4.3 + 5.1 + 5.2
**Todas las RPC mantienen:**
- `SECURITY DEFINER` con `search_path` fijado
- Supresión `k ≥ 5` por construcción (devuelven `status='suppressed'` si N<5)
- Casteo seguro de TEXT a numérico cuando aplique
- Filtros globales (departamento ahora viene de A1b, no A1)
**El sidebar de filtros también cambia:**
- "Departamento" ahora hace dropdown contra las 18 opciones reales de A1b
- "Género" sigue contra A6
- "Modalidad" sigue contra A7
- "Etario" sigue contra A5
### 4.3 Decisión local: cómo tratar "No lo sé" en 6.5
Si el agente lo decide localmente, **debe reportar la decisión** como Nivel 2.
Opciones razonables:
- Tratar como NULL → no entra al promedio
- Tratar como 0 → baja el score
**Recomendación:** tratar como NULL (no entra al promedio) — "no lo sé" no es opinión.
### 4.4 Cliente — eliminar K4 (NPS)
- Quitar el componente del grid de KPIs
- Reajustar el layout del grid (de 4 cols a 3 cols, o 3 cards en 4 cols con la cuarta vacía)
- Eliminar tipo TypeScript asociado y query del cliente
- `grep -r "NPS\|nps" components/dashboard/ app/dashboard/ lib/dashboard*` debe
retornar vacío después
### 4.5 Cliente — redefinir Widget 6 (Apoyos)
- Renombrar archivo `politicas-bar.tsx``apoyos-bar.tsx` (o equivalente)
- Eliminar la lógica de comparación "demandadas vs ofrecidas"
- Mostrar solo barras horizontales con top 8 de 6.2 (multi-select, max 3 por respondente)
- Color único: `#43BBC8`. Eliminar el color secundario `#F8AD13` para "ofrecidas".
### 4.6 Cliente — renombrar Widget 8 (Saturación)
- Renombrar archivo `tabla-riesgo.tsx``tabla-saturacion.tsx`
- Título: "Saturación del cuidador" (no "Riesgo de retención")
- Etiquetas de segmentos: "Alta saturación" / "Saturación media" / "Baja saturación"
- Columnas: Segmento · N · % del total · Apoyo más demandado
### 4.7 Cliente — aplicar todos los subtítulos correctos
Reemplazar los 11 subtítulos con los textos exactos de la tabla en sección 3.6.
### 4.8 Documentar deuda técnica
Agregar a `TECH_DEBT.md` (o crearlo si no existe) los siguientes ítems:
```markdown
[TECH-DEBT-002] KPI NPS Cuidador
- Estado: el instrumento actual no tiene pregunta NPS.
- Si en el futuro se quiere medir NPS hacia la empresa de cuidadores, agregar
pregunta en próxima versión del instrumento: "En una escala de 0 a 10,
¿qué tan probable es que recomiendes esta empresa a otra persona cuidadora?"
- Hasta entonces, el KPI no existe en el dashboard.
[TECH-DEBT-003] Brecha de políticas (oferta vs demanda)
- Estado: el instrumento captura solo demanda (6.2 = apoyos que ayudarían).
- No captura qué políticas la empresa ya ofrece.
- Para construir el widget de brecha, agregar pregunta en próxima versión:
"¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las
mismas opciones de 6.2 + "Ninguno" + "No lo sé").
- Hasta entonces, W6 muestra solo demanda.
[TECH-DEBT-004] Intención directa de salida laboral
- Estado: el instrumento no pregunta directamente "¿considerás dejar este trabajo?".
- El widget W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación".
- Para construir un indicador de intención de salida real, agregar pregunta:
"En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas
al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir).
[TECH-DEBT-005] A1 (País) vs A1b (Departamento)
- A1 sigue presente en el seed después de Etapa 2E.
- En contexto del piloto (Paraguay únicamente), A1 es redundante: 100% responde Paraguay.
- Decidir antes de Sprint 3: eliminar A1 del instrumento, o mantenerlo si la
plataforma se va a regionalizar (Uruguay, Bolivia, etc.) en el corto plazo.
```
### 4.9 Verificación cruzada — patrón nuevo C.8
Antes de declarar terminado, el agente ejecuta este check (Patrón C.8 propuesto):
```bash
# Cada subtítulo referenciado en el cliente debe tener su código documentado en SEED_SPEC.md
grep -oE '"[0-9A-Z]+(\.[0-9]+)?b?\s*[·+×]' components/dashboard/*.tsx | \
awk '{print $1}' | sort -u
# El agente verifica esta lista contra los códigos de SEED_SPEC.md
```
Si algún código del cliente no está en `SEED_SPEC.md`, **DETENERSE y reportar**
antes de declarar terminado. No ejecutar literalmente y delegar al director
(eso sería AP.10 — propagación silenciosa de inconsistencia).
---
## 5. Entregables (DoD)
- [ ] Migración 009 aplicada en Cloud (truncado + A1b con 18 opciones)
- [ ] Migración 010 aplicada en Cloud (9 RPCs refactorizadas)
- [ ] Test post-migración: con N=0 (truncado) todas las RPCs devuelven `suppressed`
- [ ] Test con N≥5 de datos sintéticos (insertados ad-hoc) — al menos 1 RPC devuelve `ok`
- [ ] `grep -r "NPS\|nps" components/dashboard/ app/dashboard/ lib/dashboard*` → vacío
- [ ] Subtítulos del cliente match exactamente con la tabla 3.6 del prompt
- [ ] Sidebar de filtros: dropdown de Departamento usa A1b, no A1
- [ ] Verificación cruzada (4.9) ejecutada sin discrepancias
- [ ] `TECH_DEBT.md` actualizado con TECH-DEBT-002, 003, 004, 005
- [ ] Build sin errores (`next build`)
- [ ] `git diff --name-only HEAD` solo en scope (migrations, components/dashboard,
lib/dashboard*, TECH_DEBT.md)
- [ ] Captura del dashboard en estado `suppressed` compartida (Principio 2)
## 6. Qué reportar
```
Verificado:
- Migración 009 aplicada: rows truncados=X, A1b insertada con 18 opciones ✓
- Migración 010 aplicada: 9 RPCs DROPped y CREATEd ✓
- Test N=0: todas las RPCs → suppressed ✓
- Test con datos sintéticos: [lista de RPCs con ok / suppressed]
- grep NPS: vacío ✓
- Subtítulos: 13 textos exactos verificados contra tabla 3.6 ✓
- Sidebar Departamento usa A1b ✓
- Verificación cruzada C.8: [resultado]
- TECH_DEBT.md: ítems 002005 agregados ✓
- Build exitoso · diff en scope ✓
Asumido:
- Decisión local "No lo sé" en 6.5 → [NULL / 0 / otra]
- [lo que no se pudo verificar]
Decisiones proactivas (Nivel 2):
- [lista]
Conflictos de scope detectados (Patrón C.8):
- [si los hubo, listarlos AQUÍ y DETENER antes de ejecutar]
```
## 7. Qué NO hacer
- ❌ No tocar el formulario (`/app/(survey)/`, `app/actions/submit-survey.ts`).
- ❌ No tocar RLS de write existente (la migración 011 anterior es intocable).
- ❌ No agregar dependencias npm.
- ❌ No inventar códigos de pregunta. Si un código nombrado en este prompt no
coincide con `SEED_SPEC.md`, DETENERSE y reportar (Patrón C.8). NO ejecutar
"literalmente" y delegar al director.
- ❌ No tocar la estructura de capas (sigue siendo cliente → Route Handler → RPC).
- ❌ No agregar widgets nuevos. Esta etapa es refactor, no extensión.
- ❌ No resolver ítems del backlog que no sean los 4 listados en sección 4.8.
---
## Apéndice — Patrón nuevo propuesto C.8
Si esta etapa cierra limpia, agregar a `method/PATTERNS.md`:
> **C.8 — Verificación de códigos contra fuente única · *deriva de P3, P4 · origen:
> 2do proyecto*.** Cuando un prompt menciona códigos de un instrumento, esquema o
> contrato (preguntas, columnas, campos), el agente verifica cada código contra la
> fuente única (`SEED_SPEC.md`, `schema.sql`, etc.) **antes** de ejecutar. Si
> detecta una discrepancia, DETIENE la ejecución y reporta el conflicto. Ejecutar
> "literalmente" y delegar la verificación semántica al director es AP.10
> (propagación silenciosa de inconsistencia). Respuesta directa al caso Etapa 2D del
> proyecto Datos País.

View File

@@ -0,0 +1,6 @@
-- Migration 011: Truncar datos de testing
-- Ejecutado directamente el 2026-06-05 antes del push (tablas en 0 al momento del push).
-- TRUNCATE en tablas ya vacías es idempotente — este archivo solo registra el evento.
BEGIN;
TRUNCATE TABLE public.answers, public.responses, public.consent_records RESTART IDENTITY CASCADE;
COMMIT;

View File

@@ -0,0 +1,677 @@
-- ============================================================
-- Migration 012: Dashboard RPCs v2 — alineación al instrumento real
-- Correcciones respecto a migration 009:
-- _dash_responses : departamento filtra por responses.qi_zone (no JOIN a answers)
-- rpc_dashboard_kpis : K1 = 1.6+1.13 (sin 1.1); K2 = 5.2 en-dash; K3 = avg 4.3 raw 15
-- rpc_prevalencia_por_depto : group by qi_zone
-- rpc_perfil_poblacion : (era rpc_distribucion_cuidado) — renombrada
-- rpc_score_organizacional : 0.7×6.1 + 0.3×6.5; 6.5='No lo sé' → NULL
-- rpc_apoyos_demandados : (era rpc_politicas_gap) — solo 6.2, renombrada
-- rpc_intensidad_vs_bienestar : (era rpc_intensidad_vs_productividad) — renombrada
-- rpc_saturacion_cuidador : (era rpc_riesgo_retencion) — nueva lógica 4.3+5.1+5.2
-- Todas mantienen: SECURITY DEFINER, search_path fijado, k-anonimato k≥5.
-- ============================================================
-- Eliminar funciones renombradas
DROP FUNCTION IF EXISTS public.rpc_distribucion_cuidado(uuid, jsonb);
DROP FUNCTION IF EXISTS public.rpc_politicas_gap(uuid, jsonb);
DROP FUNCTION IF EXISTS public.rpc_intensidad_vs_productividad(uuid, jsonb);
DROP FUNCTION IF EXISTS public.rpc_riesgo_retencion(uuid, jsonb);
-- ── Helper: responses filtradas por cohort ────────────────────
-- Usa qi_zone directamente (poblado desde A1 en submit-survey.ts).
-- Evita un JOIN a answers por consulta.
CREATE OR REPLACE FUNCTION public._dash_responses(
p_survey_id uuid,
p_depto text DEFAULT NULL,
p_genero text[] DEFAULT NULL,
p_etario text[] DEFAULT NULL,
p_modalidad text[] DEFAULT NULL
)
RETURNS TABLE(response_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT r.id
FROM public.responses r
WHERE r.survey_id = p_survey_id
AND (p_depto IS NULL OR r.qi_zone = p_depto)
AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A6'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;
-- ── Helper: parse filters JSONB → arrays ──────────────────────
CREATE OR REPLACE FUNCTION public._dash_parse_filters(p_filters jsonb)
RETURNS TABLE(
f_depto text,
f_genero text[],
f_etario text[],
f_modalidad text[]
)
LANGUAGE sql IMMUTABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT
NULLIF(p_filters->>'depto',''),
ARRAY(SELECT jsonb_array_elements_text(p_filters->'genero'))::text[],
ARRAY(SELECT jsonb_array_elements_text(p_filters->'etario'))::text[],
ARRAY(SELECT jsonb_array_elements_text(p_filters->'modalidad'))::text[];
$$;
-- ── KPIs ─────────────────────────────────────────────────────
-- K1: cuidadores = 1.6='Sí' OR 1.13='Sí'
-- K2: 5.2 IN ('Varias veces (410 veces al año)', 'Con frecuencia (más de 10 veces al año)') ← en-dash
-- K3: promedio raw de 4.3 (rating 15), devuelto como decimal 1.05.0
CREATE OR REPLACE FUNCTION public.rpc_dashboard_kpis(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_cuidadores bigint;
v_bienestar_avg numeric;
v_bienestar_n bigint;
v_ausencias bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_n
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
-- K1: 1.6='Sí' OR 1.13='Sí' (cuidadores de otros)
SELECT count(DISTINCT dr.response_id) INTO v_cuidadores
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
WHERE EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = dr.response_id
AND q.code IN ('1.6','1.13')
AND a.value #>> '{}' = 'Sí'
);
-- K3: promedio raw 4.3 (15)
SELECT avg((a.value #>> '{}')::numeric), count(*)
INTO v_bienestar_avg, v_bienestar_n
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '4.3'
AND a.value #>> '{}' ~ '^[0-9]+$';
-- K2: ausencias recurrentes 5.2 — EN-DASH obligatorio
SELECT count(DISTINCT dr.response_id) INTO v_ausencias
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '5.2'
AND a.value #>> '{}' IN (
'Varias veces (410 veces al año)',
'Con frecuencia (más de 10 veces al año)'
);
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_cuidadores', ROUND(v_cuidadores::numeric / v_n * 100, 1),
'score_bienestar', CASE WHEN v_bienestar_n > 0
THEN ROUND(v_bienestar_avg, 1)
ELSE NULL END,
'pct_ausencias', ROUND(v_ausencias::numeric / v_n * 100, 1)
);
END;
$$;
-- ── Prevalencia por departamento ──────────────────────────────
-- Usa qi_zone de responses (sin JOIN a answers para departamento).
-- Cuidadores = 1.6='Sí' OR 1.13='Sí'.
CREATE OR REPLACE FUNCTION public.rpc_prevalencia_por_depto(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'depto', depto_val,
'n', CASE WHEN n_depto >= 5 THEN n_depto ELSE NULL END,
'pct_cuidadores', CASE WHEN n_depto >= 5
THEN ROUND(n_cuid::numeric / n_depto * 100, 1)
ELSE NULL END,
'suppressed', (n_depto < 5)
) AS row
FROM (
SELECT
r.qi_zone AS depto_val,
count(DISTINCT dr.response_id) AS n_depto,
count(DISTINCT dr.response_id) FILTER (
WHERE EXISTS (
SELECT 1 FROM public.answers a2
JOIN public.questions q2 ON q2.id = a2.question_id
WHERE a2.response_id = dr.response_id
AND q2.code IN ('1.6','1.13')
AND a2.value #>> '{}' = 'Sí'
)
) AS n_cuid
FROM public._dash_responses(p_survey_id, NULL, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.responses r ON r.id = dr.response_id
WHERE r.qi_zone IS NOT NULL
GROUP BY r.qi_zone
) sub
ORDER BY depto_val
) t;
RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ── Perfil de la población (era rpc_distribucion_cuidado) ─────
-- 1.1 = disc. propia, 1.6 = familiar, 1.13 = adulto mayor
CREATE OR REPLACE FUNCTION public.rpc_perfil_poblacion(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_disc bigint;
v_familiar bigint;
v_mayor bigint;
v_ninguno bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT count(DISTINCT dr.response_id) INTO v_disc
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '1.1' AND a.value #>> '{}' = 'Sí';
SELECT count(DISTINCT dr.response_id) INTO v_familiar
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '1.6' AND a.value #>> '{}' = 'Sí';
SELECT count(DISTINCT dr.response_id) INTO v_mayor
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '1.13' AND a.value #>> '{}' = 'Sí';
SELECT count(DISTINCT dr.response_id) INTO v_ninguno
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
WHERE NOT EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = dr.response_id
AND q.code IN ('1.1','1.6','1.13')
AND a.value #>> '{}' = 'Sí'
);
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'disc_propia', ROUND(v_disc::numeric / v_total * 100, 1),
'familiar', ROUND(v_familiar::numeric / v_total * 100, 1),
'adulto_mayor', ROUND(v_mayor::numeric / v_total * 100, 1),
'ninguno', ROUND(v_ninguno::numeric / v_total * 100, 1)
);
END;
$$;
-- ── Score organizacional (Madurez) ────────────────────────────
-- Formula: 0.7 × 6.1_norm + 0.3 × 6.5_norm
-- 6.1: Sí=100, Parcialmente=66, No invisible=33, No nunca=0
-- 6.5: Sí=100, Tal vez=66, No necesario=33, No lo sé=NULL (no entra al avg)
CREATE OR REPLACE FUNCTION public.rpc_score_organizacional(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_score_6_1 numeric;
v_score_6_5 numeric;
v_score_final numeric;
v_zona text;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT AVG(CASE
WHEN a.value #>> '{}' = 'Sí, me siento acompañado/a' THEN 100.0
WHEN a.value #>> '{}' = 'Parcialmente, hay cierta comprensión' THEN 66.0
WHEN a.value #>> '{}' = 'No, es algo bastante invisible' THEN 33.0
WHEN a.value #>> '{}' = 'No, nunca se ha hablado del tema' THEN 0.0
ELSE NULL
END) INTO v_score_6_1
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.1';
-- 'No lo sé' → NULL: AVG ignora NULLs en PostgreSQL
SELECT AVG(CASE
WHEN a.value #>> '{}' = 'Sí, definitivamente' THEN 100.0
WHEN a.value #>> '{}' = 'Tal vez, dependería del caso' THEN 66.0
WHEN a.value #>> '{}' = 'No lo considero necesario' THEN 33.0
WHEN a.value #>> '{}' = 'No lo sé' THEN NULL
ELSE NULL
END) INTO v_score_6_5
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.5';
v_score_final := ROUND(
0.7 * COALESCE(v_score_6_1, 0.0) + 0.3 * COALESCE(v_score_6_5, 0.0),
1
);
v_zona := CASE
WHEN v_score_final < 34 THEN 'baja'
WHEN v_score_final < 67 THEN 'desarrollo'
ELSE 'consciente'
END;
RETURN jsonb_build_object('status','ok','n',v_total,'score',v_score_final,'zona',v_zona);
END;
$$;
-- ── Heatmap: horas de cuidado × frecuencia ausentismo (2.2 × 5.2) ──
-- Sin cambios estructurales respecto a migration 009.
CREATE OR REPLACE FUNCTION public.rpc_heatmap_intensidad(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'horas', horas_bin,
'ausencias', aus_bin,
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
'suppressed', (cnt < 5)
) AS row
FROM (
SELECT
ah.value #>> '{}' AS horas_bin,
aa.value #>> '{}' AS aus_bin,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers ah ON ah.response_id = dr.response_id
JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2'
JOIN public.answers aa ON aa.response_id = dr.response_id
JOIN public.questions qa ON qa.id = aa.question_id AND qa.code = '5.2'
GROUP BY 1, 2
) sub
ORDER BY horas_bin, aus_bin
) t;
RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ── Pirámide demográfica (A5 × A6) ───────────────────────────
-- Sin cambios respecto a migration 009.
CREATE OR REPLACE FUNCTION public.rpc_piramide_demografica(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'etario', etario_val,
'genero', genero_val,
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
'suppressed', (cnt < 5)
) AS row
FROM (
SELECT
ae.value #>> '{}' AS etario_val,
ag.value #>> '{}' AS genero_val,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers ae ON ae.response_id = dr.response_id
JOIN public.questions qe ON qe.id = ae.question_id AND qe.code = 'A5'
JOIN public.answers ag ON ag.response_id = dr.response_id
JOIN public.questions qg ON qg.id = ag.question_id AND qg.code = 'A6'
GROUP BY 1, 2
) sub
ORDER BY etario_val, genero_val
) t;
RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ── Apoyos más demandados (era rpc_politicas_gap) ────────────
-- Solo 6.2 (multi-select max 3). Top 8 por frecuencia, N≥5.
CREATE OR REPLACE FUNCTION public.rpc_apoyos_demandados(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object(
'politica', opt,
'n', cnt,
'pct', ROUND(cnt::numeric / v_total * 100, 1)
) AS row
FROM (
SELECT
jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.2'
AND jsonb_typeof(a.value) = 'array'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY cnt DESC
LIMIT 8
) t;
RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ── Intensidad de cuidado vs. Bienestar (era rpc_intensidad_vs_productividad) ──
-- 2.2 × 4.3 — sin cambios de lógica respecto a migration 009.
CREATE OR REPLACE FUNCTION public.rpc_intensidad_vs_bienestar(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'horas', horas_bin,
'bienestar', bien_bin,
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
'suppressed', (cnt < 5)
) AS row
FROM (
SELECT
ah.value #>> '{}' AS horas_bin,
ab.value #>> '{}' AS bien_bin,
count(DISTINCT dr.response_id) AS cnt
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers ah ON ah.response_id = dr.response_id
JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2'
JOIN public.answers ab ON ab.response_id = dr.response_id
JOIN public.questions qb ON qb.id = ab.question_id AND qb.code = '4.3'
WHERE ab.value #>> '{}' ~ '^[0-9]+$'
GROUP BY 1, 2
) sub
ORDER BY horas_bin, bien_bin
) t;
RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ── Saturación del cuidador (era rpc_riesgo_retencion) ───────
-- Indicador compuesto: 4.3 (bienestar) + 5.1 (rendimiento) + 5.2 (ausentismo)
-- Alta saturación: 3 criterios. Media: 2. Baja: 01.
-- Columnas: Segmento · N · % del total · Apoyo más demandado (6.2)
CREATE OR REPLACE FUNCTION public.rpc_saturacion_cuidador(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_total bigint;
v_n_alta bigint;
v_n_media bigint;
v_n_baja bigint;
v_apo_alta text;
v_apo_media text;
v_apo_baja text;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT count(*) INTO v_total
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
IF v_total < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
END IF;
-- Contar por segmento en una sola pasada
SELECT
count(*) FILTER (WHERE n_crit = 3),
count(*) FILTER (WHERE n_crit = 2),
count(*) FILTER (WHERE n_crit <= 1)
INTO v_n_alta, v_n_media, v_n_baja
FROM (
SELECT
dr.response_id,
(CASE WHEN EXISTS (
SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id
WHERE a.response_id=dr.response_id AND q.code='4.3'
AND a.value #>> '{}' ~ '^[0-9]+$'
AND (a.value #>> '{}')::int <= 2
) THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (
SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id
WHERE a.response_id=dr.response_id AND q.code='5.1'
AND a.value #>> '{}' = 'Casi todos los días'
) THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (
SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id
WHERE a.response_id=dr.response_id AND q.code='5.2'
AND a.value #>> '{}' IN (
'Varias veces (410 veces al año)',
'Con frecuencia (más de 10 veces al año)'
)
) THEN 1 ELSE 0 END) AS n_crit
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
) sub;
-- Apoyo más demandado (6.2) por segmento, solo si N≥5
IF v_n_alta >= 5 THEN
SELECT opt INTO v_apo_alta FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM (
SELECT response_id FROM (
SELECT dr2.response_id,
(CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (410 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2
) s WHERE n_crit = 3
) seg
JOIN public.answers a ON a.response_id = seg.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY opt ORDER BY cnt DESC LIMIT 1
) t;
END IF;
IF v_n_media >= 5 THEN
SELECT opt INTO v_apo_media FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM (
SELECT response_id FROM (
SELECT dr2.response_id,
(CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (410 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2
) s WHERE n_crit = 2
) seg
JOIN public.answers a ON a.response_id = seg.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY opt ORDER BY cnt DESC LIMIT 1
) t;
END IF;
IF v_n_baja >= 5 THEN
SELECT opt INTO v_apo_baja FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
count(DISTINCT dr.response_id) AS cnt
FROM (
SELECT response_id FROM (
SELECT dr2.response_id,
(CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END
+ CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (410 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2
) s WHERE n_crit <= 1
) seg
JOIN public.answers a ON a.response_id = seg.response_id
JOIN public.questions q ON q.id = a.question_id
WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY opt ORDER BY cnt DESC LIMIT 1
) t;
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n_total', v_total,
'segmentos', jsonb_build_array(
jsonb_build_object(
'segmento', 'Alta saturación',
'n', CASE WHEN v_n_alta >= 5 THEN v_n_alta ELSE NULL END,
'pct', CASE WHEN v_n_alta >= 5 THEN ROUND(v_n_alta::numeric/v_total*100,1) ELSE NULL END,
'apoyo', CASE WHEN v_n_alta >= 5 THEN v_apo_alta ELSE NULL END,
'suppressed', (v_n_alta < 5)
),
jsonb_build_object(
'segmento', 'Saturación media',
'n', CASE WHEN v_n_media >= 5 THEN v_n_media ELSE NULL END,
'pct', CASE WHEN v_n_media >= 5 THEN ROUND(v_n_media::numeric/v_total*100,1) ELSE NULL END,
'apoyo', CASE WHEN v_n_media >= 5 THEN v_apo_media ELSE NULL END,
'suppressed', (v_n_media < 5)
),
jsonb_build_object(
'segmento', 'Baja saturación',
'n', CASE WHEN v_n_baja >= 5 THEN v_n_baja ELSE NULL END,
'pct', CASE WHEN v_n_baja >= 5 THEN ROUND(v_n_baja::numeric/v_total*100,1) ELSE NULL END,
'apoyo', CASE WHEN v_n_baja >= 5 THEN v_apo_baja ELSE NULL END,
'suppressed', (v_n_baja < 5)
)
)
);
END;
$$;