cctally 1.50.0 → 1.51.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.51.0] - 2026-06-19
9
+
10
+ ### Added
11
+ - **`record-credit` — manually record an in-place weekly credit the auto-detector misses.** When Anthropic lowers your 7-day usage counter mid-window by a sub-25pp, non-zero amount (e.g. 46%→31%) instead of a clean reset, neither auto-detector fires and the monotonic clamp pins every display at the stale peak. `cctally record-credit --to 31` records the credit explicitly and repairs the data while keeping the **same week** (no re-anchor): it records a clamp floor for the current week, lowers `hwm-7d`, clears stale replays, and inserts a post-credit snapshot so reports and the statusline read the credited value while the week boundaries and the forecast rate stay correct. As usage resumes, a later reading below the pre-credit peak (e.g. 37%) is stored normally rather than suppressed. Preview-and-confirm by default (`--dry-run` / `--yes`), with `--from` / `--at` / `--week` overrides, a `--force` clean re-record that never touches real history, and a `--json` (schemaVersion 1) envelope. See `docs/commands/record-credit.md`.
12
+
13
+ ### Fixed
14
+ - **Dashboard conversation viewer — the mobile fix that stops the sticky topbar from hiding `← Back` now holds on notched phones, and in-page panel jumps land correctly.** The mobile topbar height budget (`--topbar-h`) was a flat 104px, but the real two-row mobile header renders ~148px; the #205 reader-height fix compensated only for the no-safe-area (headless) case, so on a notched device (`env(safe-area-inset-top)`) roughly 30px of document overflow returned and the sticky topbar could again scroll over the reader's `← Back` button. `--topbar-h` is now measured safe-area-aware (`calc(142px + max(6px, env(safe-area-inset-top)))`) so it tracks the real header height at any inset — the conversation reader fills the viewport with no document overflow on every phone, and because that same variable is every panel's scroll-anchor floor, in-page jumps and `scrollIntoView()` now land panel titles flush just below the header instead of ~44px under it. Desktop is byte-identical (#206).
15
+
8
16
  ## [1.50.0] - 2026-06-18
9
17
 
10
18
  ### Fixed
