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.
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.
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).
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.
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):
- Casa activa —
properties.status = 'active' y deleted_at IS NULL y name NOT ILIKE '%[Test]%'. El conjunto de hid activos lo provee fetchActiveHomes().
- Reserva disfrutada —
bookings_snapshot.flag_enjoyed = true (CX marca la estancia como disfrutada; se sincroniza desde Firestore bookFlags.enjoyed).
- 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 |
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.
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). |