cctally 1.100.0 → 1.101.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 +44 -0
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +713 -166
- package/bin/_cctally_dashboard_envelope.py +28 -38
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +304 -96
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_record.py +7 -1
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_forecast.py +12 -4
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +10 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
- package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
|
@@ -982,6 +982,8 @@ def _source_safe_claude_project_detail(
|
|
|
982
982
|
"key": key,
|
|
983
983
|
"label": label,
|
|
984
984
|
"window_weeks": detail.get("window_weeks"),
|
|
985
|
+
"window_start_at": detail.get("window_start_at"),
|
|
986
|
+
"window_end_at": detail.get("window_end_at"),
|
|
985
987
|
"window_cost_usd": detail.get("window_cost_usd"),
|
|
986
988
|
"window_attributed_pct": detail.get("window_attributed_pct"),
|
|
987
989
|
"models": detail.get("models", []),
|
|
@@ -2017,6 +2019,10 @@ def _build_alert_payload_weekly(*args, **kwargs):
|
|
|
2017
2019
|
return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
|
|
2018
2020
|
|
|
2019
2021
|
|
|
2022
|
+
def synthetic_preview_week_start(*args, **kwargs):
|
|
2023
|
+
return sys.modules["cctally"].synthetic_preview_week_start(*args, **kwargs)
|
|
2024
|
+
|
|
2025
|
+
|
|
2020
2026
|
def _build_alert_payload_five_hour(*args, **kwargs):
|
|
2021
2027
|
return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
|
|
2022
2028
|
|
|
@@ -3770,7 +3776,22 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3770
3776
|
recorded-windows-widening trick (loads reset windows from
|
|
3771
3777
|
``[start - BLOCK_DURATION, end + BLOCK_DURATION]`` so a recorded
|
|
3772
3778
|
reset just outside the visible window can still anchor blocks
|
|
3773
|
-
inside it) and the
|
|
3779
|
+
inside it) and the post-group block-overlap filter.
|
|
3780
|
+
|
|
3781
|
+
#620 S1 D8: the entry set is grouped into native blocks FIRST and the
|
|
3782
|
+
week filter is then applied to whole blocks, retaining every block whose
|
|
3783
|
+
interval overlaps ``[week_start_at, week_end_at)`` with its full native
|
|
3784
|
+
totals. It used to filter ENTRIES to the week before grouping, so a
|
|
3785
|
+
block straddling a week boundary was folded from only the part of itself
|
|
3786
|
+
that fell inside the week — while ``/api/block/<iso>`` fetches the
|
|
3787
|
+
block's own native window and applies no week clip, so the panel and its
|
|
3788
|
+
own drilldown reported different totals for the same ``start_at`` and
|
|
3789
|
+
the panel's was permanently short. Selecting a block that overlaps the
|
|
3790
|
+
week is the deliberate part of the contract and stays; clipping a
|
|
3791
|
+
selected block's contents was the defect.
|
|
3792
|
+
|
|
3793
|
+
The fetch already read one block duration on each side, so this needs no
|
|
3794
|
+
additional query.
|
|
3774
3795
|
|
|
3775
3796
|
Returning the full ``BlocksView`` (rows + totals) lets the sync
|
|
3776
3797
|
thread populate ``DataSnapshot.blocks_total_cost_usd`` /
|
|
@@ -3781,13 +3802,12 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3781
3802
|
fetch_start = week_start_at - BLOCK_DURATION
|
|
3782
3803
|
fetch_end = week_end_at + BLOCK_DURATION
|
|
3783
3804
|
entries = get_entries(fetch_start, fetch_end, skip_sync=skip_sync)
|
|
3784
|
-
entries = [e for e in entries if week_start_at <= e.timestamp < week_end_at]
|
|
3785
3805
|
|
|
3786
3806
|
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
3787
3807
|
_load_recorded_five_hour_windows(fetch_start, fetch_end)
|
|
3788
3808
|
)
|
|
3789
3809
|
c = _cctally()
|
|
3790
|
-
|
|
3810
|
+
view = c.build_blocks_view(
|
|
3791
3811
|
entries,
|
|
3792
3812
|
now_utc=now_utc,
|
|
3793
3813
|
recorded_windows=recorded_windows,
|
|
@@ -3798,6 +3818,60 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
|
|
|
3798
3818
|
display_tz=display_tz,
|
|
3799
3819
|
mode="auto",
|
|
3800
3820
|
)
|
|
3821
|
+
return _blocks_view_overlapping_week(
|
|
3822
|
+
view, week_start_at=week_start_at, week_end_at=week_end_at,
|
|
3823
|
+
)
|
|
3824
|
+
|
|
3825
|
+
|
|
3826
|
+
def _blocks_view_overlapping_week(view, *, week_start_at, week_end_at):
|
|
3827
|
+
"""Retain only the blocks whose interval overlaps
|
|
3828
|
+
``[week_start_at, week_end_at)``, keeping each retained block's FULL
|
|
3829
|
+
native totals (#620 S1 D8).
|
|
3830
|
+
|
|
3831
|
+
Overlap is the standard half-open test ``start < week_end and end >
|
|
3832
|
+
week_start``: a block that merely touches a bound (its end exactly at
|
|
3833
|
+
``week_start_at``, or its start exactly at ``week_end_at``) shares no
|
|
3834
|
+
instant with the week and is not retained.
|
|
3835
|
+
|
|
3836
|
+
Totals are re-derived from the retained non-gap blocks so the React
|
|
3837
|
+
panel's ``footer total == sum(visible rows)`` invariant still holds, and
|
|
3838
|
+
``aggregated`` is filtered in lockstep so no consumer can read a block
|
|
3839
|
+
set that disagrees with ``rows``.
|
|
3840
|
+
"""
|
|
3841
|
+
kept_blocks = []
|
|
3842
|
+
total_cost = 0.0
|
|
3843
|
+
total_tokens = 0
|
|
3844
|
+
kept_starts = set()
|
|
3845
|
+
for b in view.aggregated:
|
|
3846
|
+
start = getattr(b, "start_time", None)
|
|
3847
|
+
end = getattr(b, "end_time", None)
|
|
3848
|
+
if start is None or end is None:
|
|
3849
|
+
# API-anchored views carry dicts, not Blocks. This adapter only
|
|
3850
|
+
# ever sees the heuristic path, but degrade by retaining rather
|
|
3851
|
+
# than silently dropping a shape we cannot classify.
|
|
3852
|
+
kept_blocks.append(b)
|
|
3853
|
+
continue
|
|
3854
|
+
if not (start < week_end_at and end > week_start_at):
|
|
3855
|
+
continue
|
|
3856
|
+
kept_blocks.append(b)
|
|
3857
|
+
if getattr(b, "is_gap", False):
|
|
3858
|
+
continue
|
|
3859
|
+
# Plain `+=` rather than `stable_sum`, deliberately: this mirrors
|
|
3860
|
+
# `build_blocks_view`'s own accumulation (`bin/_lib_view_models.py`),
|
|
3861
|
+
# and this function exists to publish the SAME totals that view
|
|
3862
|
+
# publishes. A different fold here could disagree with it in the last
|
|
3863
|
+
# ULP, which is the divergence the function was written to remove.
|
|
3864
|
+
total_cost += b.cost_usd
|
|
3865
|
+
total_tokens += b.total_tokens
|
|
3866
|
+
kept_starts.add(start.astimezone(dt.timezone.utc).isoformat())
|
|
3867
|
+
rows = tuple(r for r in view.rows if r.start_at in kept_starts)
|
|
3868
|
+
return dataclasses.replace(
|
|
3869
|
+
view,
|
|
3870
|
+
rows=rows,
|
|
3871
|
+
aggregated=tuple(kept_blocks),
|
|
3872
|
+
total_cost_usd=total_cost,
|
|
3873
|
+
total_tokens=total_tokens,
|
|
3874
|
+
)
|
|
3801
3875
|
|
|
3802
3876
|
|
|
3803
3877
|
def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
|
|
@@ -4152,6 +4226,158 @@ def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
|
|
|
4152
4226
|
)
|
|
4153
4227
|
|
|
4154
4228
|
|
|
4229
|
+
class _ProjectsWeekGrid:
|
|
4230
|
+
"""The ordered half-open subscription intervals the Projects panel
|
|
4231
|
+
attributes cost into (#620 S1 D1).
|
|
4232
|
+
|
|
4233
|
+
``_projects_week_start_monday_utc`` above is the fallback used when no
|
|
4234
|
+
snapshot anchor is available; this is what replaces it when one IS
|
|
4235
|
+
available. Intervals come from ``_compute_subscription_weeks``, the same
|
|
4236
|
+
kernel ``cmd_project`` buckets by, so the panel and the CLI describe one
|
|
4237
|
+
set of weeks rather than two.
|
|
4238
|
+
|
|
4239
|
+
Intervals are half-open ``[start, end)`` and are NOT assumed to be seven
|
|
4240
|
+
days long: Anthropic's reset day drifts, and a drifted cycle produces a
|
|
4241
|
+
genuinely short week. ``end_for`` therefore returns the interval's own
|
|
4242
|
+
end, never ``start + 7d``.
|
|
4243
|
+
"""
|
|
4244
|
+
|
|
4245
|
+
__slots__ = ("starts", "ends", "_end_by_start", "_start_by_date")
|
|
4246
|
+
|
|
4247
|
+
def __init__(self, bounds: "list[tuple[dt.datetime, dt.datetime]]"):
|
|
4248
|
+
ordered = sorted(bounds, key=lambda b: b[0])
|
|
4249
|
+
self.starts = [b[0] for b in ordered]
|
|
4250
|
+
self.ends = [b[1] for b in ordered]
|
|
4251
|
+
self._end_by_start = {s: e for s, e in ordered}
|
|
4252
|
+
# `weekly_usage_snapshots.week_start_date` is the date-only lookup
|
|
4253
|
+
# key a legacy row carries when it has no `week_start_at`. Later
|
|
4254
|
+
# intervals win a collision, matching the "last capture per week
|
|
4255
|
+
# wins" rule the percentage read already applies.
|
|
4256
|
+
self._start_by_date = {s.date(): s for s in self.starts}
|
|
4257
|
+
|
|
4258
|
+
def __bool__(self) -> bool:
|
|
4259
|
+
return bool(self.starts)
|
|
4260
|
+
|
|
4261
|
+
def week_for(self, ts: "dt.datetime") -> "dt.datetime | None":
|
|
4262
|
+
"""The start of the interval containing ``ts``, or None when ``ts``
|
|
4263
|
+
falls outside every interval.
|
|
4264
|
+
|
|
4265
|
+
First-match-wins on the reset-day-drift overlap the clamp can leave
|
|
4266
|
+
behind — the same walk-back `cmd_project._week_start_for` performs,
|
|
4267
|
+
so an entry near a drifted boundary lands in the same week on both
|
|
4268
|
+
surfaces.
|
|
4269
|
+
"""
|
|
4270
|
+
ts_utc = ts.astimezone(dt.timezone.utc)
|
|
4271
|
+
idx = bisect.bisect_right(self.starts, ts_utc) - 1
|
|
4272
|
+
if idx < 0:
|
|
4273
|
+
return None
|
|
4274
|
+
while idx > 0 and self.starts[idx - 1] <= ts_utc < self.ends[idx - 1]:
|
|
4275
|
+
idx -= 1
|
|
4276
|
+
if self.starts[idx] <= ts_utc < self.ends[idx]:
|
|
4277
|
+
return self.starts[idx]
|
|
4278
|
+
return None
|
|
4279
|
+
|
|
4280
|
+
def start_for_date(self, day: "dt.date") -> "dt.datetime | None":
|
|
4281
|
+
"""The interval start whose own date is ``day``, for a legacy
|
|
4282
|
+
snapshot row that carries ``week_start_date`` but no
|
|
4283
|
+
``week_start_at``."""
|
|
4284
|
+
return self._start_by_date.get(day)
|
|
4285
|
+
|
|
4286
|
+
def end_for(self, start: "dt.datetime") -> "dt.datetime":
|
|
4287
|
+
"""The interval's real end. Falls back to ``start + 7d`` only for a
|
|
4288
|
+
start this grid does not know, which the padding below produces."""
|
|
4289
|
+
return self._end_by_start.get(start, start + dt.timedelta(days=7))
|
|
4290
|
+
|
|
4291
|
+
def window_ending_at(
|
|
4292
|
+
self, cw_start: "dt.datetime", weeks_back: int,
|
|
4293
|
+
) -> "list[tuple[dt.datetime, dt.datetime]]":
|
|
4294
|
+
"""The last ``weeks_back`` intervals up to and including the one
|
|
4295
|
+
starting at ``cw_start``, oldest first.
|
|
4296
|
+
|
|
4297
|
+
When the grid holds fewer than ``weeks_back`` intervals at or before
|
|
4298
|
+
``cw_start``, the head is padded backwards in seven-day steps. The
|
|
4299
|
+
padding is a genuine no-anchor tail — history older than any snapshot
|
|
4300
|
+
— so the seven-day assumption is the right one there.
|
|
4301
|
+
|
|
4302
|
+
The walk itself lives in ``_lib_subscription_weeks`` because
|
|
4303
|
+
``cmd_project`` performs the same one; a copy here is how the two
|
|
4304
|
+
surfaces drifted apart in the first place.
|
|
4305
|
+
"""
|
|
4306
|
+
return _cctally().subscription_window_ending_at(
|
|
4307
|
+
list(zip(self.starts, self.ends)), cw_start, weeks_back,
|
|
4308
|
+
)
|
|
4309
|
+
|
|
4310
|
+
|
|
4311
|
+
def _projects_week_grid(
|
|
4312
|
+
conn: "sqlite3.Connection",
|
|
4313
|
+
*,
|
|
4314
|
+
anchor_utc: "dt.datetime",
|
|
4315
|
+
weeks_back: int,
|
|
4316
|
+
account_key: "str | None" = None,
|
|
4317
|
+
) -> "_ProjectsWeekGrid | None":
|
|
4318
|
+
"""Build the panel's subscription-week grid, or None when no snapshot
|
|
4319
|
+
row carries an anchor.
|
|
4320
|
+
|
|
4321
|
+
Returning None is the deliberate no-anchor path: the caller then keeps
|
|
4322
|
+
``_projects_week_start_monday_utc`` throughout, which is what that
|
|
4323
|
+
function was written for and what every anchorless fixture already
|
|
4324
|
+
exercises byte-identically.
|
|
4325
|
+
|
|
4326
|
+
Cost: one grouped read of ``weekly_usage_snapshots`` plus the reset-event
|
|
4327
|
+
join `_compute_subscription_weeks` already performs. It adds no walk over
|
|
4328
|
+
``session_entries`` and nothing per entry, so this does not re-open the
|
|
4329
|
+
per-tick rescan #583 owns.
|
|
4330
|
+
"""
|
|
4331
|
+
acct_pred = "" if account_key is None else " AND account_key = ?"
|
|
4332
|
+
acct_params: tuple = () if account_key is None else (account_key,)
|
|
4333
|
+
try:
|
|
4334
|
+
row = conn.execute(
|
|
4335
|
+
"SELECT COUNT(*) FROM weekly_usage_snapshots "
|
|
4336
|
+
"WHERE week_start_at IS NOT NULL "
|
|
4337
|
+
" AND week_end_at IS NOT NULL "
|
|
4338
|
+
" AND week_start_date IS NOT NULL"
|
|
4339
|
+
f"{acct_pred}",
|
|
4340
|
+
acct_params,
|
|
4341
|
+
).fetchone()
|
|
4342
|
+
except sqlite3.OperationalError:
|
|
4343
|
+
return None
|
|
4344
|
+
if not row or not row[0]:
|
|
4345
|
+
return None
|
|
4346
|
+
|
|
4347
|
+
# A generous provisional range, defined once in `_lib_subscription_weeks`
|
|
4348
|
+
# because `cmd_project` needs the identical range: the extrapolation
|
|
4349
|
+
# anchor `_compute_subscription_weeks` picks is relative to `range_start`,
|
|
4350
|
+
# so two callers asking the same question over different ranges can be
|
|
4351
|
+
# handed differently-phased intervals for the same history.
|
|
4352
|
+
range_start, range_end = _cctally().subscription_window_probe_range(
|
|
4353
|
+
anchor_utc, weeks_back,
|
|
4354
|
+
)
|
|
4355
|
+
try:
|
|
4356
|
+
subweeks = _cctally()._compute_subscription_weeks(
|
|
4357
|
+
conn, range_start, range_end, account_key=account_key,
|
|
4358
|
+
)
|
|
4359
|
+
except Exception:
|
|
4360
|
+
# A malformed anchor must not take the panel down; the Monday
|
|
4361
|
+
# fallback still renders a coherent (if approximate) window.
|
|
4362
|
+
return None
|
|
4363
|
+
bounds: "list[tuple[dt.datetime, dt.datetime]]" = []
|
|
4364
|
+
for sw in subweeks:
|
|
4365
|
+
try:
|
|
4366
|
+
s = parse_iso_datetime(
|
|
4367
|
+
sw.start_ts, "projects week.start_ts",
|
|
4368
|
+
).astimezone(dt.timezone.utc)
|
|
4369
|
+
e = parse_iso_datetime(
|
|
4370
|
+
sw.end_ts, "projects week.end_ts",
|
|
4371
|
+
).astimezone(dt.timezone.utc)
|
|
4372
|
+
except (TypeError, ValueError):
|
|
4373
|
+
continue
|
|
4374
|
+
if e > s:
|
|
4375
|
+
bounds.append((s, e))
|
|
4376
|
+
if not bounds:
|
|
4377
|
+
return None
|
|
4378
|
+
return _ProjectsWeekGrid(bounds)
|
|
4379
|
+
|
|
4380
|
+
|
|
4155
4381
|
def _projects_week_label(week_start: "dt.datetime") -> str:
|
|
4156
4382
|
"""Render a `wk Mon DD` label for the trend chart x-axis.
|
|
4157
4383
|
|
|
@@ -4200,9 +4426,25 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
|
|
|
4200
4426
|
downstream. An ``EXPLAIN QUERY PLAN`` regression asserts the mutation_seq
|
|
4201
4427
|
index seek (``tests/test_projects_envelope.py``).
|
|
4202
4428
|
"""
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4429
|
+
# The SQL bounds are an outward-widened CANDIDATE filter; the real
|
|
4430
|
+
# membership test is the Python one every caller applies
|
|
4431
|
+
# (`_fold_projects_entry`'s interval gate, `_week_for`, or
|
|
4432
|
+
# `_fetch_delta_rows`' own pre-filter). #620 S1: the lower bound is
|
|
4433
|
+
# widened by one second because ingestion stores
|
|
4434
|
+
# `timestamp.astimezone(utc).isoformat()`, which keeps a `+00:00`
|
|
4435
|
+
# offset, while this predicate spells its bound `Z` — and SQLite
|
|
4436
|
+
# compares the column lexically, where `+` (0x2B) sorts BELOW `Z`
|
|
4437
|
+
# (0x5A). An entry stored at exactly `since`, or in the first second
|
|
4438
|
+
# after it, therefore sorts below the bound and is dropped before any
|
|
4439
|
+
# Python gate runs. That was unreachable while a week always started at
|
|
4440
|
+
# Monday 00:00 UTC, which carries no entries; a real subscription week
|
|
4441
|
+
# starts at the reset instant, and an entry lands on it routinely. The
|
|
4442
|
+
# widening admits at most one extra second of candidates, which the
|
|
4443
|
+
# Python gate then rejects, so no caller's result changes except the one
|
|
4444
|
+
# that was silently losing the boundary entry.
|
|
4445
|
+
since_iso = (
|
|
4446
|
+
since.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
|
|
4447
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
4206
4448
|
until_iso = until.astimezone(dt.timezone.utc).strftime(
|
|
4207
4449
|
"%Y-%m-%dT%H:%M:%SZ"
|
|
4208
4450
|
)
|
|
@@ -4393,6 +4635,25 @@ def _shared_range_row_to_usage_entry(row):
|
|
|
4393
4635
|
)
|
|
4394
4636
|
|
|
4395
4637
|
|
|
4638
|
+
def _shared_range_row_to_priced_usage_entry(row):
|
|
4639
|
+
"""Prepare one shared row's effective cost once for both range folds."""
|
|
4640
|
+
entry = _shared_range_row_to_usage_entry(row)
|
|
4641
|
+
if entry.model == "<synthetic>":
|
|
4642
|
+
return entry
|
|
4643
|
+
return _cctally().UsageEntry(
|
|
4644
|
+
timestamp=entry.timestamp,
|
|
4645
|
+
model=entry.model,
|
|
4646
|
+
usage=entry.usage,
|
|
4647
|
+
cost_usd=_calculate_entry_cost(
|
|
4648
|
+
entry.model,
|
|
4649
|
+
entry.usage,
|
|
4650
|
+
mode="auto",
|
|
4651
|
+
cost_usd=entry.cost_usd,
|
|
4652
|
+
),
|
|
4653
|
+
source_path=entry.source_path,
|
|
4654
|
+
)
|
|
4655
|
+
|
|
4656
|
+
|
|
4396
4657
|
def _fold_prepared_daily_entries(
|
|
4397
4658
|
accumulators, entries, *, display_tz=None, mode: str = "auto",
|
|
4398
4659
|
):
|
|
@@ -4512,7 +4773,9 @@ def _fold_projects_entry(
|
|
|
4512
4773
|
*,
|
|
4513
4774
|
resolver_cache: dict,
|
|
4514
4775
|
week_start: "dt.datetime | None",
|
|
4776
|
+
week_end: "dt.datetime | None" = None,
|
|
4515
4777
|
prepared_daily_entries: "list | None" = None,
|
|
4778
|
+
priced_entry=None,
|
|
4516
4779
|
) -> "float | None":
|
|
4517
4780
|
"""Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
|
|
4518
4781
|
per-row body, #271 §20 Codex-P1a).
|
|
@@ -4524,6 +4787,15 @@ def _fold_projects_entry(
|
|
|
4524
4787
|
row is filtered out (``<synthetic>`` model, or its Monday-anchored week ≠
|
|
4525
4788
|
``week_start``) — the caller then skips ``week_total`` / ``tail`` advance.
|
|
4526
4789
|
|
|
4790
|
+
The membership gate is the half-open interval ``[week_start, week_end)``
|
|
4791
|
+
(#620 S1 D1). It was a ``_projects_week_start_monday_utc(ts) ==
|
|
4792
|
+
week_start`` equality, which is the SAME predicate whenever ``week_start``
|
|
4793
|
+
is a Monday 00:00 UTC and ``week_end`` is ``week_start + 7d`` — so every
|
|
4794
|
+
Monday-anchored caller is byte-unchanged — but the interval form also
|
|
4795
|
+
admits a real subscription week that neither starts on a Monday nor runs
|
|
4796
|
+
a full seven days. ``week_end`` defaults to ``week_start + 7d`` for a
|
|
4797
|
+
caller that has not been threaded through yet.
|
|
4798
|
+
|
|
4527
4799
|
``mut[bp]`` is the running mutable dict ``{"cost_usd": float,
|
|
4528
4800
|
"sessions": set, "first_seen": dt, "last_seen": dt, "first_order": ts_iso,
|
|
4529
4801
|
"first_id": int, "first_key": ProjectKey}``. The first row seen for a
|
|
@@ -4546,32 +4818,43 @@ def _fold_projects_entry(
|
|
|
4546
4818
|
if model == "<synthetic>":
|
|
4547
4819
|
return None
|
|
4548
4820
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
4549
|
-
if week_start is not None
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4821
|
+
if week_start is not None:
|
|
4822
|
+
w_end = (
|
|
4823
|
+
week_end if week_end is not None
|
|
4824
|
+
else week_start + dt.timedelta(days=7)
|
|
4825
|
+
)
|
|
4826
|
+
if not (week_start <= ts < w_end):
|
|
4827
|
+
return None
|
|
4828
|
+
if priced_entry is None:
|
|
4829
|
+
usage = claude_usage_dict( # #195 chokepoint
|
|
4830
|
+
input_tokens=input_tok,
|
|
4831
|
+
output_tokens=output_tok,
|
|
4832
|
+
cache_creation_tokens=cache_create,
|
|
4833
|
+
cache_read_tokens=cache_read,
|
|
4834
|
+
cache_1h_tokens=cache_1h,
|
|
4835
|
+
speed=speed,
|
|
4836
|
+
)
|
|
4837
|
+
entry_cost = _calculate_entry_cost(
|
|
4838
|
+
model,
|
|
4839
|
+
usage,
|
|
4840
|
+
mode="auto",
|
|
4841
|
+
cost_usd=cost_raw,
|
|
4842
|
+
)
|
|
4843
|
+
else:
|
|
4844
|
+
usage = priced_entry.usage
|
|
4845
|
+
entry_cost = priced_entry.cost_usd
|
|
4565
4846
|
if prepared_daily_entries is not None:
|
|
4566
4847
|
# #567: preserve the canonical daily entry and aggregator while
|
|
4567
4848
|
# handing off the effective cost this pass already computed.
|
|
4568
|
-
prepared_daily_entries.append(
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4849
|
+
prepared_daily_entries.append(
|
|
4850
|
+
priced_entry if priced_entry is not None else c.UsageEntry(
|
|
4851
|
+
timestamp=dt.datetime.fromisoformat(ts_iso),
|
|
4852
|
+
model=model,
|
|
4853
|
+
usage=usage,
|
|
4854
|
+
cost_usd=entry_cost,
|
|
4855
|
+
source_path=source_path,
|
|
4856
|
+
)
|
|
4857
|
+
)
|
|
4575
4858
|
pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
|
|
4576
4859
|
bp = pkey.bucket_path
|
|
4577
4860
|
a = mut.get(bp)
|
|
@@ -4600,6 +4883,7 @@ def _fold_projects_entry(
|
|
|
4600
4883
|
|
|
4601
4884
|
def fold_projects_over_range(
|
|
4602
4885
|
rows, *, resolver_cache=None, prepared_daily_entries=None,
|
|
4886
|
+
priced_entries=None,
|
|
4603
4887
|
) -> "dict[str, dict]":
|
|
4604
4888
|
"""Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
|
|
4605
4889
|
|
|
@@ -4622,13 +4906,17 @@ def fold_projects_over_range(
|
|
|
4622
4906
|
"""
|
|
4623
4907
|
mut: "dict[str, dict]" = {}
|
|
4624
4908
|
cache = {} if resolver_cache is None else resolver_cache
|
|
4625
|
-
|
|
4909
|
+
if priced_entries is not None and len(priced_entries) != len(rows):
|
|
4910
|
+
raise ValueError("priced shared-range entries do not match source rows")
|
|
4911
|
+
for index, row in enumerate(rows):
|
|
4626
4912
|
_fold_projects_entry(
|
|
4627
4913
|
mut,
|
|
4628
4914
|
row,
|
|
4629
4915
|
resolver_cache=cache,
|
|
4630
4916
|
week_start=None,
|
|
4631
4917
|
prepared_daily_entries=prepared_daily_entries,
|
|
4918
|
+
priced_entry=(
|
|
4919
|
+
priced_entries[index] if priced_entries is not None else None),
|
|
4632
4920
|
)
|
|
4633
4921
|
return mut
|
|
4634
4922
|
|
|
@@ -4900,25 +5188,35 @@ def _shared_range_cache_payload(
|
|
|
4900
5188
|
}
|
|
4901
5189
|
|
|
4902
5190
|
|
|
4903
|
-
|
|
5191
|
+
@dataclass(frozen=True)
|
|
5192
|
+
class ClaudeRangeAggregateCapture:
|
|
5193
|
+
"""Cache-owned inputs for one post-transaction Claude range fold."""
|
|
5194
|
+
|
|
5195
|
+
base: tuple
|
|
5196
|
+
max_entry_id: int
|
|
5197
|
+
entry_mutation_seq: int
|
|
5198
|
+
shared_end_exclusive: object
|
|
5199
|
+
prior: object
|
|
5200
|
+
delta_rows: tuple
|
|
5201
|
+
full_rows: tuple | None
|
|
5202
|
+
|
|
5203
|
+
|
|
5204
|
+
def capture_cached_claude_range_aggregates(
|
|
4904
5205
|
conn,
|
|
4905
5206
|
*,
|
|
4906
5207
|
shared_start,
|
|
4907
5208
|
shared_end_exclusive,
|
|
4908
|
-
now_utc,
|
|
4909
5209
|
display_tz,
|
|
4910
|
-
legacy_labels,
|
|
4911
5210
|
max_entry_id: "int | None" = None,
|
|
4912
5211
|
entry_mutation_seq: "int | None" = None,
|
|
4913
5212
|
generation: int = 0,
|
|
4914
5213
|
):
|
|
4915
|
-
"""
|
|
5214
|
+
"""Capture only cache-backed inputs for the #567 append accumulator.
|
|
4916
5215
|
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
the current legacy population is reapplied on every publication.
|
|
5216
|
+
This half may run under `_tui_build_source_bundle`'s pinned transaction. It
|
|
5217
|
+
performs no deepcopy, pricing, project fold, daily fold, payload assembly,
|
|
5218
|
+
or memo mutation. The returned rows are ordinary immutable SQLite tuples,
|
|
5219
|
+
so the caller can end the read transaction before consuming them.
|
|
4922
5220
|
"""
|
|
4923
5221
|
if max_entry_id is None or entry_mutation_seq is None:
|
|
4924
5222
|
observed_id, observed_seq = _shared_range_entry_signature(conn)
|
|
@@ -4935,7 +5233,8 @@ def build_cached_claude_range_aggregates(
|
|
|
4935
5233
|
generation=generation,
|
|
4936
5234
|
)
|
|
4937
5235
|
prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
|
|
4938
|
-
|
|
5236
|
+
delta_rows: tuple = ()
|
|
5237
|
+
full_rows: tuple | None = None
|
|
4939
5238
|
if isinstance(prior, dict) and prior.get("base") == base:
|
|
4940
5239
|
monotone = (
|
|
4941
5240
|
max_entry_id >= prior["max_entry_id"]
|
|
@@ -4951,9 +5250,6 @@ def build_cached_claude_range_aggregates(
|
|
|
4951
5250
|
)
|
|
4952
5251
|
)
|
|
4953
5252
|
if monotone and not old_row_changed:
|
|
4954
|
-
project_mut = copy.deepcopy(prior["project_mut"])
|
|
4955
|
-
daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
|
|
4956
|
-
resolver_cache = dict(prior["resolver_cache"])
|
|
4957
5253
|
delta_by_id = {}
|
|
4958
5254
|
for row in _shared_range_entries_after_id(
|
|
4959
5255
|
conn, prior["max_entry_id"],
|
|
@@ -4971,85 +5267,198 @@ def build_cached_claude_range_aggregates(
|
|
|
4971
5267
|
):
|
|
4972
5268
|
if row[0] <= prior["max_entry_id"]:
|
|
4973
5269
|
delta_by_id[row[0]] = row
|
|
4974
|
-
delta_rows = sorted(
|
|
5270
|
+
delta_rows = tuple(sorted(
|
|
4975
5271
|
delta_by_id.values(),
|
|
4976
5272
|
key=lambda row: (row[1], row[0]),
|
|
4977
|
-
)
|
|
5273
|
+
))
|
|
4978
5274
|
prior_tail = prior["tail"]
|
|
4979
|
-
if prior_tail is None or all(
|
|
5275
|
+
if not (prior_tail is None or all(
|
|
4980
5276
|
(row[1], row[0]) > prior_tail
|
|
4981
5277
|
for row in delta_rows
|
|
4982
5278
|
if row[2] != "<synthetic>"
|
|
4983
|
-
):
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
row for row in delta_rows if row[2] != "<synthetic>"
|
|
5001
|
-
]
|
|
5002
|
-
if real_delta:
|
|
5003
|
-
last = real_delta[-1]
|
|
5004
|
-
tail = (last[1], last[0])
|
|
5005
|
-
state = {
|
|
5006
|
-
"base": base,
|
|
5007
|
-
"max_entry_id": max_entry_id,
|
|
5008
|
-
"entry_mutation_seq": entry_mutation_seq,
|
|
5009
|
-
"end_exclusive": shared_end_exclusive,
|
|
5010
|
-
"tail": tail,
|
|
5011
|
-
"project_mut": project_mut,
|
|
5012
|
-
"daily_accumulators": daily_accumulators,
|
|
5013
|
-
"resolver_cache": resolver_cache,
|
|
5014
|
-
}
|
|
5015
|
-
if state is None:
|
|
5016
|
-
rows = tuple(iter_shared_range_entries(
|
|
5279
|
+
)):
|
|
5280
|
+
# An out-of-order append cannot be folded onto the accumulator.
|
|
5281
|
+
# Capture the cold carrier while the same snapshot is pinned;
|
|
5282
|
+
# discovering this after rollback would require a second read
|
|
5283
|
+
# generation.
|
|
5284
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5285
|
+
conn, start=shared_start,
|
|
5286
|
+
end_exclusive=shared_end_exclusive,
|
|
5287
|
+
))
|
|
5288
|
+
delta_rows = ()
|
|
5289
|
+
else:
|
|
5290
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5291
|
+
conn, start=shared_start,
|
|
5292
|
+
end_exclusive=shared_end_exclusive,
|
|
5293
|
+
))
|
|
5294
|
+
else:
|
|
5295
|
+
full_rows = tuple(iter_shared_range_entries(
|
|
5017
5296
|
conn, start=shared_start, end_exclusive=shared_end_exclusive,
|
|
5018
5297
|
))
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5298
|
+
return ClaudeRangeAggregateCapture(
|
|
5299
|
+
base=base,
|
|
5300
|
+
max_entry_id=max_entry_id,
|
|
5301
|
+
entry_mutation_seq=entry_mutation_seq,
|
|
5302
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
5303
|
+
prior=prior,
|
|
5304
|
+
delta_rows=delta_rows,
|
|
5305
|
+
full_rows=full_rows,
|
|
5306
|
+
)
|
|
5307
|
+
|
|
5308
|
+
|
|
5309
|
+
def build_cached_claude_range_aggregates_from_capture(
|
|
5310
|
+
capture: ClaudeRangeAggregateCapture,
|
|
5311
|
+
*,
|
|
5312
|
+
now_utc,
|
|
5313
|
+
display_tz,
|
|
5314
|
+
legacy_labels,
|
|
5315
|
+
tolerate_leg_failures: bool = False,
|
|
5316
|
+
):
|
|
5317
|
+
"""Fold and publish one captured Claude range accumulator outside the pin.
|
|
5318
|
+
|
|
5319
|
+
``tolerate_leg_failures`` is the source-bundle path's typed degradation
|
|
5320
|
+
seam. Project identity resolution and the daily calendar are independent
|
|
5321
|
+
folds over the same captured rows, so a project-only fault must not discard
|
|
5322
|
+
a valid daily result. The one-shot compatibility wrapper keeps the legacy
|
|
5323
|
+
raise-on-any-fault contract by leaving it false.
|
|
5324
|
+
"""
|
|
5325
|
+
prior = capture.prior
|
|
5326
|
+
incremental = capture.full_rows is None and isinstance(prior, dict)
|
|
5327
|
+
rows = capture.delta_rows if incremental else (capture.full_rows or ())
|
|
5328
|
+
payload: dict[str, object] = {}
|
|
5329
|
+
outcomes = {
|
|
5330
|
+
"projects": {"state": "ok"},
|
|
5331
|
+
"daily": {"state": "ok"},
|
|
5332
|
+
}
|
|
5333
|
+
failures: dict[str, Exception] = {}
|
|
5334
|
+
project_mut = None
|
|
5335
|
+
resolver_cache = None
|
|
5336
|
+
daily_accumulators = None
|
|
5337
|
+
|
|
5338
|
+
try:
|
|
5339
|
+
prepared = tuple(
|
|
5340
|
+
_shared_range_row_to_priced_usage_entry(row) for row in rows)
|
|
5341
|
+
except Exception as exc:
|
|
5342
|
+
prepared = ()
|
|
5343
|
+
failures["projects"] = exc
|
|
5344
|
+
failures["daily"] = exc
|
|
5345
|
+
outcomes["projects"] = {
|
|
5346
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5347
|
+
outcomes["daily"] = {
|
|
5348
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5349
|
+
|
|
5350
|
+
try:
|
|
5351
|
+
if "projects" in failures:
|
|
5352
|
+
raise failures["projects"]
|
|
5353
|
+
if incremental:
|
|
5354
|
+
project_mut = copy.deepcopy(prior["project_mut"])
|
|
5355
|
+
resolver_cache = dict(prior["resolver_cache"])
|
|
5356
|
+
for row, priced_entry in zip(rows, prepared):
|
|
5357
|
+
_fold_projects_entry(
|
|
5358
|
+
project_mut,
|
|
5359
|
+
row,
|
|
5360
|
+
resolver_cache=resolver_cache,
|
|
5361
|
+
week_start=None,
|
|
5362
|
+
priced_entry=priced_entry,
|
|
5363
|
+
)
|
|
5364
|
+
else:
|
|
5365
|
+
resolver_cache = {}
|
|
5366
|
+
project_mut = fold_projects_over_range(
|
|
5367
|
+
rows,
|
|
5368
|
+
resolver_cache=resolver_cache,
|
|
5369
|
+
priced_entries=prepared,
|
|
5370
|
+
)
|
|
5371
|
+
payload["projects"] = _project_aggregate_rows_from_folded(
|
|
5372
|
+
project_mut, legacy_labels)
|
|
5373
|
+
except Exception as exc:
|
|
5374
|
+
failures["projects"] = exc
|
|
5375
|
+
outcomes["projects"] = {
|
|
5376
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5377
|
+
|
|
5378
|
+
try:
|
|
5379
|
+
if "daily" in failures:
|
|
5380
|
+
raise failures["daily"]
|
|
5381
|
+
daily_accumulators = (
|
|
5382
|
+
copy.deepcopy(prior["daily_accumulators"])
|
|
5383
|
+
if incremental else {}
|
|
5025
5384
|
)
|
|
5026
|
-
daily_accumulators = {}
|
|
5027
5385
|
_fold_prepared_daily_entries(
|
|
5028
|
-
daily_accumulators, prepared, display_tz=display_tz
|
|
5029
|
-
)
|
|
5386
|
+
daily_accumulators, prepared, display_tz=display_tz)
|
|
5387
|
+
daily_buckets = _finalize_daily_accumulators(daily_accumulators)
|
|
5388
|
+
daily_rows = _build_daily_aggregate_rows_from_buckets(
|
|
5389
|
+
daily_buckets, now_utc=now_utc, display_tz=display_tz)
|
|
5390
|
+
c = _cctally()
|
|
5391
|
+
payload["daily"] = [
|
|
5392
|
+
c.daily_panel_row_to_wire(row) for row in daily_rows]
|
|
5393
|
+
except Exception as exc:
|
|
5394
|
+
failures["daily"] = exc
|
|
5395
|
+
outcomes["daily"] = {
|
|
5396
|
+
"state": "failed", "code": "claude_fold_failed"}
|
|
5397
|
+
|
|
5398
|
+
if incremental:
|
|
5399
|
+
tail = prior["tail"]
|
|
5400
|
+
real_delta = [
|
|
5401
|
+
row for row in capture.delta_rows if row[2] != "<synthetic>"
|
|
5402
|
+
]
|
|
5403
|
+
if real_delta:
|
|
5404
|
+
last = real_delta[-1]
|
|
5405
|
+
tail = (last[1], last[0])
|
|
5406
|
+
else:
|
|
5030
5407
|
real_rows = [row for row in rows if row[2] != "<synthetic>"]
|
|
5031
5408
|
tail = None
|
|
5032
5409
|
if real_rows:
|
|
5033
5410
|
last = real_rows[-1]
|
|
5034
5411
|
tail = (last[1], last[0])
|
|
5412
|
+
|
|
5413
|
+
if not failures:
|
|
5035
5414
|
state = {
|
|
5036
|
-
"base": base,
|
|
5037
|
-
"max_entry_id": max_entry_id,
|
|
5038
|
-
"entry_mutation_seq": entry_mutation_seq,
|
|
5039
|
-
"end_exclusive": shared_end_exclusive,
|
|
5415
|
+
"base": capture.base,
|
|
5416
|
+
"max_entry_id": capture.max_entry_id,
|
|
5417
|
+
"entry_mutation_seq": capture.entry_mutation_seq,
|
|
5418
|
+
"end_exclusive": capture.shared_end_exclusive,
|
|
5040
5419
|
"tail": tail,
|
|
5041
5420
|
"project_mut": project_mut,
|
|
5042
5421
|
"daily_accumulators": daily_accumulators,
|
|
5043
5422
|
"resolver_cache": resolver_cache,
|
|
5044
5423
|
}
|
|
5045
|
-
|
|
5046
|
-
state
|
|
5047
|
-
|
|
5424
|
+
_cctally()._load_sibling("_lib_snapshot_cache")._assert_owner()
|
|
5425
|
+
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
|
|
5426
|
+
|
|
5427
|
+
if failures and not tolerate_leg_failures:
|
|
5428
|
+
raise next(iter(failures.values()))
|
|
5429
|
+
if tolerate_leg_failures:
|
|
5430
|
+
return payload, outcomes
|
|
5431
|
+
return payload
|
|
5432
|
+
|
|
5433
|
+
|
|
5434
|
+
def build_cached_claude_range_aggregates(
|
|
5435
|
+
conn,
|
|
5436
|
+
*,
|
|
5437
|
+
shared_start,
|
|
5438
|
+
shared_end_exclusive,
|
|
5439
|
+
now_utc,
|
|
5440
|
+
display_tz,
|
|
5441
|
+
legacy_labels,
|
|
5442
|
+
max_entry_id: "int | None" = None,
|
|
5443
|
+
entry_mutation_seq: "int | None" = None,
|
|
5444
|
+
generation: int = 0,
|
|
5445
|
+
):
|
|
5446
|
+
"""One-shot compatibility wrapper for non-pinned focused callers."""
|
|
5447
|
+
capture = capture_cached_claude_range_aggregates(
|
|
5448
|
+
conn,
|
|
5449
|
+
shared_start=shared_start,
|
|
5450
|
+
shared_end_exclusive=shared_end_exclusive,
|
|
5451
|
+
display_tz=display_tz,
|
|
5452
|
+
max_entry_id=max_entry_id,
|
|
5453
|
+
entry_mutation_seq=entry_mutation_seq,
|
|
5454
|
+
generation=generation,
|
|
5455
|
+
)
|
|
5456
|
+
return build_cached_claude_range_aggregates_from_capture(
|
|
5457
|
+
capture,
|
|
5048
5458
|
now_utc=now_utc,
|
|
5049
5459
|
display_tz=display_tz,
|
|
5460
|
+
legacy_labels=legacy_labels,
|
|
5050
5461
|
)
|
|
5051
|
-
_CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
|
|
5052
|
-
return payload
|
|
5053
5462
|
|
|
5054
5463
|
|
|
5055
5464
|
def _aggregate_projects_week_raw(
|
|
@@ -5080,7 +5489,8 @@ def _aggregate_projects_week_raw(
|
|
|
5080
5489
|
conn, since=week_start, until=week_end,
|
|
5081
5490
|
):
|
|
5082
5491
|
entry_cost = _fold_projects_entry(
|
|
5083
|
-
mut, row, resolver_cache=resolver_cache,
|
|
5492
|
+
mut, row, resolver_cache=resolver_cache,
|
|
5493
|
+
week_start=week_start, week_end=week_end,
|
|
5084
5494
|
)
|
|
5085
5495
|
if entry_cost is None:
|
|
5086
5496
|
continue
|
|
@@ -5143,7 +5553,7 @@ def _aggregate_projects_week(
|
|
|
5143
5553
|
def _assemble_projects_via_cache(
|
|
5144
5554
|
conn: "sqlite3.Connection",
|
|
5145
5555
|
*,
|
|
5146
|
-
|
|
5556
|
+
week_bounds: "list[tuple[dt.datetime, dt.datetime]]",
|
|
5147
5557
|
cw_start: "dt.datetime",
|
|
5148
5558
|
cw_end: "dt.datetime",
|
|
5149
5559
|
cur_max_id: int,
|
|
@@ -5218,33 +5628,47 @@ def _assemble_projects_via_cache(
|
|
|
5218
5628
|
if r[2] == "<synthetic>": # r[2] = model
|
|
5219
5629
|
continue
|
|
5220
5630
|
ts = parse_iso_datetime(r[1], "session_entries.timestamp_utc")
|
|
5221
|
-
if
|
|
5631
|
+
if not (cw_start <= ts < cw_end):
|
|
5222
5632
|
continue
|
|
5223
5633
|
out.append(r)
|
|
5224
5634
|
out.sort(key=lambda r: (r[1], r[0])) # (ts_iso, id)
|
|
5225
5635
|
return out
|
|
5226
5636
|
|
|
5227
|
-
|
|
5637
|
+
# The cache identity below is `(start, end)`, while the spec named
|
|
5638
|
+
# "account, exact start, exact end". The account axis is omitted
|
|
5639
|
+
# deliberately, not by oversight: this panel always folds merged
|
|
5640
|
+
# (`_projects_week_grid` is called here with `account_key=None`), so
|
|
5641
|
+
# every entry in this cache was produced by the one merged read and two
|
|
5642
|
+
# scopes cannot collide in it. Adding a constant third component would
|
|
5643
|
+
# be a key that never varies. If the panel ever gains an account scope,
|
|
5644
|
+
# the axis has to be added at the same time — an account-scoped fold
|
|
5645
|
+
# served from a merged slot is a wrong answer, not a stale one.
|
|
5646
|
+
for w, w_end in week_bounds:
|
|
5228
5647
|
if w == cw_start:
|
|
5229
5648
|
week_buckets, week_total = sc.accumulate_projects_current_week(
|
|
5230
|
-
|
|
5649
|
+
# #620 S1: the accumulator's identity is the INTERVAL. An
|
|
5650
|
+
# early reset that moves the current week's bounds must
|
|
5651
|
+
# cold-refold the slot rather than keep appending to a
|
|
5652
|
+
# running aggregate folded over the old window.
|
|
5653
|
+
week_key=sc.projects_env_week_key(cw_start, cw_end),
|
|
5231
5654
|
cur_max_id=cur_max_id,
|
|
5232
5655
|
cur_max_seq=cur_max_seq,
|
|
5233
5656
|
fetch_all_raw=_fetch_all_raw,
|
|
5234
5657
|
fetch_delta_rows=_fetch_delta_rows,
|
|
5235
5658
|
finalize=_finalize_projects_mut,
|
|
5236
5659
|
fold=lambda mut, row: _fold_projects_entry(
|
|
5237
|
-
mut, row, resolver_cache=resolver_cache,
|
|
5660
|
+
mut, row, resolver_cache=resolver_cache,
|
|
5661
|
+
week_start=cw_start, week_end=cw_end,
|
|
5238
5662
|
),
|
|
5239
5663
|
)
|
|
5240
5664
|
else:
|
|
5241
|
-
week_iso = sc.projects_env_week_key(w)
|
|
5665
|
+
week_iso = sc.projects_env_week_key(w, w_end)
|
|
5242
5666
|
hit = sc.projects_env_week_get(week_iso)
|
|
5243
5667
|
if hit is not None:
|
|
5244
5668
|
week_buckets, week_total = hit
|
|
5245
5669
|
else:
|
|
5246
5670
|
week_buckets, week_total = _aggregate_projects_week(
|
|
5247
|
-
conn, week_start=w, week_end=
|
|
5671
|
+
conn, week_start=w, week_end=w_end,
|
|
5248
5672
|
resolver_cache=resolver_cache,
|
|
5249
5673
|
)
|
|
5250
5674
|
sc.projects_env_week_put(week_iso, week_buckets, week_total)
|
|
@@ -5268,14 +5692,21 @@ def _build_projects_envelope(
|
|
|
5268
5692
|
shape from spec §5.2 (no per-model breakdowns, no first/last seen
|
|
5269
5693
|
per session, no per-row $/1%; just cost / attributed_pct / sessions).
|
|
5270
5694
|
|
|
5271
|
-
Week boundaries
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5695
|
+
Week boundaries are the real subscription intervals (#620 S1 D1):
|
|
5696
|
+
``_projects_week_grid`` derives them from ``_compute_subscription_weeks``,
|
|
5697
|
+
the same kernel ``cmd_project`` buckets by, so the two surfaces attribute
|
|
5698
|
+
the same projects over the same weeks. A ``weekly_usage_snapshots`` row
|
|
5699
|
+
is matched onto an interval by its ``week_start_at`` anchor, falling back
|
|
5700
|
+
to ``week_start_date`` for a legacy row that carries no anchor; a row
|
|
5701
|
+
that matches no interval contributes nothing, leaving ``attributed_pct``
|
|
5702
|
+
None rather than attributing over a mismatched population. Only a store
|
|
5703
|
+
with no anchored snapshot at all falls back to
|
|
5704
|
+
``_projects_week_start_monday_utc``, which is what that function was
|
|
5705
|
+
written for.
|
|
5706
|
+
|
|
5707
|
+
``current_week`` is passed through opaquely — if non-None and carrying a
|
|
5708
|
+
``.week_start_at`` UTC datetime, that instant selects which interval is
|
|
5709
|
+
the current week. None (the default) uses ``now_utc``.
|
|
5279
5710
|
|
|
5280
5711
|
Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
|
|
5281
5712
|
(R-PROJ5 invariant). Per-tick memoized on
|
|
@@ -5330,33 +5761,51 @@ def _build_projects_envelope(
|
|
|
5330
5761
|
return cached
|
|
5331
5762
|
|
|
5332
5763
|
# ---- Week-start anchor (current subscription week) ------------------
|
|
5333
|
-
#
|
|
5334
|
-
#
|
|
5335
|
-
#
|
|
5336
|
-
#
|
|
5337
|
-
#
|
|
5338
|
-
#
|
|
5339
|
-
#
|
|
5340
|
-
#
|
|
5341
|
-
#
|
|
5342
|
-
#
|
|
5343
|
-
#
|
|
5764
|
+
# #620 S1 D1. The panel buckets cost into the REAL subscription
|
|
5765
|
+
# intervals — the ones `_compute_subscription_weeks` derives from the
|
|
5766
|
+
# retained reset anchors, and the ones `cmd_project` already buckets by.
|
|
5767
|
+
# `_projects_week_start_monday_utc` remains what its own docstring says
|
|
5768
|
+
# it is: the fallback for when no anchor is available. It used to be
|
|
5769
|
+
# applied to the anchor itself, which discarded the very thing it was
|
|
5770
|
+
# written to defer to, so the cost window and the quota window described
|
|
5771
|
+
# different intervals for every account whose reset is not exactly
|
|
5772
|
+
# Monday midnight UTC — in practice almost all of them.
|
|
5773
|
+
#
|
|
5774
|
+
# ``TuiCurrentWeek.week_start_at`` after ``_apply_midweek_reset_override``
|
|
5775
|
+
# is the in-week reset instant rather than the week's start. It is used
|
|
5776
|
+
# here only to locate the containing interval, never as a bucket key, so
|
|
5777
|
+
# a shifted value resolves to the same week the entry walk uses and no
|
|
5778
|
+
# activity is stranded. Regression:
|
|
5779
|
+
# ``tests/fixtures/dashboard/reset-week/`` +
|
|
5344
5780
|
# ``test_current_week_rows_populated_after_midweek_reset``.
|
|
5345
|
-
if cw_key is not None
|
|
5346
|
-
|
|
5781
|
+
anchor_instant = cw_key if cw_key is not None else now_utc
|
|
5782
|
+
grid = _projects_week_grid(
|
|
5783
|
+
conn, anchor_utc=anchor_instant, weeks_back=weeks_back,
|
|
5784
|
+
)
|
|
5785
|
+
cw_start = grid.week_for(anchor_instant) if grid is not None else None
|
|
5786
|
+
if cw_start is None:
|
|
5787
|
+
# No anchor covers `now` — the genuine fallback path, byte-identical
|
|
5788
|
+
# to the pre-#620 behaviour for a store with no anchored snapshots.
|
|
5789
|
+
grid = None
|
|
5790
|
+
cw_start = _projects_week_start_monday_utc(anchor_instant)
|
|
5791
|
+
cw_end = cw_start + dt.timedelta(days=7)
|
|
5792
|
+
week_bounds = [
|
|
5793
|
+
(
|
|
5794
|
+
cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i)),
|
|
5795
|
+
cw_start - dt.timedelta(days=7 * (weeks_back - 2 - i)),
|
|
5796
|
+
)
|
|
5797
|
+
for i in range(weeks_back)
|
|
5798
|
+
]
|
|
5347
5799
|
else:
|
|
5348
|
-
|
|
5800
|
+
cw_end = grid.end_for(cw_start)
|
|
5801
|
+
week_bounds = grid.window_ending_at(cw_start, weeks_back)
|
|
5349
5802
|
|
|
5350
|
-
#
|
|
5351
|
-
# cw_start, oldest → newest, of length ``weeks_back``. Clamping to
|
|
5803
|
+
# Week starts, oldest → newest, of length ``weeks_back``. Clamping to
|
|
5352
5804
|
# actual history happens after the entry walk reveals what weeks
|
|
5353
5805
|
# have any activity.
|
|
5354
|
-
weeks_full = [
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
]
|
|
5358
|
-
cw_end = cw_start + dt.timedelta(days=7)
|
|
5359
|
-
since_dt = weeks_full[0]
|
|
5806
|
+
weeks_full = [b[0] for b in week_bounds]
|
|
5807
|
+
end_by_week = {s: e for s, e in week_bounds}
|
|
5808
|
+
since_dt = week_bounds[0][0]
|
|
5360
5809
|
until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
|
|
5361
5810
|
|
|
5362
5811
|
# ---- Bucket entries per (ProjectKey, week_start) --------------------
|
|
@@ -5371,7 +5820,7 @@ def _build_projects_envelope(
|
|
|
5371
5820
|
# HTTP-drill): the original single full-window walk, byte-unchanged.
|
|
5372
5821
|
if use_projects_env_cache:
|
|
5373
5822
|
buckets, total_cost_by_week, key_by_bucket = _assemble_projects_via_cache(
|
|
5374
|
-
conn,
|
|
5823
|
+
conn, week_bounds=week_bounds, cw_start=cw_start, cw_end=cw_end,
|
|
5375
5824
|
cur_max_id=max_id, cur_max_seq=entry_mutation_seq,
|
|
5376
5825
|
)
|
|
5377
5826
|
else:
|
|
@@ -5387,8 +5836,13 @@ def _build_projects_envelope(
|
|
|
5387
5836
|
key_by_bucket = {}
|
|
5388
5837
|
|
|
5389
5838
|
def _week_for(ts: dt.datetime) -> "dt.datetime | None":
|
|
5390
|
-
|
|
5391
|
-
|
|
5839
|
+
if grid is not None:
|
|
5840
|
+
wstart = grid.week_for(ts)
|
|
5841
|
+
if wstart is None:
|
|
5842
|
+
return None
|
|
5843
|
+
else:
|
|
5844
|
+
wstart = _projects_week_start_monday_utc(ts)
|
|
5845
|
+
if wstart not in end_by_week:
|
|
5392
5846
|
return None
|
|
5393
5847
|
return wstart
|
|
5394
5848
|
|
|
@@ -5484,7 +5938,7 @@ def _build_projects_envelope(
|
|
|
5484
5938
|
weekly_pct_by_week: dict[dt.datetime, float] = {}
|
|
5485
5939
|
try:
|
|
5486
5940
|
cur = conn.execute(
|
|
5487
|
-
"SELECT week_start_date, weekly_percent "
|
|
5941
|
+
"SELECT week_start_date, week_start_at, weekly_percent "
|
|
5488
5942
|
"FROM weekly_usage_snapshots "
|
|
5489
5943
|
"ORDER BY captured_at_utc ASC, id ASC"
|
|
5490
5944
|
)
|
|
@@ -5493,19 +5947,40 @@ def _build_projects_envelope(
|
|
|
5493
5947
|
# No weekly_usage_snapshots table — leaves attributed_pct = None
|
|
5494
5948
|
# throughout (acceptable per spec §2.7).
|
|
5495
5949
|
rows = []
|
|
5496
|
-
for week_date_str, weekly_pct in rows:
|
|
5950
|
+
for week_date_str, week_start_at, weekly_pct in rows:
|
|
5497
5951
|
try:
|
|
5498
5952
|
wd = dt.date.fromisoformat(week_date_str)
|
|
5499
5953
|
except (TypeError, ValueError):
|
|
5500
5954
|
continue
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5955
|
+
wstart: "dt.datetime | None" = None
|
|
5956
|
+
if grid is not None:
|
|
5957
|
+
# #620 S1 D1. Resolve the row onto the SAME interval the entry
|
|
5958
|
+
# walk buckets into, so the numerator, the denominator and this
|
|
5959
|
+
# percentage are all taken from one half-open window before the
|
|
5960
|
+
# multiplication below.
|
|
5961
|
+
if week_start_at:
|
|
5962
|
+
try:
|
|
5963
|
+
anchor = parse_iso_datetime(
|
|
5964
|
+
week_start_at, "weekly_usage_snapshots.week_start_at",
|
|
5965
|
+
).astimezone(dt.timezone.utc)
|
|
5966
|
+
except (TypeError, ValueError):
|
|
5967
|
+
anchor = None
|
|
5968
|
+
if anchor is not None:
|
|
5969
|
+
wstart = grid.week_for(anchor)
|
|
5970
|
+
if wstart is None:
|
|
5971
|
+
# A legacy row carrying only the date-only boundary. One
|
|
5972
|
+
# shared interval still serves both the cost and the
|
|
5973
|
+
# percentage; taking them from different intervals is never
|
|
5974
|
+
# acceptable, so an unresolvable row contributes nothing and
|
|
5975
|
+
# `attributed_pct` stays None (#620 S1 A3).
|
|
5976
|
+
wstart = grid.start_for_date(wd)
|
|
5977
|
+
if wstart is None:
|
|
5978
|
+
continue
|
|
5979
|
+
else:
|
|
5980
|
+
# No anchor anywhere in the store: the genuine Monday fallback.
|
|
5981
|
+
wstart = _projects_week_start_monday_utc(dt.datetime.combine(
|
|
5982
|
+
wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
|
|
5983
|
+
))
|
|
5509
5984
|
if weekly_pct is not None:
|
|
5510
5985
|
weekly_pct_by_week[wstart] = float(weekly_pct)
|
|
5511
5986
|
|
|
@@ -5532,12 +6007,11 @@ def _build_projects_envelope(
|
|
|
5532
6007
|
if weeks_with_activity:
|
|
5533
6008
|
# Window = inclusive [oldest_active_week, cw_start]. Always emits
|
|
5534
6009
|
# cw_start (panel + trend share the same current_week column).
|
|
6010
|
+
# Keyed by the SAME rule as the current week (#620 S1 D1) — the
|
|
6011
|
+
# subscription intervals themselves, not a seven-day walk, because
|
|
6012
|
+
# a mixed keying inside one panel is the defect restated.
|
|
5535
6013
|
oldest = min(weeks_with_activity[0], cw_start)
|
|
5536
|
-
trend_weeks = []
|
|
5537
|
-
w = oldest
|
|
5538
|
-
while w <= cw_start:
|
|
5539
|
-
trend_weeks.append(w)
|
|
5540
|
-
w += dt.timedelta(days=7)
|
|
6014
|
+
trend_weeks = [w for w in weeks_full if oldest <= w <= cw_start]
|
|
5541
6015
|
else:
|
|
5542
6016
|
trend_weeks = [cw_start]
|
|
5543
6017
|
|
|
@@ -5729,13 +6203,74 @@ def _project_detail_for_window(
|
|
|
5729
6203
|
if bucket_path is None:
|
|
5730
6204
|
return None
|
|
5731
6205
|
|
|
5732
|
-
# ---- Window bounds
|
|
6206
|
+
# ---- Window bounds, from the envelope's own current-week anchor ----
|
|
6207
|
+
# That anchor is the account's real subscription week start; the
|
|
6208
|
+
# Monday-midnight snap is only the no-anchor fallback (#620).
|
|
5733
6209
|
cw_start = parse_iso_datetime(
|
|
5734
6210
|
env["current_week"]["week_start_at"],
|
|
5735
6211
|
"projects.current_week.week_start_at",
|
|
6212
|
+
).astimezone(dt.timezone.utc)
|
|
6213
|
+
|
|
6214
|
+
# The drill resolves the SAME interval its panel resolved, by rebuilding
|
|
6215
|
+
# the panel's grid rather than stepping back in seven-day multiples.
|
|
6216
|
+
# `_ProjectsWeekGrid` exists because a drifted reset day produces a
|
|
6217
|
+
# genuinely short week: on `non-monday-anchor` at `weeks_back=4` the grid
|
|
6218
|
+
# starts the window at 2026-03-27T09:00Z while a seven-day walk yields
|
|
6219
|
+
# 2026-03-26T09:00Z, and an early reset that shortens the current week
|
|
6220
|
+
# would likewise leave a `cw_start + 7d` end counting cost past the
|
|
6221
|
+
# week's real end. `window_start_at` / `window_end_at` publish these
|
|
6222
|
+
# bounds to the client as authoritative, so a divergence here renders a
|
|
6223
|
+
# window the panel never computed.
|
|
6224
|
+
#
|
|
6225
|
+
# The grid is rebuilt from the PANEL'S OWN anchor, not from `cw_start`.
|
|
6226
|
+
# `_projects_week_grid` derives its provisional range from an ISO-Monday
|
|
6227
|
+
# snap of whatever anchor it is handed, and `cw_start` is the interval
|
|
6228
|
+
# START while the panel anchors on `current_week.week_start_at` — which
|
|
6229
|
+
# after `_apply_midweek_reset_override` is the in-week reset instant and
|
|
6230
|
+
# can sit up to a week later. The two therefore snap to different Mondays
|
|
6231
|
+
# and `_compute_subscription_weeks` can pick a different extrapolation
|
|
6232
|
+
# anchor for each, so window equality would rest on a coincidence rather
|
|
6233
|
+
# than on the two surfaces asking the same question. `cw_start` remains
|
|
6234
|
+
# the anchor `window_ending_at` walks back from, because that walk needs
|
|
6235
|
+
# an interval start.
|
|
6236
|
+
panel_anchor = getattr(current_week, "week_start_at", None)
|
|
6237
|
+
if not isinstance(panel_anchor, dt.datetime):
|
|
6238
|
+
panel_anchor = now_utc
|
|
6239
|
+
detail_grid = _projects_week_grid(
|
|
6240
|
+
conn, anchor_utc=panel_anchor, weeks_back=weeks_back,
|
|
6241
|
+
)
|
|
6242
|
+
detail_bounds = (
|
|
6243
|
+
detail_grid.window_ending_at(cw_start, weeks_back)
|
|
6244
|
+
if detail_grid is not None else []
|
|
5736
6245
|
)
|
|
5737
|
-
|
|
5738
|
-
|
|
6246
|
+
if detail_bounds:
|
|
6247
|
+
since_dt = detail_bounds[0][0]
|
|
6248
|
+
until_dt = detail_bounds[-1][1]
|
|
6249
|
+
else:
|
|
6250
|
+
# No anchor covers this start — the same no-anchor tail the panel
|
|
6251
|
+
# falls back to, where the seven-day assumption is the right one.
|
|
6252
|
+
since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
|
|
6253
|
+
until_dt = cw_start + dt.timedelta(days=7)
|
|
6254
|
+
since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
|
|
6255
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
6256
|
+
)
|
|
6257
|
+
until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
|
|
6258
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
6259
|
+
)
|
|
6260
|
+
# SQL candidate bound, NOT the published one. Ingestion stores
|
|
6261
|
+
# `timestamp_utc` as `…+00:00` while these bounds are spelled `…Z`, and
|
|
6262
|
+
# SQLite compares that column lexically with `+` (0x2B) below `Z` (0x5A).
|
|
6263
|
+
# So the lower bound drops an entry sitting exactly on it and the upper
|
|
6264
|
+
# bound admits one sitting exactly on it — an asymmetry in both
|
|
6265
|
+
# directions. Widening the lower bound by a second makes SQL an outward
|
|
6266
|
+
# candidate filter at both ends; the half-open membership test is then
|
|
6267
|
+
# enforced on the PARSED datetime in the entry loop, which is the only
|
|
6268
|
+
# place it can be stated honestly.
|
|
6269
|
+
since_sql_iso = (
|
|
6270
|
+
since_dt.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
|
|
6271
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
6272
|
+
until_dt_utc = until_dt.astimezone(dt.timezone.utc)
|
|
6273
|
+
since_dt_utc = since_dt.astimezone(dt.timezone.utc)
|
|
5739
6274
|
|
|
5740
6275
|
# ---- Build bucket → source_paths map for SQL-side scoping ----------
|
|
5741
6276
|
# Walk session_files (~8k rows) once instead of session_entries
|
|
@@ -5787,6 +6322,8 @@ def _project_detail_for_window(
|
|
|
5787
6322
|
"key": project_key,
|
|
5788
6323
|
"bucket_path": bucket_path,
|
|
5789
6324
|
"window_weeks": weeks_back,
|
|
6325
|
+
"window_start_at": since_iso,
|
|
6326
|
+
"window_end_at": until_iso,
|
|
5790
6327
|
"window_cost_usd": 0.0,
|
|
5791
6328
|
"window_attributed_pct": None,
|
|
5792
6329
|
"models": [],
|
|
@@ -5810,13 +6347,6 @@ def _project_detail_for_window(
|
|
|
5810
6347
|
[(p,) for p in bucket_source_paths],
|
|
5811
6348
|
)
|
|
5812
6349
|
|
|
5813
|
-
since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
|
|
5814
|
-
"%Y-%m-%dT%H:%M:%SZ"
|
|
5815
|
-
)
|
|
5816
|
-
until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
|
|
5817
|
-
"%Y-%m-%dT%H:%M:%SZ"
|
|
5818
|
-
)
|
|
5819
|
-
|
|
5820
6350
|
# ---- Walk session_entries (project-scoped) once -------------------
|
|
5821
6351
|
# INNER JOIN to _drill_paths drops every row whose source_path
|
|
5822
6352
|
# doesn't belong to this bucket. The Python-side filter that
|
|
@@ -5832,7 +6362,7 @@ def _project_detail_for_window(
|
|
|
5832
6362
|
"LEFT JOIN session_files sf ON sf.path = e.source_path "
|
|
5833
6363
|
"WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
|
|
5834
6364
|
"ORDER BY e.timestamp_utc ASC, e.id ASC",
|
|
5835
|
-
(
|
|
6365
|
+
(since_sql_iso, until_iso),
|
|
5836
6366
|
)
|
|
5837
6367
|
|
|
5838
6368
|
# Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
|
|
@@ -5855,6 +6385,14 @@ def _project_detail_for_window(
|
|
|
5855
6385
|
# on _drill_paths already restricted the result set to entries
|
|
5856
6386
|
# whose source_path belongs to this bucket.
|
|
5857
6387
|
ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
|
|
6388
|
+
# The half-open membership test. The SQL bounds above are a widened
|
|
6389
|
+
# candidate filter that admits a second on each side, because the
|
|
6390
|
+
# column's stored offset spelling and the bound's spelling do not
|
|
6391
|
+
# compare the way the interval means; this is where the interval is
|
|
6392
|
+
# actually decided.
|
|
6393
|
+
ts_utc = ts.astimezone(dt.timezone.utc)
|
|
6394
|
+
if not (since_dt_utc <= ts_utc < until_dt_utc):
|
|
6395
|
+
continue
|
|
5858
6396
|
entry_cost = _calculate_entry_cost(
|
|
5859
6397
|
model,
|
|
5860
6398
|
claude_usage_dict( # #195 chokepoint
|
|
@@ -5977,6 +6515,8 @@ def _project_detail_for_window(
|
|
|
5977
6515
|
"key": project_key,
|
|
5978
6516
|
"bucket_path": bucket_path,
|
|
5979
6517
|
"window_weeks": weeks_back,
|
|
6518
|
+
"window_start_at": since_iso,
|
|
6519
|
+
"window_end_at": until_iso,
|
|
5980
6520
|
"window_cost_usd": window_cost,
|
|
5981
6521
|
"window_attributed_pct": win_pct,
|
|
5982
6522
|
"models": models_out,
|
|
@@ -8098,10 +8638,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8098
8638
|
return
|
|
8099
8639
|
|
|
8100
8640
|
if axis == "weekly":
|
|
8641
|
+
# Mirrors the CLI `alerts test --axis weekly` branch: the preview
|
|
8642
|
+
# carries the reset INSTANT a real crossing carries, so it renders
|
|
8643
|
+
# the instant form rather than the day-granularity fallback.
|
|
8644
|
+
preview_week_start = synthetic_preview_week_start()
|
|
8101
8645
|
payload = _build_alert_payload_weekly(
|
|
8102
8646
|
threshold=threshold,
|
|
8103
8647
|
crossed_at_utc=now_utc_iso(),
|
|
8104
|
-
week_start_date=
|
|
8648
|
+
week_start_date=preview_week_start.date().isoformat(),
|
|
8649
|
+
week_start_at=preview_week_start.isoformat().replace(
|
|
8650
|
+
"+00:00", "Z"
|
|
8651
|
+
),
|
|
8105
8652
|
cumulative_cost_usd=1.23,
|
|
8106
8653
|
dollars_per_percent=0.01,
|
|
8107
8654
|
)
|