@@ -1365,6 +1365,34 @@ def open_db() -> sqlite3.Connection:
1365
1365
  """
1366
1366
  )
1367
1367
 
1368
+ # In-place weekly partial-credit floor (issue #209, record-credit M2).
1369
+ # Plain CREATE TABLE IF NOT EXISTS, NO migration handler / NO user_version
1370
+ # bump — the same framework-untracked posture as `project_budget_milestones`
1371
+ # above. A `record-credit` invocation records a weekly credit (e.g.
1372
+ # 46% -> 31%) WITHOUT writing a `week_reset_events` row: a credit lowers the
1373
+ # current-7d clamp floor only and must NOT re-anchor the week window (the
1374
+ # `week_reset_events`-driven window-resolution code would otherwise show a
1375
+ # spurious "new week" and corrupt the forecast rate). `_reset_aware_floor`
1376
+ # (below) unions this table with `week_reset_events` so the four MAX-clamp
1377
+ # sites floor the current % to the post-credit value while the window stays
1378
+ # put. `effective_at_utc` is `floor_to_hour(at)` in UTC; `applied_at_utc` is
1379
+ # audit-only (kept out of goldens). Lives BEFORE the migration dispatcher: a
1380
+ # plain CREATE on a framework-untracked table never touches
1381
+ # `schema_migrations`, so the dispatcher's fresh-install snapshot is
1382
+ # unaffected. See docs/superpowers/specs/2026-06-19-record-credit-weekly-design.md §2/§4a.
1383
+ conn.execute(
1384
+ """
1385
+ CREATE TABLE IF NOT EXISTS weekly_credit_floors (
1386
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1387
+ week_start_date TEXT NOT NULL,
1388
+ effective_at_utc TEXT NOT NULL,
1389
+ observed_pre_credit_pct REAL NOT NULL,
1390
+ applied_at_utc TEXT NOT NULL,
1391
+ UNIQUE(week_start_date, effective_at_utc)
1392
+ )
1393
+ """
1394
+ )
1395
+
1368
1396
  # Migration framework dispatcher. Replaces the prior inline gate stack
1369
1397
  # (has_blocks + _migration_done) with the framework's _run_pending_-
1370
1398
  # migrations entry point. See spec §2.3, §5.2 + the migration handlers
@@ -1523,6 +1551,54 @@ def _get_latest_row_for_week(
1523
1551
  ).fetchone()
1524
1552
 
1525
1553
 
1554
+ def _reset_aware_floor(
1555
+ conn: sqlite3.Connection,
1556
+ week_start_date: str,
1557
+ week_start_at: str,
1558
+ week_end_at: str,
1559
+ ) -> str | None:
1560
+ """Return the latest in-week clamp floor (an ISO timestamp) across BOTH
1561
+ `week_reset_events` and `weekly_credit_floors`, or None when neither has a
1562
+ row for this week.
1563
+
1564
+ This is the single chokepoint the four MAX-clamp sites consult to floor the
1565
+ current 7d % to the most-recent in-place credit / reset effective moment
1566
+ (record-credit M2, issue #209, spec §4a):
1567
+ - statusline `_hwm_clamp` 7d (bin/_cctally_statusline.py)
1568
+ - the record-usage write-site monotonic clamp (bin/_cctally_record.py)
1569
+ - `_resolve_reset_aware_hwm` (the --from default helper)
1570
+ - `project`'s `_load_week_snapshots` per-week MAX (bin/_cctally_project.py)
1571
+
1572
+ A `week_reset_events` row counts iff its `effective_reset_at_utc` falls in
1573
+ `[week_start_at, week_end_at)`; a `weekly_credit_floors` row counts iff its
1574
+ `week_start_date` matches (record-credit always stamps `effective_at_utc`
1575
+ inside the week, validated at plan-build time).
1576
+
1577
+ The latest floor wins via `ORDER BY unixepoch(floor_at) DESC LIMIT 1` —
1578
+ `unixepoch()`, NOT a textual `MAX(...)`: the two legs carry mixed offset
1579
+ spellings (`Z` / `+00:00`), and a lexical MAX would silently mis-order them
1580
+ on a non-UTC host (the same gotcha as the statusline clamp / 5h-block
1581
+ cross-reset flag; see the comment at bin/_cctally_statusline.py)."""
1582
+ row = conn.execute(
1583
+ """
1584
+ SELECT floor_at FROM (
1585
+ SELECT effective_reset_at_utc AS floor_at
1586
+ FROM week_reset_events
1587
+ WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
1588
+ AND unixepoch(effective_reset_at_utc) < unixepoch(?)
1589
+ UNION ALL
1590
+ SELECT effective_at_utc AS floor_at
1591
+ FROM weekly_credit_floors
1592
+ WHERE week_start_date = ?
1593
+ )
1594
+ ORDER BY unixepoch(floor_at) DESC
1595
+ LIMIT 1
1596
+ """,
1597
+ (week_start_at, week_end_at, week_start_date),
1598
+ ).fetchone()
1599
+ return row[0] if row and row[0] else None
1600
+
1601
+
1526
1602
  def get_latest_usage_for_week(
1527
1603
  conn: sqlite3.Connection,
1528
1604
  week_ref: WeekRef,
@@ -4988,6 +4988,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4988
4988
  "_error": f"{type(exc).__name__}: {exc}",
4989
4989
  }
4990
4990
 
4991
+ # B1 (#207): the "vs last week" header delta reuses the is_current trend
4992
+ # row's delta_dpp ($/1% vs the previous trend row — normally the prior
4993
+ # subscription week). Select by the is_current FLAG, not snap.trend[-1]:
4994
+ # snap.trend is oldest-first by week_start_date and reset/credit handling
4995
+ # can synthesize an intervening row, so position -1 is not guaranteed to
4996
+ # be the current week (Codex P1). reversed() picks the latest if ever
4997
+ # multiple are flagged. Null-safe: None when no row is current, or when
4998
+ # the current row's delta_dpp is itself None — both hide the stat.
4999
+ _current_trend = next(
5000
+ (r for r in reversed(snap.trend) if r.is_current), None
5001
+ ) if snap.trend else None
5002
+
4991
5003
  return {
4992
5004
  "envelope_version": 2,
4993
5005
  "generated_at": _iso_z(snap.generated_at),
@@ -5011,7 +5023,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5011
5023
  "dollar_per_pct": dollar_pp,
5012
5024
  "forecast_pct": header_fcast_pct,
5013
5025
  "forecast_verdict": verdict,
5014
- "vs_last_week_delta": None, # populated if/when trend comparison lands
5026
+ "vs_last_week_delta": (
5027
+ _current_trend.delta_dpp if _current_trend is not None else None
5028
+ ),
5015
5029
  },
5016
5030
 
5017
5031
  "current_week":
@@ -5387,6 +5401,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5387
5401
  if path == "/":
5388
5402
  self._serve_static_file(self.static_dir / "dashboard.html",
5389
5403
  "text/html; charset=utf-8")
5404
+ elif path == "/favicon.ico":
5405
+ # #207 D11 — serve the SVG favicon for the browser's default
5406
+ # /favicon.ico request so it stops 404-ing even absent the
5407
+ # <link rel="icon"> in dashboard.html. Vite copies public/ verbatim
5408
+ # into the build output, so favicon.svg lands under static_dir.
5409
+ self._serve_static_file(self.static_dir / "favicon.svg",
5410
+ "image/svg+xml")
5390
5411
  elif path.startswith("/static/"):
5391
5412
  # Defense in depth: a lexical ".." pre-check is bypassable via
5392
5413
  # percent-encoding (e.g., %2e%2e), so the authoritative guard is the
@@ -1610,6 +1610,51 @@ def build_parser() -> argparse.ArgumentParser:
1610
1610
  )
1611
1611
  ru.set_defaults(func=c.cmd_record_usage)
1612
1612
 
1613
+ rc = sub.add_parser(
1614
+ "record-credit",
1615
+ help="Record an in-place weekly credit the auto-detector misses",
1616
+ formatter_class=CLIHelpFormatter,
1617
+ description=textwrap.dedent(
1618
+ """\
1619
+ Record an in-place weekly (7d) credit that the auto-detector
1620
+ misses (a sub-25pp, non-zero drop — e.g. Anthropic lowered your
1621
+ 7d % from 46 to 31 without a clean reset). Writes a
1622
+ weekly_credit_floors clamp row (no week re-anchor — the same week
1623
+ continues), lowers hwm-7d, and inserts a post-credit snapshot so
1624
+ reports and the statusline read the credited value.
1625
+ Preview + confirm by default.
1626
+ """
1627
+ ),
1628
+ epilog=textwrap.dedent(
1629
+ """\
1630
+ Examples:
1631
+ cctally record-credit --to 31 # baseline auto-read from HWM
1632
+ cctally record-credit --to 31 --dry-run # preview, write nothing
1633
+ cctally record-credit --to 31 --yes # apply without prompting
1634
+
1635
+ Exit codes: 0 success (incl. --dry-run and an interactive decline),
1636
+ 2 validation/refuse, 3 on a database error.
1637
+ """
1638
+ ),
1639
+ )
1640
+ rc.add_argument("--to", required=True, type=float,
1641
+ help="New post-credit weekly %% (0-100).")
1642
+ rc.add_argument("--from", dest="from_pct", metavar="FROM", type=float, default=None,
1643
+ help="Pre-credit baseline %% (default: current HWM for the week).")
1644
+ rc.add_argument("--at", default=None,
1645
+ help="Effective credit moment (ISO; naive=UTC; default now).")
1646
+ rc.add_argument("--week", default=None,
1647
+ help="week_start_date YYYY-MM-DD (default: current week).")
1648
+ rc.add_argument("--dry-run", action="store_true",
1649
+ help="Preview only; write nothing.")
1650
+ rc.add_argument("--yes", action="store_true",
1651
+ help="Apply without the confirm prompt.")
1652
+ rc.add_argument("--force", action="store_true",
1653
+ help="Re-record when a credit is already fully recorded for the week.")
1654
+ rc.add_argument("--json", action="store_true",
1655
+ help="Machine output (schemaVersion 1).")
1656
+ rc.set_defaults(func=c.cmd_record_credit)
1657
+
1613
1658
  rfu = sub.add_parser(
1614
1659
  "refresh-usage",
1615
1660
  help="Force-fetch 7d/5h percent from OAuth API and record it",
@@ -26,7 +26,13 @@ import json
26
26
  import os
27
27
  import sys
28
28
 
29
- from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
29
+ from _cctally_core import (
30
+ _command_as_of,
31
+ _reset_aware_floor,
32
+ eprint,
33
+ open_db,
34
+ parse_iso_datetime,
35
+ )
30
36
  from _lib_fmt import stable_sum
31
37
 
32
38
 
@@ -53,22 +59,59 @@ def _load_week_snapshots(
53
59
  `+03:00` from pre-UTC-cast canonicalizer history) coalesce into one
54
60
  bucket instead of splitting and silently dropping the higher value.
55
61
 
62
+ Reset-aware (record-credit M2, #209): each week's MAX is restricted to
63
+ snapshots captured at-or-after that week's latest in-place clamp floor
64
+ (`_reset_aware_floor`, the union of `week_reset_events` +
65
+ `weekly_credit_floors`). Without this, a credited week's per-project Used %
66
+ would read the stale pre-credit peak (e.g. 46) instead of the post-credit
67
+ value (e.g. 31) — the same floor the statusline / write-clamp / `--from`
68
+ helper apply (spec §4a, test S15). The floor compare uses `unixepoch()` on
69
+ both sides (mixed `Z` / `+00:00` offset spellings).
70
+
56
71
  Returns an empty dict if the stats DB has no relevant rows.
57
72
  """
58
73
  conn = open_db()
59
74
  try:
60
75
  cur = conn.execute(
61
- "SELECT week_start_at, weekly_percent FROM weekly_usage_snapshots "
76
+ "SELECT week_start_date, week_start_at, week_end_at, "
77
+ " captured_at_utc, weekly_percent "
78
+ "FROM weekly_usage_snapshots "
62
79
  "WHERE week_start_at IS NOT NULL "
63
80
  "AND week_end_at IS NOT NULL "
64
81
  "AND datetime(week_start_at) < datetime(?) "
65
82
  "AND datetime(week_end_at) > datetime(?)",
66
83
  (until.isoformat(), since.isoformat()),
67
84
  )
85
+ rows = cur.fetchall()
86
+ # Resolve each week's clamp floor once (keyed on week_start_date) so a
87
+ # mid-week credit doesn't re-inflate the per-project Used %. None means
88
+ # "no floor" — every captured row counts.
89
+ floor_epoch: dict[str, int | None] = {}
90
+
91
+ def _floor_for(wsd, ws_iso, we_iso):
92
+ if wsd not in floor_epoch:
93
+ f_iso = _reset_aware_floor(conn, wsd, ws_iso, we_iso)
94
+ floor_epoch[wsd] = (
95
+ int(parse_iso_datetime(f_iso, "project.floor").timestamp())
96
+ if f_iso else None
97
+ )
98
+ return floor_epoch[wsd]
99
+
68
100
  result: dict[dt.datetime, float] = {}
69
- for ws_iso, pct in cur.fetchall():
101
+ for wsd, ws_iso, we_iso, cap_iso, pct in rows:
70
102
  if ws_iso is None or pct is None:
71
103
  continue
104
+ floor = _floor_for(wsd, ws_iso, we_iso)
105
+ if floor is not None and cap_iso is not None:
106
+ try:
107
+ cap_epoch = int(
108
+ parse_iso_datetime(str(cap_iso), "project.cap").timestamp()
109
+ )
110
+ except ValueError:
111
+ cap_epoch = None
112
+ # Drop pre-floor (stale pre-credit) snapshots from the MAX.
113
+ if cap_epoch is not None and cap_epoch < floor:
114
+ continue
72
115
  ws = dt.datetime.fromisoformat(
73
116
  str(ws_iso).replace("Z", "+00:00")
74
117
  )