ligbox-ops-platform/app/funnel_timing.py
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

115 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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