> ## Documentation Index
> Fetch the complete documentation index at: https://wiki.vivla.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reporte semanal NPS

> Lógica del reporte semanal de NPS: métricas, fórmulas, ventanas, fuentes y el filtro de reservas disfrutadas en casa activa

# Reporte semanal NPS

El **Reporte semanal NPS** resume la satisfacción de propietarios e invitados por métrica y por periodo. Existe en dos superficies que comparten **exactamente el mismo payload**:

* **Email de los lunes** — Windmill cron `0 9 * * 1` → `POST /surveys/reports/weekly-nps/send` (autenticado con `x-api-key`). Renderiza el payload con la plantilla React Email y lo envía a `WEEKLY_NPS_REPORT_TO` (lista separada por comas/punto y coma).
* **Vista in-app "Reporte semanal"** — pestaña Reportes de Resultados de encuestas, `GET /surveys/reports/weekly-nps` (rol `tool-chat` viewer). Acepta `?week=<ISO>` para navegar semanas.

Ambas llaman a `WeeklyNpsReportService.computePayload()`. Documentar el cálculo aquí lo deja bien definido: una sola fuente de verdad para email y vista.

```
apps/backend/src/surveys/reports/
  weekly-nps-report.service.ts   # computePayload(): orquesta ventanas y métricas
  weekly-nps-data.service.ts     # acceso a Supabase (queries por ventana + globales)
  weekly-nps-compute.ts          # helpers puros (ventanas, normalización, NPS Bain, filtros)
  weekly-nps.constants.ts        # slugs incluidos, targets
  templates/WeeklyNpsEmail.tsx   # plantilla del email
```

## Ventanas de tiempo

El reporte muestra una columna por ventana, en orden: **Anual (YTD)**, un **trimestre por cada trimestre transcurrido** del año, y **Semana anterior**. Se calculan en `buildWindows()` (`weekly-nps-compute.ts`):

| Columna         | Desde                | Hasta (exclusivo)                  |
| --------------- | -------------------- | ---------------------------------- |
| Anual           | 1 ene del año        | este lunes 00:00                   |
| Qn              | inicio del trimestre | min(fin del trimestre, este lunes) |
| Semana anterior | lunes anterior       | este lunes 00:00                   |

* Todo se computa en **UTC**. El cron corre lunes 09:00 Madrid; el desfase de \~2h es irrelevante para el volumen de respuestas (misma aproximación que el reporte original).
* La **semana** reportada es la **semana completa anterior**: lunes → lunes (extremo derecho exclusivo).
* El frontend espeja esta aritmética en `lib/surveys/week.ts` (`getWeekBounds`) y envía `?week` al backend.

## Slugs incluidos y excluidos

Constante `REPORT_SURVEY_SLUGS` (`weekly-nps.constants.ts`):

* **Incluidos:** `stay-review`, `arrival-review`, `onboarding-review`.
* **Excluidos:** `home-review`, `financial-review`, `book-review` (no entran en ninguna métrica de este reporte).

## Fuentes de datos

| Fuente              | Tabla                                                    | Enlace a reserva                                           | Uso                                                |
| ------------------- | -------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------- |
| Respuestas nuevas   | `survey_responses` (status `completed`)                  | `scope_id` = booking id                                    | pools NPS + numerador % Respondidas                |
| Respuestas legacy   | `survey_legacy_responses`                                | `booking_id` (stay/arrival); `nps_score` / `response_data` | pools NPS + numerador % Respondidas                |
| Estancias           | `bookings_snapshot`                                      | `id`                                                       | Estancias, denominador % Respondidas, Frictionless |
| Links de onboarding | `survey_tokens` (`survey_type_slug = onboarding-review`) | —                                                          | denominador % Respondidas                          |

* Respuestas nuevas: la ventana se aplica por `completed_at`. Ratings 1-5 envueltos como `{ value: n }` (desde mayo 2026; se aceptan ambas formas).
* Respuestas legacy: la ventana se aplica por `responded_at`. `nps_score` ya viene en 0-10; el stay-review legacy lleva `response_data.experience.nps` (Casa) y `response_data.stay.nps` (Equipo/CX).
* Estancias: la ventana se aplica por `check_in_date` (no por `actual_check_in_date`). Estados `active`/`past`/`inprogress`.

