- RetentionSummaryBadge in the "Previous conversations" sidebar: tiered status (First contact / Active / Recurring / Sleeping / At risk / Inactive) + counts of interactions, one-shots, Pix. - Retention tab in Captain Reports: KpiCards, FlowCard, CohortMatrix (12x13 heatmap with CSV export). - Five new filters on the contacts list: recurring, last interaction, days since, interactions count, reservations paid. - Full pt_BR + en i18n under CAPTAIN_REPORTS.RETENTION.* - Spec for InteractionCalculatorService covering gap behavior, one-shot classification, internal-label exclusion, multi-conversation grouping across the 30h window. - Docs: docs/captain-retention-indicators.md with business rules, column reference, endpoint shape, and backup SQL queries.
5.4 KiB
Captain — Indicadores de Retenção e Recorrência
Design e implementação do sistema de métricas de retenção para motel (negociado com Rodrigo em 2026-04-22).
Regras de negócio
Definição de "interação"
- Todas as mensagens públicas (não-privadas, status != failed) do contato e do atendimento (Captain::Assistant ou User) são consideradas.
- Gap de 30h entre a última msg de uma interação e a próxima msg abre uma nova interação. Abaixo disso, mesma "sessão".
- Motivo: motel tem frequência alta (múltiplas visitas na mesma semana); 30h evita que manhã+tarde virem 2 interações mas captura corretamente "voltou-no-dia-seguinte".
Classificação de cada grupo
| Cliente | Atendimento | Tipo |
|---|---|---|
| ≥ 2 msgs textuais | ≥ 2 msgs textuais | qualificada (conta em interactions_count) |
| ≥ 1 msg textual | ≥ 1 msg textual | one-shot (conta em one_shot_consultations_count) |
| 0 de um dos lados | — | ignorado |
Textual = pelo menos 2 letras (\p{L}). Exclui emoji-only, sticker,
"Message without content".
Exclusão
Contatos com o label equipe_interna (manual) são ignorados em todas as
métricas. Aplica-se a gerentes, testes, números internos.
Recorrência
is_recurring = interactions_count >= 2.
Reservas vs Pix
pix_generated_count: todaCaptain::PixChargecriada (sinal de intenção).reservations_paid_count:Captain::PixCharge.where(status: 'paid')(reserva real).- Conversão = paid / generated.
Colunas em contacts
| Coluna | Tipo | Atualizada por |
|---|---|---|
interactions_count |
integer | Captain::Retention::RecalculateContactStatsJob |
one_shot_consultations_count |
integer | idem |
first_interaction_at |
datetime | idem |
last_interaction_at |
datetime | idem |
pix_generated_count |
integer | idem (conta PixCharges) |
reservations_paid_count |
integer | idem |
is_recurring |
boolean | idem |
days_since_last_interaction |
integer | idem (recalculado no job) |
Fluxo de atualização
- Job diário (
captain_retention_recalculate_all_contact_stats_job, 6h UTC / 3h BRT): itera todos os contatos com conversa nos últimos 120 dias e enfileiraRecalculateContactStatsJobpor contact. - Evento conversa resolvida:
CaptainListener#conversation_resolvedenfileira recalc incremental do contato. - Evento PixCharge:
after_create_commiteafter_update_commitenfileiram recalc (cobre geração e mudança pra paid/expired/failed).
Endpoints
GET /api/v1/accounts/:id/captain/reports/retention
Params: start_date, end_date (ISO, opcional, default = mês corrente).
Resposta:
{
"period": { "start": "2026-04-01", "end": "2026-04-22" },
"status": {
"active": 340, "recurring": 85, "sleeping": 62,
"at_risk": 30, "churned": 15, "never_interacted": 0
},
"flow": { "new": 120, "returned": 40, "total_touches": 160 },
"pix": { "generated": 180, "paid": 45, "conversion_rate": 0.25 },
"retention": { "last_30d": 0.28, "last_90d": 0.12 }
}
Cortes de status (relativos a Time.current):
- active:
last_interaction_at >= now - 30d - recurring:
is_recurring=true AND last_interaction_at >= now - 90d - sleeping:
last_interaction_atentre 30 e 90 dias atrás - at_risk: entre 90 e 180 dias atrás
- churned: mais de 180 dias atrás
- never_interacted:
last_interaction_at IS NULL
GET /api/v1/accounts/:id/captain/reports/retention/cohort
Params: history_months (default 12, max 24).
Resposta: matriz com cohorts (linha por mês), cada linha tem
size + retention[] (array de 13 objetos: offset 0..12 com count e rate).
Cohort = mês da first_interaction_at. Célula [cohort, M+N] = % de contatos
da cohort com qualquer msg de cliente no mês M+N.
UI
- Aba Retenção em Relatórios do Captain (
retention/RetentionTab.vue):- KpiCards: 4 cards (ativos, recorrentes, retorno 30d, conversão Pix)
- FlowCard: novos vs retornaram no período + situação absoluta (sleeping/ at_risk/churned)
- CohortMatrix: tabela cohort 12x13 com heatmap verde + export CSV
- Badge no card de contato (
RetentionSummaryBadge.vue): mostra tier (Primeiro contato/Ativo/Recorrente/Adormecido/Em risco/Inativo) + contadores- "última há X dias".
- Filtros na lista de contatos (
contactProvider.js): 5 filtros novos — cliente recorrente, última interação, dias sem interagir, nº de interações, reservas pagas.
Queries SQL de backup
Contagem rápida de ativos no mês:
SELECT COUNT(*) FROM contacts
WHERE account_id = $1 AND last_interaction_at >= NOW() - INTERVAL '30 days';
Taxa de retorno 30d da cohort de 30-37 dias atrás:
WITH cohort AS (
SELECT id FROM contacts
WHERE account_id = $1
AND first_interaction_at BETWEEN NOW() - INTERVAL '37 days' AND NOW() - INTERVAL '30 days'
)
SELECT (COUNT(*) FILTER (WHERE c.last_interaction_at >= NOW() - INTERVAL '7 days'))::float
/ NULLIF(COUNT(*), 0) AS rate
FROM contacts c WHERE c.id IN (SELECT id FROM cohort);
Próximos passos possíveis
- Recalcular
first_interaction_at/last_interaction_atvia SQL direto no job (atualmente é via service Ruby — OK até ~10k contatos ativos). - Widget "Top recorrentes" (ranking dos contatos com mais reservas pagas).
- Alerta quando taxa de retorno cai > 10% mês a mês.
- Histórico da métrica para rodar tendências (tabela
retention_snapshots).