Files
motor-de-encuestas/sprints/sprint-2/DASHBOARD.md
2026-06-05 07:18:10 -03:00

14 KiB
Raw Blame History

PROMPT: Dashboard "Economía del Cuidado" — Análisis Empresarial (v2)

Cambio clave respecto a v1: toda la agregación y los filtros de privacidad corren server-side (RPC de Postgres). El cliente NUNCA consulta answers crudo. El k-anonimato (k≥5) y la exclusión de campos sensibles son estructurales, no dependen de recordar un filtro en cada query.


Contexto de negocio

Dashboard analítico de la plataforma "Datos País sobre el Cuidado" (Paraguay). Es el producto de valor para las empresas participantes: entender el impacto de las responsabilidades de cuidado en su fuerza laboral y tomar decisiones basadas en evidencia.

Audiencia: directores de RRHH, sostenibilidad, gerencia general de empresas paraguayas.


Stack técnico

  • Frontend: Next.js 15 (App Router), TypeScript, Tailwind CSS
  • Base de datos: Supabase (PostgreSQL) — misma instancia que el formulario
  • Gráficos: Recharts (ya instalado)
  • Mapa: SVG inline de Paraguay (NO librería externa — ver Widget 1). Esto evita incompatibilidades de peer-deps con React 19 y no agrega dependencias (alineado con la política de gobernanza del método).
  • NO agregar nuevas dependencias npm.
  • Paleta RE: #43BBC8 (teal), #494F4F (oscuro), #F8AD13 (naranja), #EE761A (naranja intenso), #E7E6E5 (gris claro), blanco

⚠️ REGLAS DE PRIVACIDAD — NO NEGOCIABLES (leer primero)

Estas reglas son del proyecto (CLAUDE.md) y tienen prioridad sobre cualquier widget. Si un widget no puede cumplirlas, NO se construye ese widget — se reporta el conflicto.

  1. k-anonimato k≥5 por construcción. Toda celda, barra, punto, segmento o departamento que represente menos de 5 respondentes se SUPRIME (se muestra como "Datos insuficientes" o se agrega a un grupo mayor). No es post-filtro: las funciones RPC ya devuelven datos con la supresión aplicada. El cliente nunca ve un N<5.
  2. 3.4 (salario) jamás sale de la base. Protegido por RLS server-side que niega question_code='3.4' al rol usado por el dashboard. El dashboard NO usa este dato en ningún cálculo ni widget. No alcanza con filtrarlo en el cliente.
  3. 7.1 (texto libre) jamás llega al dashboard. Mismo tratamiento que 3.4: excluido en la capa de datos, no en el cliente.
  4. El cliente NUNCA consulta la tabla answers directamente. Todo pasa por funciones RPC que devuelven agregados ya suprimidos. Si el browser pudiera pedir filas crudas, la privacidad sería inspeccionable/evitable desde la consola.

Arquitectura de datos (server-side)

Capa 1 — Funciones RPC en Postgres (Supabase)

Crear funciones SQL (SECURITY DEFINER con search_path fijado) que:

  • Reciben survey_id + filtros opcionales (departamento, género, etario, contrato)
  • Calculan el agregado de cada widget
  • Aplican supresión k≥5 ANTES de devolver
  • Nunca tocan 3.4 ni 7.1
  • Devuelven JSON tipado

Las funciones van en una migración nueva: migrations/0XX_dashboard_rpc.sql. Listar las funciones a crear (una por widget o agrupadas por afinidad):

rpc_dashboard_kpis(survey_id, filters)        → KPIs + N total
rpc_prevalencia_por_depto(survey_id, filters) → mapa (con supresión por depto)
rpc_heatmap_intensidad(survey_id, filters)    → celdas con N≥5
rpc_distribucion_cuidado(survey_id, filters)  → donut
rpc_politicas_gap(survey_id, filters)         → barras (demandadas vs existentes)
rpc_score_organizacional(survey_id, filters)  → gauge
rpc_piramide_demografica(survey_id, filters)  → pirámide (celdas con N≥5)
rpc_riesgo_retencion(survey_id, filters)      → tabla (segmentos con N≥5)
rpc_intensidad_vs_productividad(survey_id, filters) → reemplazo del scatter (ver Widget 8)