## Normalización a 0-10 y NPS Bain

Todo se lleva a una escala 0-10 antes de poolear:

* **Ratings 1-5 → 0-10**: `normFromFivePoint(v) = value × 2` (rechaza fuera de 0-5).
* **Llegada (arrival-review nuevo)**: `approvalRateTo010(answers)` = aprobados / total × 10 (tasa de aprobación de los ítems swipe).
* **Legacy NPS / arrival legacy**: `clamp010` (ya 0-10).

NPS estilo **Bain** (`computeNpsStats`): promotor `9-10`, pasivo `7-8`, detractor `0-6`; **NPS = %Promotores − %Detractores** (−100..+100). También expone `mean`, `n` y el desglose.

## Pools NPS (NO filtrados por disfrutadas)

Cada pool agrega todos los scores existentes de la ventana. Los pools **no** se filtran por `flag_enjoyed` ni por casa activa: filtrarlos descartaría respuestas legacy sin reserva enlazada y movería los NPS de cabecera.

| Pool               | Numerador (scores pooleados)                                                         | Notas                                                     |
| ------------------ | ------------------------------------------------------------------------------------ | --------------------------------------------------------- |
| **Estancia**       | `stay-review` nuevo `q_home_rating` (×2) + stay legacy `experience.nps`              | Casa                                                      |
| **Experiencia CX** | `stay-review` nuevo `q_team_rating` (×2) + stay legacy `stay.nps`                    | Equipo                                                    |
| **Llegada**        | `arrival-review` nuevo `approvalRateTo010` + arrival legacy `nps_score`              | `bigNumber = 'mean'` (se muestra como media, no como NPS) |
| **Onboarding**     | `onboarding-review` nuevo `q_onboarding_rating` (×2) + onboarding legacy `nps_score` | —                                                         |
| **Global**         | Estancia + Experiencia CX + Onboarding                                               | **Excluye Llegada** (Llegada es una media, no se poolea)  |

Targets en `TARGETS` (`weekly-nps.constants.ts`): Global 60, Estancia 50, Experiencia CX 70, Llegada 80, Onboarding 100.

## Estancias

Contabiliza estancias **disfrutadas en casa activa** con check-in en la ventana (ver sección final). Base = `filterEnjoyedActive(checkIns, activeHids)`.

| Campo              | Fórmula                                                                                 |
| ------------------ | --------------------------------------------------------------------------------------- |
| `stays`            | nº de estancias disfrutadas+activas con check-in en la ventana (dedup `isOccupantStay`) |
| `distinctHomes`    | nº de `property_hid` distintos en ese conjunto                                          |
| `activeHomes`      | nº de casas activas no-test (constante en todas las columnas)                           |
| `homesCoveragePct` | `distinctHomes / activeHomes × 100` (null si `activeHomes = 0`)                         |

`isOccupantStay` deja una fila por estancia real del ocupante: `book`/`rent`/`exchange` cuentan; `to_rent`/`to_swap` solo si NO fueron cedidas (la fila derivada rent/exchange ya cuenta esa estancia del invitado).

## % Respondidas (tasa de respuesta)

Numerador y denominador consistentes sobre la base disfrutada+activa.

* **Denominador** = `estancias disfrutadas+activas de la ventana × 2 + links de onboarding`. Cada estancia disfrutada genera **dos** envíos (arrival-review tras el check-in + stay-review tras el check-out); cada link de onboarding es 1 envío.
* **Numerador** = respuestas `stay`/`arrival` (nuevas y legacy) cuyo **booking enlazado** está en el **set global** de reservas disfrutadas+activas, **más todas** las respuestas de onboarding (sin filtrar: el `scope_id` de onboarding es un user id, no un booking).
  * Enlace estricto: nuevas por `scope_id`, legacy por `booking_id`.
  * El set global (`fetchEnjoyedActiveBookingIds`) se calcula una vez fuera del bucle de ventanas. Se enlaza contra el set **global** (no contra los check-ins de la ventana) para evitar el artefacto cross-ventana: una respuesta se fecha por `completed_at`/`responded_at` y una reserva por `check_in_date`, así que pueden caer en ventanas distintas.

