"""Cálculo de durações do funil onboarding (Spec 014).""" from __future__ import annotations from datetime import datetime, timezone def _parse_iso(iso: str | None) -> datetime | None: if not iso: return None try: dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except ValueError: return None def format_duration(seconds: float | int | None) -> str: if seconds is None: return "—" sec = max(0, int(round(float(seconds)))) if sec < 60: return f"{sec}s" mins, rem = divmod(sec, 60) if mins < 60: return f"{mins}m {rem}s" hrs, mins = divmod(mins, 60) if hrs < 48: return f"{hrs}h {mins}m" days, hrs = divmod(hrs, 24) return f"{days}d {hrs}h" def enrich_timeline_events(events: list[dict]) -> list[dict]: if not events: return [] start_dt = _parse_iso(events[0].get("created_at") or events[0].get("at")) prev_dt = None enriched: list[dict] = [] for idx, ev in enumerate(events): at = ev.get("created_at") or ev.get("at") cur_dt = _parse_iso(at) from_prev = None from_start = None if cur_dt and prev_dt: from_prev = (cur_dt - prev_dt).total_seconds() if cur_dt and start_dt: from_start = (cur_dt - start_dt).total_seconds() row = dict(ev) row["duration_from_prev_sec"] = from_prev if idx > 0 else 0 row["duration_from_start_sec"] = from_start row["duration_from_prev_label"] = format_duration(from_prev) if idx > 0 else "—" row["duration_from_start_label"] = format_duration(from_start) enriched.append(row) if cur_dt: prev_dt = cur_dt return enriched def build_timing_report(events: list[dict], *, now_iso: str | None = None) -> dict: enriched = enrich_timeline_events(events) if not enriched: return { "timing_enabled": True, "events": [], "total_duration_sec": None, "total_duration_label": "—", "started_at": None, "completed_at": None, "idle_since_sec": None, "idle_since_label": "—", } last = enriched[-1] start_dt = _parse_iso(enriched[0].get("created_at") or enriched[0].get("at")) last_dt = _parse_iso(last.get("created_at") or last.get("at")) completed_types = {"onboarding.completed", "onboarding.failed"} last_type = last.get("event_type") or last.get("event") is_done = last_type in completed_types now_dt = _parse_iso(now_iso) or datetime.now(timezone.utc) # Sessão activa: tempo total = agora − início (relógio a correr). # Concluída: tempo total = último evento − início. if is_done and last_dt and start_dt: total_sec = (last_dt - start_dt).total_seconds() elif start_dt: total_sec = (now_dt - start_dt).total_seconds() else: total_sec = last.get("duration_from_start_sec") idle_sec = None if not is_done and last_dt: idle_sec = (now_dt - last_dt).total_seconds() return { "timing_enabled": True, "events": enriched, "total_duration_sec": total_sec, "total_duration_label": format_duration(total_sec), "started_at": enriched[0].get("created_at") or enriched[0].get("at"), "last_event_at": last.get("created_at") or last.get("at"), "completed_at": last.get("created_at") or last.get("at") if is_done else None, "idle_since_sec": idle_sec, "idle_since_label": format_duration(idle_sec) if idle_sec is not None else "—", "is_completed": is_done, } def apply_module_timing(events: list[dict]) -> tuple[list[dict], dict | None]: from app.modules import store as module_store if not module_store.is_module_enabled("funnel-timing") or not events: return events, None report = build_timing_report(events) enriched = report.pop("events", events) meta = {k: v for k, v in report.items() if k != "timing_enabled"} return enriched, meta