Capa 2 — Route Handlers (Next.js)

app/api/dashboard/[widget]/route.ts — llaman a las RPC con el cliente server-side de Supabase (service_role NUNCA expuesto al browser; vive solo en el servidor). Validan que survey_id exista y esté live.

Capa 3 — Cliente

Los componentes hacen fetch('/api/dashboard/...'). Reciben datos ya agregados y ya suprimidos. No tienen acceso a Supabase directo.

Casteo de tipos

answer_value es TEXT. Las RPC deben castear explícitamente y manejar no-numéricos:

-- ejemplo dentro de una RPC
AVG(NULLIF(regexp_replace(answer_value, '[^0-9.]', '', 'g'), '')::numeric)
  FILTER (WHERE answer_value ~ '^[0-9.]+$')

Valores no parseables o nulos se excluyen del cálculo, no se cuentan como 0.


Esquema de base de datos (referencia)

surveys (id UUID PK, title TEXT, status TEXT, brand_config JSONB, created_at TIMESTAMP)
responses (id UUID PK, survey_id UUID FK, ip_hash TEXT, created_at TIMESTAMP)
answers (id UUID PK, response_id UUID FK, question_code TEXT, answer_value TEXT, created_at TIMESTAMP)
consent_records (id UUID PK, response_id UUID, operational_consent BOOL, macro_consent BOOL, created_at TIMESTAMP)

Códigos de preguntas (instrumento de 44)

  • A1 Departamento (18 deptos) · A2 Ciudad · A3 Barrio (texto) · A4 Género · A5 Rango etario · A6 Educativo · A7 Contrato
  • 1.1 Discapacidad propia (→1.2-1.5) · 1.6 Cuida familiar (→1.7-1.12) · 1.13 Cuida adulto mayor (→1.14-1.16)
  • 2.1-2.5 Intensidad (horas, tipo, red de apoyo)
  • 3.1 Ausencias días/mes · 3.2 Tardanzas · 3.3 Reducción horas · 3.4 Salario 🔒 · 3.5 Gasto en cuidados
  • 4.1-4.3 Sueño, tiempo libre, salud
  • 5.1 Productividad (1-5) · 5.2 Consideró dejar el trabajo (Sí/No)
  • 6.1 Empresa conoce (Sí/No/Parcial) · 6.2 Políticas que conoce (multi, max 3) · 6.3 Satisfacción (1-5) · 6.4 Políticas que necesita (multi) · 6.5 NPS (0-10)
  • 7.1 Texto libre 🔒

Ruta y estructura

/dashboard?survey_id=UUID

  • Header SurveyHeader existente (marca RE)
  • Sidebar izquierdo colapsable — filtros globales
  • Grid principal de widgets
  • Footer SurveyFooter existente

Filtros globales (sidebar)

Multi-select Departamento · checkbox Género · slider dual Rango etario · checkbox Contrato · botón Resetear. Los filtros se pasan como query params a los Route Handlers; las RPC los aplican server-side. Importante: al combinar filtros, el N puede caer bajo 5 → el widget muestra "Datos insuficientes para esta combinación de filtros" en lugar de datos.


WIDGETS

Estado de N insuficiente

TODO widget tiene 3 estados: loading (skeleton) · ok (datos) · suppressed ("Datos insuficientes — se necesitan al menos 5 respuestas en este corte"). El estado suppressed lo determina la RPC, no el cliente.

🗺️ WIDGET 1 — Mapa de Paraguay (OBLIGATORIO)