<Note>
  **Calidad de datos:** \~56% de las filas legacy de stay/arrival de 2026 tienen `booking_id` nulo. Esas respuestas **no cuentan** en el numerador (enlace estricto, por diseño). Las respuestas nuevas (`survey_responses`) sí llevan `scope_id` en \~100% de los casos.
</Note>

`pct = num / denom × 100` (null si `denom = 0`).

## Frictionless (sin fricción)

Mide el **% del total de estancias libres de fricción**. Una estancia tiene **fricción** únicamente cuando respondió el `stay-review` **y** alguna nota es `<= 7`; en cualquier otro caso es **frictionless**.

* **Denominador** = estancias disfrutadas+activas de la ventana.
* **Numerador** = de esas, las frictionless:
  * estancias que **respondieron** con **ambas** notas `> 7` (Casa > 7 **y** Equipo > 7), o
  * estancias que **no respondieron** el stay-review (silencio = sin problema reportado).

<Note>
  Escala 0-10 para comparar igual nuevo y legacy: las notas nuevas vienen de 1-5★ y se normalizan con `normFromFivePoint` (×2 → 0-10); las legacy (`nps`) ya están en 0-10. Una nota de exactamente **7 cuenta como fricción** (`> 7` ⇒ `>= 8`). En notas nuevas de 1-5★ el 7 no existe (3★ = 6 = fricción, 4★ = 8 = frictionless); el 7 exacto solo aparece en datos legacy.
</Note>

Los ratings por reserva (`fetchStayRatingsByBooking`) combinan nuevo (`scope_id`) y legacy (`booking_id`); el nuevo es más autoritativo y sobrescribe al legacy. Target Frictionless: 90.

## Filtro: reservas disfrutadas + casa activa

Es la regla central de los **conteos** del reporte. Afecta a **Estancias**, **% Respondidas** y **Frictionless**; **no** afecta a los pools NPS.

Una estancia entra en los conteos solo si cumple las tres condiciones (`filterEnjoyedActive`):

1. **Casa activa** — `properties.status = 'active'` **y** `deleted_at IS NULL` **y** `name NOT ILIKE '%[Test]%'`. El conjunto de `hid` activos lo provee `fetchActiveHomes()`.
2. **Reserva disfrutada** — `bookings_snapshot.flag_enjoyed = true` (CX marca la estancia como disfrutada; se sincroniza desde Firestore `bookFlags.enjoyed`).
3. **property\_hid no nulo** y dentro del set de casas activas.

| Métrica                                                  | ¿Filtrada por disfrutadas+activa?   |
| -------------------------------------------------------- | ----------------------------------- |
| Estancias (`stays`, `distinctHomes`, `homesCoveragePct`) | **Sí**                              |
| % Respondidas (numerador y denominador)                  | **Sí**                              |
| Frictionless (numerador y denominador)                   | **Sí**                              |
| Pools NPS (Estancia, CX, Llegada, Onboarding, Global)    | **No** — sobre todas las respuestas |

<Note>
  Matiz operativo: CX a veces marca `enjoyed` 1-2 días después de empezar la estancia. Como el reporte es retrospectivo, la mayoría de las estancias ya están marcadas cuando se computa. Por eso el **emisor** de `arrival-review` (`SurveyCandidatesService`) NO se gatea por `enjoyed`, pero el reporte sí cuenta sobre la regla disfrutadas+activa.
</Note>

## Endpoints

| Endpoint                                | Auth               | Descripción                                                               |
| --------------------------------------- | ------------------ | ------------------------------------------------------------------------- |
| `GET /surveys/reports/weekly-nps`       | `tool-chat` viewer | Payload del reporte (Anual + trimestres + semana). Acepta `?week=<ISO>`.  |
| `POST /surveys/reports/weekly-nps/send` | `x-api-key`        | Computa + renderiza + envía el email (`to`, `week`, `dryRun` opcionales). |
