cctally 1.8.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1026,6 +1026,7 @@ def _build_share_panel_data(panel: str, options: dict,
1026
1026
  if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
1027
1027
  if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
1028
1028
  if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
1029
+ if panel == "projects": return _build_projects_share_panel_data(options, snap)
1029
1030
  raise ValueError(f"unknown share panel: {panel!r}")
1030
1031
 
1031
1032
 
@@ -1526,6 +1527,151 @@ def _build_sessions_share_panel_data(options: dict,
1526
1527
  return {"sessions": sessions}
1527
1528
 
1528
1529
 
1530
+ def _build_projects_share_panel_data(options: dict,
1531
+ snap: "DataSnapshot | None") -> dict:
1532
+ """Projects panel_data — per-project rollup over a selectable window.
1533
+
1534
+ Reuses ``DataSnapshot.projects_envelope`` already populated by the
1535
+ sync thread, so the share artifact matches what the dashboard panel
1536
+ is showing. ``options.windowWeeks`` (spec §5.4 + §7.3) selects the
1537
+ aggregation window:
1538
+
1539
+ - ``windowWeeks=1`` (default): current_week only (PANEL share flow).
1540
+ - ``windowWeeks ∈ {4, 8, 12}``: sum across the trend window
1541
+ (MODAL share flow — supplies its active window pill).
1542
+
1543
+ Output shape (consumed by `_build_projects_recap` / `_visual` /
1544
+ `_detail` builders below — see bin/_lib_share_templates.py):
1545
+
1546
+ {
1547
+ "rows": [
1548
+ {
1549
+ "key": "<disambiguated display_key>",
1550
+ "bucket_path": "<absolute path>",
1551
+ "cost_usd": <float>,
1552
+ "attributed_pct": <float | None>,
1553
+ "sessions_count": <int>,
1554
+ },
1555
+ ... # desc by cost
1556
+ ],
1557
+ "total_cost_usd": <float>,
1558
+ "period_start": <dt.datetime UTC>,
1559
+ "period_end": <dt.datetime UTC>,
1560
+ "window_weeks": <int>,
1561
+ }
1562
+
1563
+ The Privacy invariant per spec §7.4 lives at the share-render gate
1564
+ (`_lib_share._scrub`), NOT here. This panel_data carries REAL
1565
+ display_keys + bucket_paths; downstream `_scrub` rewrites them
1566
+ when ``reveal_projects=false``.
1567
+ """
1568
+ env: dict = getattr(snap, "projects_envelope", None) or {} if snap else {}
1569
+ if not env:
1570
+ # First-tick / sub-build failure → render a minimal "no data"
1571
+ # shape. _build_project_snapshot already handles empty rows
1572
+ # downstream via "no data" title.
1573
+ now = _share_now_utc()
1574
+ return {
1575
+ "rows": [],
1576
+ "total_cost_usd": 0.0,
1577
+ "period_start": now - dt.timedelta(days=7),
1578
+ "period_end": now,
1579
+ "window_weeks": 1,
1580
+ }
1581
+ weeks_back_raw = options.get("windowWeeks", 1)
1582
+ try:
1583
+ weeks_back = int(weeks_back_raw)
1584
+ except (TypeError, ValueError):
1585
+ weeks_back = 1
1586
+ if weeks_back not in {1, 4, 8, 12}:
1587
+ weeks_back = 1
1588
+ cw = env.get("current_week", {}) or {}
1589
+ trend = env.get("trend", {}) or {}
1590
+
1591
+ # `effective_weeks` is the actual number of weeks of data the artifact
1592
+ # represents. For the 1-week (panel) path it's always 1. For multi-week
1593
+ # (modal) the trend envelope may carry fewer weeks than requested on
1594
+ # thin-history dashboards (fresh installs, post-rebuild), so clamp to
1595
+ # whatever history exists — otherwise the share artifact would label
1596
+ # itself "Last 12 weeks" and render a 12-week date range while only
1597
+ # (say) 3 weeks of rows were aggregated. The period bounds and the
1598
+ # `window_weeks` returned downstream both ride on `effective_weeks`.
1599
+ rows: list[dict]
1600
+ if weeks_back == 1:
1601
+ effective_weeks = 1
1602
+ rows = [
1603
+ {
1604
+ "key": r["key"],
1605
+ "bucket_path": r["bucket_path"],
1606
+ "cost_usd": float(r["cost_usd"]),
1607
+ "attributed_pct": r.get("attributed_pct"),
1608
+ "sessions_count": int(r.get("sessions_count", 0) or 0),
1609
+ }
1610
+ for r in (cw.get("rows") or [])
1611
+ ]
1612
+ total_cost = float(cw.get("total_cost_usd", 0.0) or 0.0)
1613
+ else:
1614
+ # Multi-week: sum across the trailing `weeks_back` slices of
1615
+ # trend.projects[i].weekly_cost. attributed_pct sums each
1616
+ # project's weekly_pct (None when no week has a snapshot).
1617
+ n_weeks = len(trend.get("weeks") or [])
1618
+ # The trend window is already clamped to <= 12; we take the
1619
+ # trailing `weeks_back` slices.
1620
+ take = min(weeks_back, n_weeks)
1621
+ # On a brand-new dashboard with zero trend weeks, fall back to a
1622
+ # single-week (current_week) period so the artifact's labelling
1623
+ # still names a real range instead of "Last 0 weeks".
1624
+ effective_weeks = max(1, take)
1625
+ rows = []
1626
+ running_total = 0.0
1627
+ for tp in trend.get("projects") or []:
1628
+ wc = (tp.get("weekly_cost") or [])[-take:]
1629
+ wp = (tp.get("weekly_pct") or [])[-take:]
1630
+ ws = (tp.get("sessions_per_week") or [])[-take:]
1631
+ cost = float(sum(wc))
1632
+ running_total += cost
1633
+ valid_pct = [float(p) for p in wp if p is not None]
1634
+ attributed = sum(valid_pct) if valid_pct else None
1635
+ # Sum per-week distinct session counts. Slight over-count when a
1636
+ # single session spans a week boundary; the envelope's per-week
1637
+ # bucketing has no session-id sets to union, so this is the
1638
+ # cheapest reasonable approximation and matches the modal's
1639
+ # client-side derivation (envelope.ts → ProjectsModal.tsx).
1640
+ rows.append({
1641
+ "key": tp["key"],
1642
+ "bucket_path": tp["bucket_path"],
1643
+ "cost_usd": cost,
1644
+ "attributed_pct": attributed,
1645
+ "sessions_count": int(sum(ws)),
1646
+ })
1647
+ rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
1648
+ total_cost = running_total
1649
+
1650
+ # Compute window bounds from the *effective* span — see the
1651
+ # `effective_weeks` note above. The rows in this panel_data are
1652
+ # week-to-date (current_week.rows are aggregated through "now"; the
1653
+ # multi-week branch sums weekly_cost slices, with the trailing slice
1654
+ # also week-to-date), so clip `period_end` to min(reset_at, now).
1655
+ # Without the clip a mid-week export advertises a future reset date
1656
+ # in the rendered period/frontmatter and disagrees with the live
1657
+ # dashboard's "spent this week" KPI, which is symmetrically clipped
1658
+ # by `_build_current_week_share_panel_data`'s use of `now`.
1659
+ cw_start_iso = cw.get("week_start_at") or _share_now_utc_iso()
1660
+ cw_start = parse_iso_datetime(cw_start_iso, "projects.cw_start")
1661
+ week_end = cw_start + dt.timedelta(days=7)
1662
+ now = _share_now_utc()
1663
+ period_end = week_end if week_end <= now else now
1664
+ period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1665
+
1666
+ return {
1667
+ "rows": rows,
1668
+ "total_cost_usd": total_cost,
1669
+ "period_start": period_start,
1670
+ "period_end": period_end,
1671
+ "window_weeks": effective_weeks,
1672
+ }
1673
+
1674
+
1529
1675
 
1530
1676
 
1531
1677
  # === Dashboard server core: _SnapshotRef + SSEHub + envelope builders =====
@@ -2310,6 +2456,888 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2310
2456
  return rows
2311
2457
 
2312
2458
 
2459
+ # --- Projects panel / modal (spec 2026-05-19-projects-panel-design.md) ------
2460
+ #
2461
+ # Per-tick projects envelope builder. Runs on the sync thread that
2462
+ # populates ``DataSnapshot``; the dashboard's pure ``snapshot_to_envelope``
2463
+ # reads it back unchanged and assigns to ``envelope["projects"]`` so the
2464
+ # serializer stays DB-free.
2465
+ #
2466
+ # See spec §5.2 (envelope shape), §6.2 (signatures), §6.4 (memoization),
2467
+ # §9.2 (R-PROJ1..R-PROJ5 reconcile invariants).
2468
+ #
2469
+ # Identity:
2470
+ # - ``key`` = disambiguated ``ProjectKey.display_key`` via
2471
+ # ``_lib_render._project_disambiguate_labels``.
2472
+ # Stable within a single envelope (a `foo` collision
2473
+ # resolves to `foo (parent_dir)`).
2474
+ # - ``bucket_path`` = canonical equality key (``ProjectKey.bucket_path``)
2475
+ # — the absolute on-disk path. Privacy-sensitive;
2476
+ # _lib_share._scrub strips it on the share path.
2477
+
2478
+ # Per-tick memo (spec §6.4 + memory: *Pre-probe before sync_cache*).
2479
+ # Keyed on (max(session_entries.id), current_week.week_start_at,
2480
+ # weeks_back); single entry, in-process only. Bounded per-tick cost on
2481
+ # large caches: subsequent calls within the same sync tick hit the
2482
+ # memo and skip the aggregation walk.
2483
+ _PROJECTS_ENV_MEMO: dict = {"key": None, "value": None}
2484
+
2485
+
2486
+ def _projects_reset_memo() -> None:
2487
+ """Clear the projects envelope memo. Used by unit tests that want to
2488
+ measure the inner aggregation cost in isolation."""
2489
+ _PROJECTS_ENV_MEMO["key"] = None
2490
+ _PROJECTS_ENV_MEMO["value"] = None
2491
+
2492
+
2493
+ def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
2494
+ """Anchor ``ts`` to its containing ISO-Monday 00:00 UTC subscription
2495
+ week start. Fallback shape used when no snapshot anchor is available
2496
+ — mirrors ``cmd_project``'s Monday fallback (bin/cctally:4711)."""
2497
+ base = ts.astimezone(dt.timezone.utc)
2498
+ return (base - dt.timedelta(days=base.weekday())).replace(
2499
+ hour=0, minute=0, second=0, microsecond=0,
2500
+ )
2501
+
2502
+
2503
+ def _projects_week_label(week_start: "dt.datetime") -> str:
2504
+ """Render a `wk Mon DD` label for the trend chart x-axis.
2505
+
2506
+ Per spec §5.2's `weeks[].week_label` example (`"wk Apr 22"`).
2507
+ UTC-anchored so JSON output is tz-agnostic.
2508
+ """
2509
+ return f"wk {week_start.strftime('%b %d')}"
2510
+
2511
+
2512
+ def _projects_iter_session_entries(conn: "sqlite3.Connection",
2513
+ *,
2514
+ since: "dt.datetime",
2515
+ until: "dt.datetime"):
2516
+ """Read ``session_entries`` joined with ``session_files`` over
2517
+ [since, until]. Yields rows directly off the passed conn — no
2518
+ cache.db monkeypatch, no production ``get_claude_session_entries``
2519
+ pipeline. The fixture DBs co-locate both schemas in one file; the
2520
+ production wiring opens both DBs and ATTACHes cache.db as a schema
2521
+ on the stats conn (see ``_run_dashboard_sync_tick``).
2522
+ """
2523
+ since_iso = since.astimezone(dt.timezone.utc).strftime(
2524
+ "%Y-%m-%dT%H:%M:%SZ"
2525
+ )
2526
+ until_iso = until.astimezone(dt.timezone.utc).strftime(
2527
+ "%Y-%m-%dT%H:%M:%SZ"
2528
+ )
2529
+ cur = conn.execute(
2530
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
2531
+ " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
2532
+ " e.cost_usd_raw, e.source_path, "
2533
+ " sf.session_id, sf.project_path "
2534
+ "FROM session_entries e "
2535
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
2536
+ "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
2537
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
2538
+ (since_iso, until_iso),
2539
+ )
2540
+ for row in cur:
2541
+ yield row
2542
+
2543
+
2544
+ def _build_projects_envelope(
2545
+ conn: "sqlite3.Connection",
2546
+ *,
2547
+ now_utc: "dt.datetime",
2548
+ current_week: "Any | None" = None,
2549
+ weeks_back: int = 12,
2550
+ ) -> dict:
2551
+ """Build the ``projects.{current_week, trend}`` envelope block.
2552
+
2553
+ Reuses ``cmd_project``'s identity model — per-(``ProjectKey``, week)
2554
+ rollup over ``session_entries`` with display-key disambiguation via
2555
+ ``_project_disambiguate_labels`` — but emits the simpler envelope
2556
+ shape from spec §5.2 (no per-model breakdowns, no first/last seen
2557
+ per session, no per-row $/1%; just cost / attributed_pct / sessions).
2558
+
2559
+ Week boundaries follow ``cmd_project``'s Monday-anchored UTC
2560
+ fallback (``bin/cctally:4711``); ``weekly_usage_snapshots`` rows are
2561
+ matched by ``week_start_date`` (date-only) for ``attributed_pct``.
2562
+
2563
+ ``current_week`` is passed through opaquely — if non-None and
2564
+ carrying a ``.week_start_at`` UTC datetime, that boundary supplants
2565
+ the Monday fallback for the current week's bucket. None (the
2566
+ default) preserves the fallback.
2567
+
2568
+ Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
2569
+ (R-PROJ5 invariant). Per-tick memoized on
2570
+ ``(max(session_entries.id), cw_week_start, weeks_back)``.
2571
+ """
2572
+ c = _cctally()
2573
+
2574
+ # ---- Pre-probe gate / memoization (spec §6.4) -----------------------
2575
+ # `attributed_pct` and trend `total_pct` are functions of
2576
+ # `weekly_usage_snapshots.weekly_percent`, which the throttled OAuth
2577
+ # refresh path can advance between session_entries writes. Probe
2578
+ # `MAX(weekly_usage_snapshots.id)` so the memo invalidates on that
2579
+ # surface too (mirrors the operational-error guard the attribution
2580
+ # SELECT uses below).
2581
+ cur = conn.execute("SELECT COALESCE(MAX(id), 0) FROM session_entries")
2582
+ max_id = cur.fetchone()[0]
2583
+ try:
2584
+ cur = conn.execute(
2585
+ "SELECT COALESCE(MAX(id), 0) FROM weekly_usage_snapshots"
2586
+ )
2587
+ max_wus_id = cur.fetchone()[0]
2588
+ except sqlite3.OperationalError:
2589
+ max_wus_id = 0
2590
+ cw_key: "dt.datetime | None" = None
2591
+ if current_week is not None:
2592
+ cw_key = getattr(current_week, "week_start_at", None)
2593
+ memo_key = (max_id, max_wus_id, cw_key, weeks_back)
2594
+ cached = _PROJECTS_ENV_MEMO.get("value")
2595
+ if cached is not None and _PROJECTS_ENV_MEMO.get("key") == memo_key:
2596
+ return cached
2597
+
2598
+ # ---- Week-start anchor (current subscription week) ------------------
2599
+ # ``TuiCurrentWeek.week_start_at`` is NOT a valid Monday lookup key
2600
+ # after ``_apply_midweek_reset_override`` — it is shifted to the
2601
+ # in-week reset instant (e.g. Friday 13:00 UTC) while the bucket
2602
+ # aggregator below snaps every entry to its containing ISO-Monday
2603
+ # via ``_week_for``. Using ``cw_key`` directly as the bucket-lookup
2604
+ # key strands all current-week activity in an empty bucket and emits
2605
+ # ``rows: []`` with ``total_cost_usd: 0.0``. Snap to the canonical
2606
+ # Monday-UTC week anchor here so the lookup keys align — same
2607
+ # invariant the weekly handling notes call out for
2608
+ # ``weekly_usage_snapshots``/``percent_milestones`` cross-table
2609
+ # joins. Regression: ``tests/fixtures/dashboard/reset-week/`` +
2610
+ # ``test_current_week_rows_populated_after_midweek_reset``.
2611
+ if cw_key is not None:
2612
+ cw_start = _projects_week_start_monday_utc(cw_key)
2613
+ else:
2614
+ cw_start = _projects_week_start_monday_utc(now_utc)
2615
+
2616
+ # Build a list of canonical Monday-anchored week starts ending with
2617
+ # cw_start, oldest → newest, of length ``weeks_back``. Clamping to
2618
+ # actual history happens after the entry walk reveals what weeks
2619
+ # have any activity.
2620
+ weeks_full = [
2621
+ cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i))
2622
+ for i in range(weeks_back)
2623
+ ]
2624
+ cw_end = cw_start + dt.timedelta(days=7)
2625
+ since_dt = weeks_full[0]
2626
+ until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
2627
+
2628
+ # ---- Bucket entries per (ProjectKey, week_start) --------------------
2629
+ # `_resolve_project_key` is the production resolver; we use git-root
2630
+ # mode (default for `cmd_project --group` absent) — matches the
2631
+ # CLI's default.
2632
+ _resolve_project_key = c._resolve_project_key
2633
+ resolver_cache: dict = {}
2634
+
2635
+ # buckets[(bucket_path, week_start)] -> {key, cost, sessions, ...}
2636
+ buckets: dict[tuple[str, dt.datetime], dict] = {}
2637
+ # Track total cost per week across ALL entries (attribution denominator).
2638
+ total_cost_by_week: dict[dt.datetime, float] = {}
2639
+ # Track first-seen ProjectKey instance per bucket_path so we can pass
2640
+ # the dict to `_project_disambiguate_labels` (which keys on `key.bucket_path`
2641
+ # equality via ProjectKey.__eq__).
2642
+ key_by_bucket: dict[str, Any] = {}
2643
+
2644
+ def _week_for(ts: dt.datetime) -> "dt.datetime | None":
2645
+ wstart = _projects_week_start_monday_utc(ts)
2646
+ if wstart < weeks_full[0] or wstart > weeks_full[-1]:
2647
+ return None
2648
+ return wstart
2649
+
2650
+ # Orphan handling: `_projects_iter_session_entries` LEFT JOINs
2651
+ # `session_files` so entries whose source_path has no
2652
+ # `session_files` row return `project_path = NULL`. Below,
2653
+ # `_resolve_project_key(None, ...)` maps that to the
2654
+ # `(unknown)` bucket — same identity the drill-down's explicit
2655
+ # orphan scan in `_project_detail_for_window` (see the
2656
+ # ``if unknown_bucket:`` branch around the
2657
+ # `orphan_cur` SELECT) collects via a NULL-side LEFT JOIN. The
2658
+ # two paths converge on the same `(unknown)` source_path set.
2659
+ for row in _projects_iter_session_entries(
2660
+ conn, since=since_dt, until=until_dt,
2661
+ ):
2662
+ (entry_id, ts_iso, model, input_tok, output_tok,
2663
+ cache_create, cache_read, cost_raw, source_path,
2664
+ session_id, project_path) = row
2665
+ if model == "<synthetic>":
2666
+ continue
2667
+ # Parse timestamp; assume Z / +00:00 — production iterators do
2668
+ # the same via `parse_iso_datetime`.
2669
+ ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
2670
+ wstart = _week_for(ts)
2671
+ if wstart is None:
2672
+ continue
2673
+
2674
+ # Entry cost via the shared pricing chokepoint.
2675
+ entry_cost = _calculate_entry_cost(
2676
+ model,
2677
+ {
2678
+ "input_tokens": input_tok or 0,
2679
+ "output_tokens": output_tok or 0,
2680
+ "cache_creation_input_tokens": cache_create or 0,
2681
+ "cache_read_input_tokens": cache_read or 0,
2682
+ },
2683
+ mode="auto",
2684
+ cost_usd=cost_raw,
2685
+ )
2686
+
2687
+ # Project-key identity (`git_root` mode = production default).
2688
+ pkey = _resolve_project_key(project_path, "git-root", resolver_cache)
2689
+ bkey = (pkey.bucket_path, wstart)
2690
+ b = buckets.get(bkey)
2691
+ if b is None:
2692
+ b = {
2693
+ "key": pkey,
2694
+ "cost_usd": 0.0,
2695
+ "sessions": set(),
2696
+ "first_seen": ts,
2697
+ "last_seen": ts,
2698
+ }
2699
+ buckets[bkey] = b
2700
+ b["cost_usd"] += entry_cost
2701
+ if session_id:
2702
+ b["sessions"].add(session_id)
2703
+ elif source_path:
2704
+ # Fallback: treat one source_path as one session when
2705
+ # session_files.session_id is NULL (lazy population).
2706
+ b["sessions"].add(source_path)
2707
+ if ts < b["first_seen"]:
2708
+ b["first_seen"] = ts
2709
+ if ts > b["last_seen"]:
2710
+ b["last_seen"] = ts
2711
+ total_cost_by_week[wstart] = (
2712
+ total_cost_by_week.get(wstart, 0.0) + entry_cost
2713
+ )
2714
+ # Remember first-seen ProjectKey for each bucket_path so the
2715
+ # disambiguator pass below sees consistent ProjectKey instances.
2716
+ if pkey.bucket_path not in key_by_bucket:
2717
+ key_by_bucket[pkey.bucket_path] = pkey
2718
+
2719
+ # ---- Load weekly_usage_snapshots for attribution --------------------
2720
+ # weekly_percent keyed by week_start (UTC datetime, Monday). We
2721
+ # match on `week_start_date` (date-only) since that's the canonical
2722
+ # cross-table key per the CLAUDE.md weekly handling notes.
2723
+ #
2724
+ # We use the LATEST snapshot per week_start_date — NOT MAX — because
2725
+ # the "weekly_percent is monotonic within a week" invariant breaks
2726
+ # on weeks that receive an in-place credit (see CLAUDE.md "In-place
2727
+ # 5h credit" / `week_reset_events` notes). MAX would lock attribution
2728
+ # to the pre-credit high-water mark even after Anthropic credits the
2729
+ # week back down, overstating Used % on the Projects panel/modal
2730
+ # forever. The "latest row" pattern matches `_select_last_known_snapshot`
2731
+ # (bin/cctally:1162-1168) and the doctor credited-week check
2732
+ # (bin/cctally:8706-8714).
2733
+ #
2734
+ # Portable per-key-latest pattern: read rows ordered by capture-
2735
+ # ascending and let later rows overwrite. The final value per key
2736
+ # is the most-recent snapshot.
2737
+ weekly_pct_by_week: dict[dt.datetime, float] = {}
2738
+ try:
2739
+ cur = conn.execute(
2740
+ "SELECT week_start_date, weekly_percent "
2741
+ "FROM weekly_usage_snapshots "
2742
+ "ORDER BY captured_at_utc ASC, id ASC"
2743
+ )
2744
+ rows = cur.fetchall()
2745
+ except sqlite3.OperationalError:
2746
+ # No weekly_usage_snapshots table — leaves attributed_pct = None
2747
+ # throughout (acceptable per spec §2.7).
2748
+ rows = []
2749
+ for week_date_str, weekly_pct in rows:
2750
+ try:
2751
+ wd = dt.date.fromisoformat(week_date_str)
2752
+ except (TypeError, ValueError):
2753
+ continue
2754
+ # Snap the date to UTC Monday 00:00 (matches the bucketing key).
2755
+ wstart = dt.datetime.combine(
2756
+ wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
2757
+ )
2758
+ # Snap to Monday (snapshot rows that captured a non-Monday week
2759
+ # boundary still align to the same canonical bucket as the entry
2760
+ # walk, since the bucketing is Monday-anchored).
2761
+ wstart = _projects_week_start_monday_utc(wstart)
2762
+ if weekly_pct is not None:
2763
+ weekly_pct_by_week[wstart] = float(weekly_pct)
2764
+
2765
+ # ---- Disambiguate display_keys across the union of projects --------
2766
+ # `_project_disambiguate_labels` expects a list of dicts each with a
2767
+ # `key` field that's a ProjectKey. Sort by bucket_path for stable
2768
+ # indexing.
2769
+ bucket_paths_sorted = sorted(key_by_bucket.keys())
2770
+ disambig_rows = [
2771
+ {"key": key_by_bucket[bp]} for bp in bucket_paths_sorted
2772
+ ]
2773
+ augmented_by_idx = c._project_disambiguate_labels(disambig_rows)
2774
+ display_key_by_bucket: dict[str, str] = {}
2775
+ for idx, bp in enumerate(bucket_paths_sorted):
2776
+ pkey = key_by_bucket[bp]
2777
+ display_key_by_bucket[bp] = augmented_by_idx.get(
2778
+ idx, pkey.display_key,
2779
+ )
2780
+
2781
+ # ---- Determine actual weeks emitted (clamp to history) -------------
2782
+ weeks_with_activity = sorted(
2783
+ ws for ws in {ws for (_bp, ws) in buckets.keys()}
2784
+ )
2785
+ if weeks_with_activity:
2786
+ # Window = inclusive [oldest_active_week, cw_start]. Always emits
2787
+ # cw_start (panel + trend share the same current_week column).
2788
+ oldest = min(weeks_with_activity[0], cw_start)
2789
+ trend_weeks = []
2790
+ w = oldest
2791
+ while w <= cw_start:
2792
+ trend_weeks.append(w)
2793
+ w += dt.timedelta(days=7)
2794
+ else:
2795
+ trend_weeks = [cw_start]
2796
+
2797
+ # ---- current_week.rows ---------------------------------------------
2798
+ cw_rows = []
2799
+ cw_pct = weekly_pct_by_week.get(cw_start)
2800
+ cw_total_cost = total_cost_by_week.get(cw_start, 0.0)
2801
+ for bp in bucket_paths_sorted:
2802
+ b = buckets.get((bp, cw_start))
2803
+ if b is None:
2804
+ continue
2805
+ if cw_pct is not None and cw_total_cost > 0:
2806
+ attributed = (b["cost_usd"] / cw_total_cost) * cw_pct
2807
+ else:
2808
+ attributed = None
2809
+ cw_rows.append({
2810
+ "key": display_key_by_bucket[bp],
2811
+ "bucket_path": bp,
2812
+ # NOTE: NOT rounded — `round(..., 6)` introduces ~1e-6 error
2813
+ # that breaks the R-PROJ1/R-PROJ2 1e-9 reconcile tolerance.
2814
+ # The JSON serializer emits up to 17 significant digits;
2815
+ # network bandwidth is negligible (KB-scale payloads).
2816
+ "cost_usd": b["cost_usd"],
2817
+ "attributed_pct": attributed,
2818
+ "sessions_count": len(b["sessions"]),
2819
+ })
2820
+ # Desc by cost (ties broken by key for byte-stability across runs).
2821
+ cw_rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
2822
+
2823
+ cw_block = {
2824
+ "week_label": _projects_week_label(cw_start),
2825
+ "week_start_date": cw_start.date().isoformat(),
2826
+ "week_start_at": _iso_z(cw_start),
2827
+ "total_cost_usd": cw_total_cost,
2828
+ "rows": cw_rows,
2829
+ }
2830
+
2831
+ # ---- trend.weeks[] + trend.projects[] ------------------------------
2832
+ trend_weeks_blocks = []
2833
+ for w in trend_weeks:
2834
+ wpct = weekly_pct_by_week.get(w)
2835
+ trend_weeks_blocks.append({
2836
+ "week_start_date": w.date().isoformat(),
2837
+ "week_label": _projects_week_label(w),
2838
+ "total_cost_usd": total_cost_by_week.get(w, 0.0),
2839
+ "total_pct": wpct,
2840
+ })
2841
+
2842
+ trend_projects = []
2843
+ for bp in bucket_paths_sorted:
2844
+ weekly_cost: list[float] = []
2845
+ weekly_pct_arr: list[float | None] = []
2846
+ sessions_per_week: list[int] = []
2847
+ first_seen_per_week: list[str | None] = []
2848
+ last_seen_per_week: list[str | None] = []
2849
+ for w in trend_weeks:
2850
+ b = buckets.get((bp, w))
2851
+ if b is None:
2852
+ weekly_cost.append(0.0)
2853
+ weekly_pct_arr.append(None)
2854
+ sessions_per_week.append(0)
2855
+ first_seen_per_week.append(None)
2856
+ last_seen_per_week.append(None)
2857
+ continue
2858
+ week_total = total_cost_by_week.get(w, 0.0)
2859
+ week_pct = weekly_pct_by_week.get(w)
2860
+ if week_pct is not None and week_total > 0:
2861
+ attributed = (b["cost_usd"] / week_total) * week_pct
2862
+ else:
2863
+ attributed = None
2864
+ weekly_cost.append(b["cost_usd"])
2865
+ weekly_pct_arr.append(attributed)
2866
+ sessions_per_week.append(len(b["sessions"]))
2867
+ first_seen_per_week.append(_iso_z(b["first_seen"]))
2868
+ last_seen_per_week.append(_iso_z(b["last_seen"]))
2869
+ # Skip projects with zero total cost across the entire window
2870
+ # (the bucket-loop only enters projects that have at least one
2871
+ # entry, so this is mainly a safety check).
2872
+ if all(c == 0.0 for c in weekly_cost):
2873
+ continue
2874
+ trend_projects.append({
2875
+ "key": display_key_by_bucket[bp],
2876
+ "bucket_path": bp,
2877
+ "weekly_cost": weekly_cost,
2878
+ "weekly_pct": weekly_pct_arr,
2879
+ "sessions_per_week": sessions_per_week,
2880
+ "first_seen_per_week": first_seen_per_week,
2881
+ "last_seen_per_week": last_seen_per_week,
2882
+ })
2883
+ # Stable sort: desc by total window cost, ties broken by key.
2884
+ trend_projects.sort(
2885
+ key=lambda p: (-sum(p["weekly_cost"]), p["key"]),
2886
+ )
2887
+
2888
+ trend_block = {
2889
+ "window_weeks": len(trend_weeks),
2890
+ "weeks": trend_weeks_blocks,
2891
+ "projects": trend_projects,
2892
+ }
2893
+
2894
+ result = {
2895
+ "current_week": cw_block,
2896
+ "trend": trend_block,
2897
+ }
2898
+ _PROJECTS_ENV_MEMO["key"] = memo_key
2899
+ _PROJECTS_ENV_MEMO["value"] = result
2900
+ return result
2901
+
2902
+
2903
+ def _project_detail_for_window(
2904
+ conn: "sqlite3.Connection",
2905
+ *,
2906
+ project_key: str,
2907
+ weeks_back: int,
2908
+ now_utc: "dt.datetime",
2909
+ current_week: "Any | None" = None,
2910
+ projects_envelope: "dict | None" = None,
2911
+ ) -> "dict | None":
2912
+ """Build the drill payload for ``GET /api/project/<key>?weeks=N``
2913
+ (spec §5.3).
2914
+
2915
+ Resolves ``project_key`` against the same disambiguated display
2916
+ keys the envelope emits. Returns ``None`` on miss → caller maps to
2917
+ HTTP 404. Top-N sessions by ``last_activity`` desc (cap=5 per spec
2918
+ §5.3); models list is desc by cost. ``window_attributed_pct`` is
2919
+ the across-window sum of ``(project_cost_in_week / week_total) *
2920
+ week_pct`` — ``None`` when no contributing week has a snapshot.
2921
+
2922
+ Identity invariant (CLAUDE.md spec §9.2 R-PROJ3 + Codex F6):
2923
+ ``project_key`` is matched against the DISAMBIGUATED display_key
2924
+ (`foo (repos)` etc.), NOT a substring filter — the CLI
2925
+ ``--project <pattern>`` form does NOT reliably round-trip
2926
+ disambiguated keys. The reconcile harness asserts this by
2927
+ ``bucket_path`` identity, not pattern.
2928
+
2929
+ Performance — two layered optimizations make this O(project-rows-
2930
+ in-window) instead of O(all-rows-in-window):
2931
+
2932
+ 1. ``projects_envelope`` (HTTP path passes ``snap.projects_envelope``):
2933
+ reuse the sync thread's already-built envelope for the
2934
+ ``project_key`` → ``bucket_path`` resolution and the trend-pct
2935
+ lookup, instead of rebuilding. On an active dashboard, the
2936
+ per-process memo on ``_build_projects_envelope`` invalidates
2937
+ every time ``session_entries.id`` advances — between the sync
2938
+ tick and the user's click, that's almost always — so the memo
2939
+ saves nothing on the HTTP path. Plumbing the snapshot's
2940
+ envelope through skips ~1-2s of redundant work per drill.
2941
+
2942
+ 2. SQL-side bucket filter: resolve ``bucket_path`` → set of
2943
+ ``session_files.path`` once (cheap — ``session_files`` is
2944
+ ~8k rows vs ``session_entries`` 150k+), stage into a TEMP TABLE,
2945
+ and INNER JOIN the entries walk so the engine only touches this
2946
+ project's rows. Eliminates the Python-side
2947
+ ``if pkey.bucket_path != bucket_path: continue`` filter that
2948
+ previously discarded ~99% of rows after paying for parse +
2949
+ resolve + cost-compute on each.
2950
+ """
2951
+ c = _cctally()
2952
+
2953
+ # Resolve the projects envelope: prefer the snapshot-provided one
2954
+ # (sync thread already built it for this tick) over rebuilding
2955
+ # locally. ``None`` keeps the legacy behavior for callers that
2956
+ # don't have a snapshot (tests, the reconcile harness).
2957
+ if projects_envelope is not None:
2958
+ env = projects_envelope
2959
+ else:
2960
+ env = _build_projects_envelope(
2961
+ conn,
2962
+ now_utc=now_utc,
2963
+ current_week=current_week,
2964
+ weeks_back=weeks_back,
2965
+ )
2966
+
2967
+ # Resolve project_key → bucket_path via the envelope's identity map.
2968
+ bucket_path: "str | None" = None
2969
+ matching_trend: "dict | None" = None
2970
+ for tp in env["trend"]["projects"]:
2971
+ if tp["key"] == project_key:
2972
+ bucket_path = tp["bucket_path"]
2973
+ matching_trend = tp
2974
+ break
2975
+ if bucket_path is None:
2976
+ # The project may have shown up in current_week (panel only)
2977
+ # but had 0 cost across the window — fall back to that.
2978
+ for r in env["current_week"]["rows"]:
2979
+ if r["key"] == project_key:
2980
+ bucket_path = r["bucket_path"]
2981
+ break
2982
+ if bucket_path is None:
2983
+ return None
2984
+
2985
+ # ---- Window bounds (Monday-anchored UTC fallback, like the builder) -
2986
+ cw_start = parse_iso_datetime(
2987
+ env["current_week"]["week_start_at"],
2988
+ "projects.current_week.week_start_at",
2989
+ )
2990
+ since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
2991
+ until_dt = cw_start + dt.timedelta(days=7)
2992
+
2993
+ # ---- Build bucket → source_paths map for SQL-side scoping ----------
2994
+ # Walk session_files (~8k rows) once instead of session_entries
2995
+ # (~150k+ rows). _resolve_project_key gets called at most ~distinct-
2996
+ # project_paths times (~127 on real DBs) instead of once per entry,
2997
+ # and most of that is hashmap lookups via resolver_cache.
2998
+ resolver_cache: dict = {}
2999
+ _resolve_project_key_fn = c._resolve_project_key
3000
+ unknown_bucket = bucket_path == "(unknown)"
3001
+ bucket_source_paths: set[str] = set()
3002
+ sf_cur = conn.execute(
3003
+ "SELECT path, project_path FROM session_files"
3004
+ )
3005
+ for sf_path, sf_project_path in sf_cur:
3006
+ pkey = _resolve_project_key_fn(
3007
+ sf_project_path, "git-root", resolver_cache,
3008
+ )
3009
+ if pkey.bucket_path == bucket_path:
3010
+ bucket_source_paths.add(sf_path)
3011
+ if unknown_bucket:
3012
+ # session_entries rows whose source_path has no session_files
3013
+ # row at all LEFT-JOIN to NULL project_path → resolve to
3014
+ # ``(unknown)`` via _resolve_project_key. session_files-only
3015
+ # scan misses those.
3016
+ #
3017
+ # This explicit orphan scan is the drill-down's mirror of the
3018
+ # envelope's implicit orphan path: the envelope walk in
3019
+ # `_build_projects_envelope` (see the `_projects_iter_session_entries`
3020
+ # loop above) routes the same orphan source_paths through the
3021
+ # LEFT-JOIN→NULL→`_resolve_project_key(None, ...)` chain into the
3022
+ # ``(unknown)`` bucket. Both surfaces converge on the same
3023
+ # source_path set so envelope row counts/costs and drill row
3024
+ # counts/costs reconcile.
3025
+ orphan_cur = conn.execute(
3026
+ "SELECT DISTINCT e.source_path "
3027
+ "FROM session_entries e "
3028
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
3029
+ "WHERE sf.path IS NULL AND e.source_path IS NOT NULL"
3030
+ )
3031
+ for (sp,) in orphan_cur:
3032
+ bucket_source_paths.add(sp)
3033
+
3034
+ if not bucket_source_paths:
3035
+ # Project visible in envelope but no contributing source_paths
3036
+ # for this conn (rare edge — e.g. an envelope built off a
3037
+ # different conn). Emit an empty drill so the 404 path stays
3038
+ # reserved for "key unknown."
3039
+ return {
3040
+ "key": project_key,
3041
+ "bucket_path": bucket_path,
3042
+ "window_weeks": weeks_back,
3043
+ "window_cost_usd": 0.0,
3044
+ "window_attributed_pct": None,
3045
+ "models": [],
3046
+ "sessions": [],
3047
+ "models_total": 0,
3048
+ "sessions_total": 0,
3049
+ }
3050
+
3051
+ # Stage bucket source_paths into a TEMP TABLE so the entries walk
3052
+ # can INNER JOIN an indexed lookup. ``IN (?, ?, ?, ...)`` would
3053
+ # collide with SQLite's parameter cap (~999 bindings) on heavy
3054
+ # multi-cwd projects. DROP-then-CREATE makes the function
3055
+ # re-entrant on a reused conn (the HTTP handler closes the conn
3056
+ # per request, but tests share conns across calls).
3057
+ conn.execute("DROP TABLE IF EXISTS temp._drill_paths")
3058
+ conn.execute(
3059
+ "CREATE TEMP TABLE _drill_paths(path TEXT PRIMARY KEY)"
3060
+ )
3061
+ conn.executemany(
3062
+ "INSERT OR IGNORE INTO _drill_paths(path) VALUES (?)",
3063
+ [(p,) for p in bucket_source_paths],
3064
+ )
3065
+
3066
+ since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
3067
+ "%Y-%m-%dT%H:%M:%SZ"
3068
+ )
3069
+ until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
3070
+ "%Y-%m-%dT%H:%M:%SZ"
3071
+ )
3072
+
3073
+ # ---- Walk session_entries (project-scoped) once -------------------
3074
+ # INNER JOIN to _drill_paths drops every row whose source_path
3075
+ # doesn't belong to this bucket. The Python-side filter that
3076
+ # previously discarded ~99% of rows post-resolve is gone.
3077
+ entries_cur = conn.execute(
3078
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
3079
+ " e.output_tokens, e.cache_create_tokens, "
3080
+ " e.cache_read_tokens, e.cost_usd_raw, e.source_path, "
3081
+ " sf.session_id, sf.project_path "
3082
+ "FROM session_entries e "
3083
+ "INNER JOIN _drill_paths dp ON dp.path = e.source_path "
3084
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
3085
+ "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
3086
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
3087
+ (since_iso, until_iso),
3088
+ )
3089
+
3090
+ # Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
3091
+ models: dict[str, dict] = {}
3092
+ # Per-session rollup: {session_id -> {cost_usd, last_activity,
3093
+ # started_at, primary_model}}
3094
+ sessions: dict[str, dict] = {}
3095
+ # Aggregate scalars across the window.
3096
+ window_cost = 0.0
3097
+ window_input_t = 0
3098
+ window_output_t = 0
3099
+
3100
+ for row in entries_cur:
3101
+ (entry_id, ts_iso, model, input_tok, output_tok,
3102
+ cache_create, cache_read, cost_raw, source_path,
3103
+ session_id, project_path) = row
3104
+ if model == "<synthetic>":
3105
+ continue
3106
+ # No need to call _resolve_project_key here — the INNER JOIN
3107
+ # on _drill_paths already restricted the result set to entries
3108
+ # whose source_path belongs to this bucket.
3109
+ ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3110
+ entry_cost = _calculate_entry_cost(
3111
+ model,
3112
+ {
3113
+ "input_tokens": input_tok or 0,
3114
+ "output_tokens": output_tok or 0,
3115
+ "cache_creation_input_tokens": cache_create or 0,
3116
+ "cache_read_input_tokens": cache_read or 0,
3117
+ },
3118
+ mode="auto",
3119
+ cost_usd=cost_raw,
3120
+ )
3121
+ window_cost += entry_cost
3122
+ window_input_t += int(input_tok or 0)
3123
+ window_output_t += int(output_tok or 0)
3124
+
3125
+ # Per-model rollup.
3126
+ m = models.get(model)
3127
+ if m is None:
3128
+ m = {
3129
+ "model": model,
3130
+ "cost_usd": 0.0,
3131
+ "sessions": set(),
3132
+ "tokens_input": 0,
3133
+ "tokens_output": 0,
3134
+ }
3135
+ models[model] = m
3136
+ m["cost_usd"] += entry_cost
3137
+ m["tokens_input"] += int(input_tok or 0)
3138
+ m["tokens_output"] += int(output_tok or 0)
3139
+ if session_id:
3140
+ m["sessions"].add(session_id)
3141
+ elif source_path:
3142
+ m["sessions"].add(source_path)
3143
+
3144
+ # Per-session rollup.
3145
+ sid = session_id or source_path
3146
+ if not sid:
3147
+ continue
3148
+ s = sessions.get(sid)
3149
+ if s is None:
3150
+ s = {
3151
+ "session_id": sid,
3152
+ "started_at": ts,
3153
+ "last_activity_at": ts,
3154
+ "primary_model": model,
3155
+ "cost_usd": 0.0,
3156
+ }
3157
+ sessions[sid] = s
3158
+ else:
3159
+ if ts < s["started_at"]:
3160
+ s["started_at"] = ts
3161
+ if ts > s["last_activity_at"]:
3162
+ s["last_activity_at"] = ts
3163
+ s["cost_usd"] += entry_cost
3164
+
3165
+ # Models desc by cost (ties broken by model name for stability).
3166
+ models_list = sorted(
3167
+ models.values(),
3168
+ key=lambda m: (-m["cost_usd"], m["model"]),
3169
+ )
3170
+ models_out = []
3171
+ for m in models_list:
3172
+ models_out.append({
3173
+ "model": m["model"],
3174
+ "cost_usd": m["cost_usd"],
3175
+ "sessions_count": len(m["sessions"]),
3176
+ "tokens_input": m["tokens_input"],
3177
+ "tokens_output": m["tokens_output"],
3178
+ })
3179
+
3180
+ # Sessions: top-5 by last_activity_at desc (per spec §5.3).
3181
+ sessions_sorted = sorted(
3182
+ sessions.values(),
3183
+ key=lambda s: s["last_activity_at"],
3184
+ reverse=True,
3185
+ )
3186
+ sessions_out = []
3187
+ for s in sessions_sorted[:5]:
3188
+ sessions_out.append({
3189
+ "session_id": s["session_id"],
3190
+ "started_at": _iso_z(s["started_at"]),
3191
+ "last_activity_at": _iso_z(s["last_activity_at"]),
3192
+ "primary_model": s["primary_model"],
3193
+ "cost_usd": s["cost_usd"],
3194
+ })
3195
+
3196
+ # window_attributed_pct: prefer the trend projection (already
3197
+ # computed correctly). Sum across weeks within the requested
3198
+ # ``weeks_back`` window; None when all contributing weeks lack
3199
+ # snapshots.
3200
+ #
3201
+ # IMPORTANT: when the HTTP path reuses ``snap.projects_envelope``
3202
+ # (built by the sync thread with ``weeks_back=12``),
3203
+ # ``matching_trend["weekly_pct"]`` is always a 12-element array even
3204
+ # when the drill is requested with ``?weeks=1|4|8``. Slice to the
3205
+ # trailing ``weeks_back`` entries — same pattern as
3206
+ # ``snapshot_to_share_envelope`` at line 1629 — so the answer doesn't
3207
+ # depend on whether the envelope was rebuilt or reused.
3208
+ win_pct: "float | None" = None
3209
+ if matching_trend is not None:
3210
+ weekly_pct_arr = matching_trend.get("weekly_pct") or []
3211
+ n = len(weekly_pct_arr)
3212
+ take = min(weeks_back, n) if n > 0 else 0
3213
+ sliced = weekly_pct_arr[-take:] if take > 0 else []
3214
+ wp = [p for p in sliced if p is not None]
3215
+ if wp:
3216
+ win_pct = sum(wp)
3217
+
3218
+ # Best-effort cleanup of the per-call TEMP TABLE so a reused conn
3219
+ # doesn't carry path state into the next drill (tests share conns;
3220
+ # production HTTP closes the conn so this is a no-op there).
3221
+ try:
3222
+ conn.execute("DROP TABLE IF EXISTS temp._drill_paths")
3223
+ except sqlite3.Error:
3224
+ pass
3225
+
3226
+ return {
3227
+ "key": project_key,
3228
+ "bucket_path": bucket_path,
3229
+ "window_weeks": weeks_back,
3230
+ "window_cost_usd": window_cost,
3231
+ "window_attributed_pct": win_pct,
3232
+ "models": models_out,
3233
+ "sessions": sessions_out,
3234
+ "models_total": len(models_out),
3235
+ "sessions_total": len(sessions),
3236
+ }
3237
+
3238
+
3239
+ # Test-surface impl. The HTTP handler `_handle_get_project_detail`
3240
+ # delegates here so unit tests can exercise the path parser + dispatch
3241
+ # logic without spinning up a real server. ``handler`` is anything that
3242
+ # exposes ``self.path`` (the URL path + query), ``send_response``,
3243
+ # ``send_header``, ``end_headers``, ``send_error``, and ``wfile.write``
3244
+ # — that's the BaseHTTPRequestHandler surface plus the
3245
+ # ``test_dashboard_project_endpoint._FakeHandler`` stand-in.
3246
+ def _handle_get_project_detail_impl(handler, *,
3247
+ conn: "sqlite3.Connection") -> None:
3248
+ """Shared impl for ``GET /api/project/<key>?weeks=N``.
3249
+
3250
+ Parses the percent-decoded key + the ``weeks`` query param,
3251
+ validates ``weeks ∈ {1, 4, 8, 12}``, then delegates to
3252
+ ``_project_detail_for_window``. 400 on missing/invalid weeks,
3253
+ 404 on unknown key, 200 with the detail JSON otherwise.
3254
+ """
3255
+ import urllib.parse as _urlparse
3256
+ raw_path = handler.path
3257
+ path_only, sep, query_str = raw_path.partition("?")
3258
+ # Strip the route prefix; what remains is the percent-encoded key.
3259
+ raw_key = path_only[len("/api/project/"):]
3260
+ project_key = _urlparse.unquote(raw_key)
3261
+ # Parse `weeks` from the query string.
3262
+ query = _urlparse.parse_qs(query_str)
3263
+ weeks_vals = query.get("weeks", [None])
3264
+ weeks_raw = weeks_vals[0] if weeks_vals else None
3265
+ try:
3266
+ weeks = int(weeks_raw) if weeks_raw is not None else None
3267
+ except (TypeError, ValueError):
3268
+ weeks = None
3269
+ if weeks not in {1, 4, 8, 12}:
3270
+ body = json.dumps({"error": "invalid weeks param"}).encode("utf-8")
3271
+ handler.send_response(400)
3272
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
3273
+ handler.send_header("Content-Length", str(len(body)))
3274
+ handler.end_headers()
3275
+ handler.wfile.write(body)
3276
+ return
3277
+
3278
+ # ``now_utc`` should mirror the current snapshot's generated_at so
3279
+ # the endpoint stays consistent with the panel rows the user just
3280
+ # clicked through. Pull it from the snapshot when available;
3281
+ # otherwise fall back to _command_as_of (CCTALLY_AS_OF honored).
3282
+ snap = None
3283
+ try:
3284
+ snap_ref = getattr(handler, "snapshot_ref", None)
3285
+ if snap_ref is not None:
3286
+ snap = snap_ref.get()
3287
+ except Exception:
3288
+ snap = None
3289
+ if snap is not None and getattr(snap, "generated_at", None) is not None:
3290
+ now_utc = snap.generated_at
3291
+ else:
3292
+ now_utc = _command_as_of()
3293
+ current_week = getattr(snap, "current_week", None) if snap else None
3294
+ # Reuse the sync-thread-built envelope when available. The per-process
3295
+ # memo invalidates on every session_entries.id advance, so on an
3296
+ # active dashboard the drill would otherwise rebuild the envelope
3297
+ # from scratch on each click (~1-2s wasted). Plumbing it through
3298
+ # skips that work; tests don't set ``projects_envelope`` on the
3299
+ # fake snapshot and fall back to the legacy build path.
3300
+ projects_envelope = getattr(snap, "projects_envelope", None) if snap else None
3301
+
3302
+ try:
3303
+ detail = _project_detail_for_window(
3304
+ conn,
3305
+ project_key=project_key,
3306
+ weeks_back=weeks,
3307
+ now_utc=now_utc,
3308
+ current_week=current_week,
3309
+ projects_envelope=projects_envelope,
3310
+ )
3311
+ except Exception as exc:
3312
+ handler.log_error("/api/project failed: %r", exc)
3313
+ body = json.dumps(
3314
+ {"error": "project detail failed"}
3315
+ ).encode("utf-8")
3316
+ handler.send_response(500)
3317
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
3318
+ handler.send_header("Content-Length", str(len(body)))
3319
+ handler.end_headers()
3320
+ handler.wfile.write(body)
3321
+ return
3322
+ if detail is None:
3323
+ body = json.dumps(
3324
+ {"error": "project not found", "key": project_key},
3325
+ ).encode("utf-8")
3326
+ handler.send_response(404)
3327
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
3328
+ handler.send_header("Content-Length", str(len(body)))
3329
+ handler.end_headers()
3330
+ handler.wfile.write(body)
3331
+ return
3332
+ body = json.dumps(detail, ensure_ascii=False).encode("utf-8")
3333
+ handler.send_response(200)
3334
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
3335
+ handler.send_header("Content-Length", str(len(body)))
3336
+ handler.send_header("Cache-Control", "no-cache")
3337
+ handler.end_headers()
3338
+ handler.wfile.write(body)
3339
+
3340
+
2313
3341
  def _empty_dashboard_snapshot() -> "DataSnapshot":