Qué muestra: prevalencia de cuidadores por departamento (% con Sí en 1.6 o 1.13). Implementación SVG inline:

  • Crear public/geo/paraguay-departamentos.svg o un componente con los paths de los 18 departamentos. Fuente del contorno: GeoJSON público de límites administrativos de Paraguay (nivel ADM1), simplificado. Si no se consigue un SVG fiel, usar una grilla esquemática de 18 celdas etiquetadas con los departamentos (cuadrícula tipo "tile map") — es aceptable como fallback y mantiene la interactividad.
  • Color por departamento: escala #E7E6E5 (baja) → #43BBC8 (alta).
  • Departamentos con N<5 se pintan en gris neutro con patrón rayado y tooltip "Datos insuficientes" — NO muestran porcentaje.
  • Hover: tooltip con nombre + % + N (solo si N≥5).
  • Click: filtra el dashboard a ese departamento.

📊 WIDGET 2 — KPI Cards

  1. % Cuidadores activos1.1=Sí OR 1.6=Sí OR 1.13=Sí
  2. Prom. días ausencia/mes — media de 3.1 (casteo numérico)
  3. Score bienestar — media de 5.1 (1-5 → 0-100%)
  4. NPS Cuidador — de 6.5. Solo se muestra si N≥10; si no, "N insuficiente para NPS". Card: número grande teal, label, animación de conteo al cargar.

🔥 WIDGET 3 — Heatmap intensidad vs. ausentismo

X = horas semanales (2.1, en bins) · Y = días ausencia (3.1, en bins). Celdas coloreadas por densidad. Celdas con N<5 se muestran vacías/neutras, no con su conteo real. Insight: correlación carga↔ausentismo.

🍩 WIDGET 4 — Donut: tipo de cuidado

% discapacidad propia (1.1) / cuidador familiar (1.6) / cuidador adulto mayor (1.13). Solapamiento calculado (categorías no excluyentes). Agregado simple, sin riesgo de k salvo que el total del survey sea <5 (entonces todo el widget es suppressed).

📈 WIDGET 5 — Barras: gap de políticas

Top 8 de 6.4 (necesitadas) vs 6.2 (existentes). Teal = necesidad, naranja = ya existe. Insight: brecha demanda/oferta de políticas.

🎯 WIDGET 6 — Gauge: madurez organizacional

Compuesto: 6.1 (Sí=1/Parcial=0.5/No=0) + 6.3 normalizado + 6.5 normalizado. Score 0-100. Zonas: 0-33 rojo "Baja madurez" / 34-66 naranja "En desarrollo" / 67-100 teal "Consciente".

👥 WIDGET 7 — Pirámide demográfica

