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 conx-api-key). Renderiza el payload con la plantilla React Email y lo envía aWEEKLY_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(roltool-chatviewer). Acepta?week=<ISO>para navegar semanas.
WeeklyNpsReportService.computePayload(). Documentar el cálculo aquí lo deja bien definido: una sola fuente de verdad para email y vista.
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 enbuildWindows() (weekly-nps-compute.ts):
- 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?weekal backend.
La semana en curso
El Reporte semanal (NPS agregado) solo muestra semanas cerradas: es el mismo número que sale por email y se persiste enanalytics.nps_weekly_snapshot, y un NPS a medias citado como “el de la semana” engaña más de lo que informa. Si la URL apunta a la semana en curso, la vista retrocede sola a la última semana cerrada.
La Lista semanal sí llega a la semana en curso: lee respuestas en bruto vía GET /survey-responses (rango de fechas arbitrario, sin agregación), así que una semana a medias es exactamente lo que CX quiere leer un miércoles. En week.ts esto es el flag allowCurrentWeek de isNextWeekFuture(), que mueve el tope del navegador una semana adelante. Cuando estás ahí:
- El rango se etiqueta
DD/MM – hoy(formatWeekRangeOpen) y aparece un badge En curso. - La consulta y la cabecera del XLSX se cortan en hoy, nunca anuncian días futuros.
- La flecha
›se bloquea ahí: no se puede pasar de la semana en curso.
Slugs incluidos y excluidos
ConstanteREPORT_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
- 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_scoreya viene en 0-10; el stay-review legacy llevaresponse_data.experience.nps(Casa) yresponse_data.stay.nps(Equipo/CX). - Estancias: la ventana se aplica por
check_in_date(no poractual_check_in_date). Estadosactive/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).
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 porflag_enjoyed ni por casa activa: filtrarlos descartaría respuestas legacy sin reserva enlazada y movería los NPS de cabecera.
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).
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: elscope_idde onboarding es un user id, no un booking).- Enlace estricto: nuevas por
scope_id, legacy porbooking_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 porcompleted_at/responded_aty una reserva porcheck_in_date, así que pueden caer en ventanas distintas.
- Enlace estricto: nuevas por
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ó elstay-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).
- estancias que respondieron con ambas notas
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.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'ydeleted_at IS NULLyname NOT ILIKE '%[Test]%'. El conjunto dehidactivos lo proveefetchActiveHomes(). - Reserva disfrutada —
bookings_snapshot.flag_enjoyed = true(CX marca la estancia como disfrutada; se sincroniza desde FirestorebookFlags.enjoyed). - property_hid no nulo y dentro del set de casas activas.
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.Semanas cedidas: el flag vive en la reserva del propietario
En intercambio y alquiler el modelo antiguo escribe dos reservas: la del propietario que cede la semana (to_rent / to_swap) y la de quien se aloja (rent / exchange), que apunta a la primera por origin_booking_id.
CX marca flag_enjoyed sobre la reserva del propietario, porque es la única que existe en v2 (v2_booking_id está al 98-99% en book y al 0% en rent / exchange). La reserva del ocupante nunca lleva el flag: 0 de 420 exchange y 0 de 347 rent en todo el histórico.
Por eso, tanto el emisor (SurveyCandidatesService) como el reporte (WeeklyNpsDataService) resuelven el flag sobre la reserva padre para esos dos tipos. book sigue leyendo el suyo. La regla vive en un único sitio, stay-enjoyed-flag.ts, para que envío y métricas cuenten lo mismo.