2314
3342
  """A minimal DataSnapshot used at startup before the first sync
2315
3343
  completes. All panels render placeholders; the sync thread replaces
@@ -3188,12 +4216,26 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
3188
4216
  "duration_min": int(round(s.duration_minutes)) if s.duration_minutes else 0,
3189
4217
  "model": s.model_primary,
3190
4218
  "project": s.project_label,
4219
+ # Projects-panel cross-nav identity (spec §4.1).
4220
+ # Disambiguated display_key matching the projects
4221
+ # envelope's `current_week.rows[].key`; ``None`` when
4222
+ # the projects envelope sub-build failed, the row's
4223
+ # project_path is missing, or the lookup didn't
4224
+ # resolve (the React cell falls back to plain text).
4225
+ "project_key": getattr(s, "project_key", None),
3191
4226
  "cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
3192
4227
  }
3193
4228
  for s in snap.sessions
3194
4229
  ],
3195
4230
  },
3196
4231
 
4232
+ # Projects panel + modal envelope block (spec §5.2).
4233
+ # Populated on the sync thread; the serializer reads it back
4234
+ # unchanged so it stays a pure renderer (no DB I/O). ``None``
4235
+ # on first tick before sync completes; the client renders the
4236
+ # panel-empty state in that case.
4237
+ "projects": getattr(snap, "projects_envelope", None),
4238
+
3197
4239
  # threshold-actions T5: see prelude above for rationale.
3198
4240
  "alerts": alerts_array,
3199
4241
  "alerts_settings": alerts_settings,
@@ -3361,6 +4403,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
3361
4403
  self._serve_api_events()
3362
4404
  elif path.startswith("/api/session/"):
3363
4405
  self._handle_get_session_detail(path)
4406
+ elif path.startswith("/api/project/"):
4407
+ self._handle_get_project_detail()
3364
4408
  elif path.startswith("/api/block/"):
3365
4409
  self._handle_get_block_detail(path)
3366
4410
  elif path == "/api/update/status":
@@ -4870,6 +5914,57 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4870
5914
  self.end_headers()
4871
5915
  self.wfile.write(body)
4872
5916
 
5917
+ def _handle_get_project_detail(self) -> None:
5918
+ """Return ProjectDetail JSON for ``GET /api/project/<key>?weeks=N``
5919
+ (spec §5.3 / §6.5).
5920
+
5921
+ Opens the stats DB and ATTACHes cache.db so the shared builder
5922
+ sees both schemas off one conn (same contract the sync thread
5923
+ uses). Loopback bind + Origin parity is the entire auth
5924
+ surface — no CSRF needed for GETs.
5925
+ """
5926
+ try:
5927
+ conn = open_db()
5928
+ except Exception as exc:
5929
+ self.log_error("/api/project open_db failed: %r", exc)
5930
+ self.send_error(500, "project detail failed")
5931
+ return
5932
+ try:
5933
+ try:
5934
+ c = _cctally()
5935
+ conn.execute(
5936
+ "ATTACH DATABASE ? AS cache_db",
5937
+ (str(c.CACHE_DB_PATH),),
5938
+ )
5939
+ conn.execute(
5940
+ "CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
5941
+ "SELECT * FROM cache_db.session_entries"
5942
+ )
5943
+ conn.execute(
5944
+ "CREATE TEMP VIEW IF NOT EXISTS session_files AS "
5945
+ "SELECT * FROM cache_db.session_files"
5946
+ )
5947
+ except Exception as exc:
5948
+ # ATTACH/CREATE failure → fall back to the stats conn
5949
+ # alone; the builder will see no session_entries and
5950
+ # return None on key match. We still want to 500 here
5951
+ # because the dashboard contract is "should have both".
5952
+ self.log_error("/api/project ATTACH failed: %r", exc)
5953
+ self.send_error(500, "project detail failed")
5954
+ return
5955
+ _handle_get_project_detail_impl(self, conn=conn)
5956
+ finally:
5957
+ try:
5958
+ conn.execute("DROP VIEW IF EXISTS session_entries")
5959
+ conn.execute("DROP VIEW IF EXISTS session_files")
5960
+ except Exception:
5961
+ pass
5962
+ try:
5963
+ conn.execute("DETACH DATABASE cache_db")
5964
+ except Exception:
5965
+ pass
5966
+ conn.close()
5967
+
4873
5968
  def _handle_get_block_detail(self, path: str) -> None:
4874
5969
  """Return BlockDetail JSON for the block whose start_time equals
4875
5970
  the URL-encoded ISO-8601 UTC datetime in the path tail.