Rango etario (A5) × género (A4). Mujeres izquierda (#EE761A), hombres derecha (#43BBC8), no binario (#F8AD13). Cada celda (etario×género) con N<5 se colapsa al rango contiguo o se omite con nota al pie.

💸 WIDGET 8 — Intensidad de cuidado vs. productividad (REEMPLAZA al scatter)

El scatter original graficaba un punto por persona = dato individual, reidentificable. Se reemplaza por un hexbin / binned heatmap agregado:

  • X = horas de cuidado (2.1, bins) · Y = productividad (5.1, 1-5)
  • Color de cada bin = cantidad de respondentes (solo bins con N≥5)
  • Línea de tendencia calculada sobre los bins, no sobre individuos
  • Insight idéntico al scatter (relación carga↔productividad) sin exponer personas
  • NO usa 3.5 (gasto) como en v1, porque cruzar gasto+productividad+género+horas en un punto individual era el vector de reidentificación más fuerte.

📋 WIDGET 9 — Tabla de riesgo de retención

Segmentos: Alto (5.2=Sí AND 6.3≤2) / Medio (5.2=Sí OR 6.3≤2) / Bajo (resto). Columnas: Segmento · N · % del total · Política más demandada del segmento.

Cambio vs v1: se ELIMINA la columna "Depto. más frecuente". Cruzar segmento de riesgo × departamento producía celdas de N=1-2 reidentificables en el piloto. Cada segmento con N<5 se muestra como "—" (suprimido), no con su conteo.


Diseño visual — "deslumbrante"

  • Fondo #0F1923 (oscuro premium) · widgets #1A2535 con borde teal 20% opacity
  • Texto principal blanco, secundario #E7E6E5
  • CSS Grid 12 columnas, responsive
  • KPI con counter animation (requestAnimationFrame), tabular-nums
  • Hover en widget: box-shadow: 0 0 20px #43BBC830
  • Transición de filtro 300ms ease

Layout (12 cols):

Fila 1: [KPI×4] cada uno 3 cols
Fila 2: [MAPA 6 cols][DONUT 3 cols][GAUGE 3 cols]
Fila 3: [HEATMAP 6 cols][PIRÁMIDE 6 cols]
Fila 4: [BARRAS 6 cols][INTENSIDAD/PRODUCTIVIDAD 6 cols]
Fila 5: [TABLA RIESGO 12 cols]

Archivos a crear

migrations/
  0XX_dashboard_rpc.sql        — funciones RPC con supresión k≥5
  0XX_rls_deny_sensitive.sql   — RLS que niega 3.4 y 7.1 al rol del dashboard

app/dashboard/
  page.tsx
  loading.tsx
app/api/dashboard/
  [widget]/route.ts            — o un route.ts por widget

components/dashboard/
  kpi-cards.tsx · paraguay-map.tsx · heatmap-widget.tsx · donut-cuidados.tsx
  politicas-bar.tsx · gauge-organizacional.tsx · piramide-demografica.tsx
  intensidad-productividad.tsx · tabla-riesgo.tsx · filter-sidebar.tsx · widget-shell.tsx

lib/
  dashboard-queries.ts  — fetch a los Route Handlers (NO Supabase directo)
  dashboard-types.ts
  dashboard-utils.ts    — NPS, score organizacional, regresión sobre bins

Entregables (DoD)

  • Migración de RPC aplicada; cada función aplica supresión k≥5 internamente
  • Migración RLS que niega 3.4 y 7.1 aplicada y verificada
  • El cliente NO importa el cliente de Supabase para datos — solo hace fetch a /api/dashboard
  • /dashboard?survey_id=UUID carga con datos reales
  • Los 9 widgets renderizan en uno de los 3 estados (loading/ok/suppressed)
  • Mapa de Paraguay interactivo (hover + click filtra)
  • Filtros globales afectan todos los widgets via query params server-side
  • Widget 8 es agregado (hexbin), NO scatter individual
  • Tabla de riesgo SIN columna de departamento
  • Test manual: con un survey de N=3, todos los widgets muestran "suppressed"
  • grep -r "3.4" app/ components/ lib/ no encuentra el código usándolo como dato
  • Build sin errores (next build)
  • git diff --name-only HEAD — solo archivos del scope

Qué NO hacer

  • Cliente consultando answers directo (todo via RPC/Route Handler)
  • Filtros de privacidad en el cliente como única protección
  • Exponer 3.4 o 7.1 en cualquier capa
  • Widgets que grafiquen individuos (scatter de personas, tablas con cruces de N<5)
  • service_role en código de cliente o en NEXT_PUBLIC_*
  • Agregar dependencias npm
  • Tocar el formulario, app/actions/ de submit, o RLS existente de escritura

Qué reportar

Verificado:
- migración RPC aplicada: [funciones creadas]
- migración RLS aplicada: 3.4 y 7.1 negados — confirmado con query de prueba
- cliente sin acceso directo a answers: confirmado (grep)
- build exitoso
- archivos creados: [lista]
- widgets en estado ok / suppressed con datos actuales: [lista]
- prueba k-anonimato (survey N<5 → suppressed): [resultado]
- mapa interactivo: [descripción]

Asumido:
- validación visual pendiente del director

Decisiones proactivas (Nivel 2):
- [cambios fuera de scope exacto]

Conflictos de scope (si git diff mostró algo no autorizado):
- [archivo + qué se hizo]