Skip to main content

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 * * 1POST /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):
ColumnaDesdeHasta (exclusivo)
Anual1 ene del añoeste lunes 00:00
Qninicio del trimestremin(fin del trimestre, este lunes)
Semana anteriorlunes anterioreste 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

FuenteTablaEnlace a reservaUso
Respuestas nuevassurvey_responses (status completed)scope_id = booking idpools NPS + numerador % Respondidas
Respuestas legacysurvey_legacy_responsesbooking_id (stay/arrival); nps_score / response_datapools NPS + numerador % Respondidas
Estanciasbookings_snapshotidEstancias, denominador % Respondidas, Frictionless
Links de onboardingsurvey_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.
PoolNumerador (scores pooleados)Notas
Estanciastay-review nuevo q_home_rating (×2) + stay legacy experience.npsCasa
Experiencia CXstay-review nuevo q_team_rating (×2) + stay legacy stay.npsEquipo
Llegadaarrival-review nuevo approvalRateTo010 + arrival legacy nps_scorebigNumber = 'mean' (se muestra como media, no como NPS)
Onboardingonboarding-review nuevo q_onboarding_rating (×2) + onboarding legacy nps_score
GlobalEstancia + Experiencia CX + OnboardingExcluye 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).
CampoFórmula
staysnº de estancias disfrutadas+activas con check-in en la ventana (dedup isOccupantStay)
distinctHomesnº de property_hid distintos en ese conjunto
activeHomesnº de casas activas no-test (constante en todas las columnas)
homesCoveragePctdistinctHomes / 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):
  1. Casa activaproperties.status = 'active' y deleted_at IS NULL y name NOT ILIKE '%[Test]%'. El conjunto de hid activos lo provee fetchActiveHomes().
  2. Reserva disfrutadabookings_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)
% Respondidas (numerador y denominador)
Frictionless (numerador y denominador)
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

EndpointAuthDescripción
GET /surveys/reports/weekly-npstool-chat viewerPayload del reporte (Anual + trimestres + semana). Acepta ?week=<ISO>.
POST /surveys/reports/weekly-nps/sendx-api-keyComputa + renderiza + envía el email (to, week, dryRun opcionales).