"""Forecasts (roadmap #7): honest ETAs from the event journal. Throughput = issues closed per week, counted from board_events. Projections are deliberately given as a RANGE (best/worst weekly rate over the last 4 complete weeks) rather than a fake-precise date, and are withheld entirely when the journal has no closures to learn from — "not enough history" is an answer, too. """ from datetime import date, datetime, timedelta, timezone def _journal_today() -> date: """'Today' in the journal's frame: board_events stamps are UTC (SQLite datetime('now')), so week buckets anchor to UTC — otherwise closures logged in the local evening (already tomorrow in UTC) fall outside every bucket.""" return datetime.now(timezone.utc).date() MONTHS_FR = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", ] MONTHS_FR_SHORT = [ "janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc.", ] def french_date(d: date, short: bool = False) -> str: months = MONTHS_FR_SHORT if short else MONTHS_FR return f"{d.day} {months[d.month - 1]}" def _event_date(event: dict) -> date | None: raw = str(event.get("created_at") or "") try: return datetime.strptime(raw[:10], "%Y-%m-%d").date() except ValueError: return None def weekly_closures(events: list[dict], weeks: int = 8, today: date | None = None) -> list[dict]: """Closure counts per ISO week, oldest first, current week last.""" today = today or _journal_today() current_monday = today - timedelta(days=today.weekday()) buckets: list[dict] = [] for i in range(weeks - 1, -1, -1): start = current_monday - timedelta(weeks=i) buckets.append({"start": start, "count": 0}) by_start = {b["start"]: b for b in buckets} for event in events: if event.get("event_type") != "closed": continue day = _event_date(event) if day is None: continue monday = day - timedelta(days=day.weekday()) if monday in by_start: by_start[monday]["count"] += 1 return buckets def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None: """avg/min/max closures per week over the last COMPLETE weeks. The current (partial) week is excluded: on Monday morning it would crush the average for no reason. Returns None when the complete weeks contain no closure at all. """ complete = weekly[:-1][-last:] if len(weekly) > 1 else [] if not complete: return None counts = [b["count"] for b in complete] if sum(counts) == 0: return None return { "avg": sum(counts) / len(counts), "min": min(counts), "max": max(counts), "weeks": len(counts), } def estimate(remaining: int, weekly: list[dict], today: date | None = None) -> dict | None: """Projected completion for `remaining` items at the real pace. Returns {"done": True} when nothing remains, None when there is no throughput to project from, else dates: central (avg rate), earliest (best week), latest (worst nonzero week, if any). """ today = today or _journal_today() if remaining <= 0: return {"done": True} stats = throughput_stats(weekly) if stats is None: return None def at_rate(rate: float) -> date: days = remaining / rate * 7 return today + timedelta(days=max(1, round(days))) result = { "done": False, "avg_per_week": round(stats["avg"], 1), "central": at_rate(stats["avg"]), "earliest": at_rate(stats["max"]) if stats["max"] > 0 else None, "latest": at_rate(stats["min"]) if stats["min"] > 0 else None, } return result def eta_label(est: dict | None, done_text: str = "terminé") -> str | None: """One-line French label for banners and cards (or None).""" if est is None: return None if est.get("done"): return f"✅ {done_text}" label = f"à ce rythme ≈ {french_date(est['central'], short=True)}" if est.get("earliest") and est.get("latest"): label += ( f" (fourchette {french_date(est['earliest'], short=True)}" f" – {french_date(est['latest'], short=True)})" ) return label