cctally 1.7.0 → 1.7.1

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.
@@ -0,0 +1,105 @@
1
+ """Pure SemVer primitives — parse, format, bump-compute, sort-key.
2
+
3
+ Eager-imported from bin/cctally to back release-flow internals and the
4
+ update-banner version-compare predicate. Zero I/O, zero module-level
5
+ side effects; safe to import from any context (script, SourceFileLoader,
6
+ compile+exec).
7
+
8
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ # Exported as a building block: bin/cctally's RELEASE_HEADER_RE and
15
+ # _cctally_release's _FORMULA_VERSION_RE both reuse this numeric-component
16
+ # pattern for SemVer matching.
17
+ _SEMVER_NUM = r'(?:0|[1-9]\d*)'
18
+
19
+ _SEMVER_RE = re.compile(
20
+ rf'^({_SEMVER_NUM})\.({_SEMVER_NUM})\.({_SEMVER_NUM})'
21
+ rf'(?:-([a-zA-Z][a-zA-Z0-9-]*)\.({_SEMVER_NUM}))?$'
22
+ )
23
+
24
+
25
+ def _release_parse_semver(s: str) -> tuple[int, int, int, str | None, int | None]:
26
+ """Parse SemVer; raises ValueError on malformed input."""
27
+ m = _SEMVER_RE.match(s)
28
+ if not m:
29
+ raise ValueError(f"invalid semver: {s!r}")
30
+ major, minor, patch, prerelease_id, prerelease_n = m.groups()
31
+ return (
32
+ int(major),
33
+ int(minor),
34
+ int(patch),
35
+ prerelease_id,
36
+ int(prerelease_n) if prerelease_n is not None else None,
37
+ )
38
+
39
+
40
+ def _release_format_semver(
41
+ major: int, minor: int, patch: int,
42
+ prerelease_id: str | None = None, prerelease_n: int | None = None,
43
+ ) -> str:
44
+ base = f"{major}.{minor}.{patch}"
45
+ if prerelease_id is None:
46
+ return base
47
+ return f"{base}-{prerelease_id}.{prerelease_n}"
48
+
49
+
50
+ def _release_compute_next_version(
51
+ current: str | None, kind: str, bump: str | None, prerelease_id: str,
52
+ ) -> str:
53
+ """Pure function. Implements bump rules from spec Section 4.4."""
54
+ if current is None:
55
+ # First-ever release. Treat as 0.0.0 base.
56
+ cur_maj, cur_min, cur_pat, cur_id, cur_n = 0, 0, 0, None, None
57
+ else:
58
+ cur_maj, cur_min, cur_pat, cur_id, cur_n = _release_parse_semver(current)
59
+ is_prerelease = cur_id is not None
60
+
61
+ if kind == "finalize":
62
+ if not is_prerelease:
63
+ raise ValueError("cannot finalize: current version is not a prerelease")
64
+ return _release_format_semver(cur_maj, cur_min, cur_pat)
65
+
66
+ if kind == "prerelease":
67
+ if is_prerelease:
68
+ if bump is not None:
69
+ raise ValueError("--bump invalid when current version is a prerelease; rc counter increments only")
70
+ return _release_format_semver(cur_maj, cur_min, cur_pat, cur_id, cur_n + 1)
71
+ if bump is None:
72
+ raise ValueError("--bump required for first prerelease from stable version")
73
+ # Apply bump kind to current stable, then attach -<id>.1
74
+ nxt = _release_compute_next_version(current or "0.0.0", bump, None, prerelease_id)
75
+ nxt_maj, nxt_min, nxt_pat, _, _ = _release_parse_semver(nxt)
76
+ return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
77
+
78
+ if is_prerelease:
79
+ raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
80
+
81
+ if kind == "patch":
82
+ return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
83
+ if kind == "minor":
84
+ return _release_format_semver(cur_maj, cur_min + 1, 0)
85
+ if kind == "major":
86
+ return _release_format_semver(cur_maj + 1, 0, 0)
87
+ raise ValueError(f"unknown bump kind: {kind!r}")
88
+
89
+
90
+ def _release_semver_sort_key(
91
+ parsed: tuple[int, int, int, str | None, int | None],
92
+ ) -> tuple:
93
+ """Total-order sort key for `_release_parse_semver` output.
94
+
95
+ SemVer §11.4: a stable release has higher precedence than a pre-release
96
+ of the same MAJOR.MINOR.PATCH. Naive tuple comparison breaks because
97
+ Python rejects ``None < str`` at runtime. The key returned here makes
98
+ stable releases sort *after* their pre-releases by inverting the
99
+ "has-prerelease" axis: stable → ``(maj, min, pat, 1, "", 0)``,
100
+ pre-release → ``(maj, min, pat, 0, id, n)``.
101
+ """
102
+ maj, min_, pat, pre_id, pre_n = parsed
103
+ if pre_id is None:
104
+ return (maj, min_, pat, 1, "", 0)
105
+ return (maj, min_, pat, 0, pre_id, pre_n)
@@ -0,0 +1,492 @@
1
+ """Subscription-week boundary computation.
2
+
3
+ Self-contained subscription-week domain: the `SubWeek` frozen dataclass +
4
+ the helpers that compute, clamp, and reset-event-shift a list of weeks
5
+ from `weekly_usage_snapshots` / `weekly_cost_snapshots` / config-based
6
+ calendar-week math.
7
+
8
+ This is the first `_lib_*` module to back-reference `bin/cctally` for
9
+ shared utility helpers (`parse_iso_datetime`, `load_config`,
10
+ `get_week_start_name`, `WEEKDAY_MAP`). The back-reference uses the same
11
+ `_cctally()` call-time accessor pattern established in
12
+ `bin/_cctally_release.py` (spec §5.5) — never `import cctally` at module
13
+ top, which would pin to the *original* module instance and break
14
+ SourceFileLoader-based test isolation. Module-load time stays
15
+ self-contained; only call-time resolves through `sys.modules["cctally"]`.
16
+
17
+ Sibling dependency: `_compute_subscription_weeks` calls `_resolve_tz` /
18
+ `_local_tz_name` (from `_lib_display_tz`) in the no-snapshot
19
+ config-based fallback path; loaded via `_load_lib` at module load time
20
+ (same shape as `bin/_lib_alerts_payload.py`).
21
+
22
+ Why the planned-extract set of 4 became 6: `_apply_overlap_clamp_to_subweeks`
23
+ calls `_clamp_end_ats_to_next_start` (originally listed as private,
24
+ implicit), and `_compute_subscription_weeks` calls
25
+ `_apply_reset_events_to_subweeks` (originally elsewhere in `bin/cctally`).
26
+ Moving both keeps the subscription-week domain self-contained and avoids
27
+ inventing a call-time back-reference to `_apply_reset_events_to_subweeks`.
28
+ `_apply_overlap_clamp_to_weekrefs` (operates on `WeekRef`, NOT `SubWeek`)
29
+ stays in `bin/cctally` and reaches `_clamp_end_ats_to_next_start` through
30
+ the re-export block.
31
+
32
+ `bin/cctally` re-exports every public symbol below so the ~50 internal
33
+ call sites + SourceFileLoader-based tests (`tests/test_subweek_display_dates`,
34
+ `tests/test_dashboard_period_builders`) resolve unchanged.
35
+
36
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import bisect
41
+ import datetime as dt
42
+ import pathlib
43
+ import sqlite3
44
+ import sys
45
+ from dataclasses import dataclass, replace
46
+
47
+
48
+ def _cctally():
49
+ """Resolve the current `cctally` module at call-time.
50
+
51
+ Spec §5.5 — defers the lookup so SourceFileLoader-loaded test instances
52
+ of `bin/cctally` (which reassign `sys.modules["cctally"]`) are seen by
53
+ this module's back-references. Mirror of `bin/_cctally_release._cctally()`.
54
+ """
55
+ return sys.modules["cctally"]
56
+
57
+
58
+ def _load_lib(name: str):
59
+ cached = sys.modules.get(name)
60
+ if cached is not None:
61
+ return cached
62
+ import importlib.util as _ilu
63
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
64
+ spec = _ilu.spec_from_file_location(name, p)
65
+ mod = _ilu.module_from_spec(spec)
66
+ sys.modules[name] = mod
67
+ spec.loader.exec_module(mod)
68
+ return mod
69
+
70
+
71
+ _lib_display_tz = _load_lib("_lib_display_tz")
72
+ _resolve_tz = _lib_display_tz._resolve_tz
73
+ _local_tz_name = _lib_display_tz._local_tz_name
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SubWeek:
78
+ """One subscription-week bounded interval.
79
+
80
+ `start_ts` / `end_ts` are ISO-8601 strings with TZ offset.
81
+
82
+ `start_date` doubles as the **internal bucket key** (matched against
83
+ `BucketUsage.bucket`) and the **lookup key** for
84
+ `weekly_usage_snapshots.week_start_date`. It reflects the API-derived
85
+ boundary at snapshot capture time and is intentionally NOT shifted by
86
+ `_apply_reset_events_to_subweeks` so the usage-% join stays joinable
87
+ after an early reset.
88
+
89
+ `display_start_date` is the user-facing start date and tracks `start_ts`
90
+ after `_apply_reset_events_to_subweeks` may have rewritten the latter
91
+ to the early-reset effective moment. For weeks never touched by a reset
92
+ event, `display_start_date == start_date`.
93
+
94
+ `end_date` is the inclusive last-day for display; it is NOT a lookup key
95
+ (`_get_latest_row_for_week` joins on `week_start_date` only) and is
96
+ already shifted in lockstep with `end_ts` by the existing clamp /
97
+ reset-event code, so a separate display field for the end is redundant.
98
+
99
+ `source` is either "snapshot" (boundary came from a `weekly_usage_snapshots`
100
+ row) or "extrapolated" (inferred from the anchor via 7-day multiples).
101
+ """
102
+ start_ts: str # ISO-8601, e.g. "2026-04-14T03:00:00+00:00"
103
+ end_ts: str # ISO-8601, start_ts + 7d
104
+ start_date: dt.date
105
+ end_date: dt.date # start_date + 6d (inclusive last day for display)
106
+ source: str # "snapshot" | "extrapolated"
107
+ display_start_date: dt.date
108
+
109
+
110
+ def _discover_week_anchor(conn: sqlite3.Connection) -> str | None:
111
+ """Return one known `week_start_at` value, or None if unavailable.
112
+
113
+ Preference order (per spec A1.6 Step 1):
114
+ 1. earliest week_start_at in weekly_usage_snapshots
115
+ 2. earliest week_start_at in weekly_cost_snapshots
116
+ 3. None — caller falls back to config-based calendar-week math.
117
+ """
118
+ for table in ("weekly_usage_snapshots", "weekly_cost_snapshots"):
119
+ row = conn.execute(
120
+ f"SELECT week_start_at FROM {table} "
121
+ f"WHERE week_start_at IS NOT NULL "
122
+ f"ORDER BY week_start_at ASC LIMIT 1"
123
+ ).fetchone()
124
+ if row is not None and row[0]:
125
+ return row[0]
126
+ return None
127
+
128
+
129
+ def _clamp_end_ats_to_next_start(
130
+ pairs: list[tuple[str | None, str | None]],
131
+ ) -> list[str | None]:
132
+ """For each (start_at, end_at) pair, return a clamped end_at.
133
+
134
+ When the next pair's start_at falls strictly inside the current pair's
135
+ (start_at, end_at) interval, the current end_at is replaced by that
136
+ next start_at. This corrects weekly_usage_snapshots.week_end_at, which
137
+ is captured once from Anthropic's --resets-at at week-start and never
138
+ updated when a later early reset actually ends the week sooner. The
139
+ overlap of the next week's start_at inside the current week's
140
+ interval is the observable ground-truth signal of an early reset.
141
+
142
+ `pairs` must be sorted by start_at ascending. None values are
143
+ passed through unchanged and never participate in clamping.
144
+ """
145
+ parse_iso_datetime = _cctally().parse_iso_datetime
146
+ n = len(pairs)
147
+ if n < 2:
148
+ return [p[1] for p in pairs]
149
+ out: list[str | None] = []
150
+ for i, (cur_start, cur_end) in enumerate(pairs):
151
+ if cur_end is None or i + 1 >= n:
152
+ out.append(cur_end)
153
+ continue
154
+ nxt_start = pairs[i + 1][0]
155
+ if nxt_start is None:
156
+ out.append(cur_end)
157
+ continue
158
+ cur_end_dt = parse_iso_datetime(cur_end, "week.end_at")
159
+ nxt_start_dt = parse_iso_datetime(nxt_start, "week.start_at")
160
+ if cur_start is not None:
161
+ cur_start_dt = parse_iso_datetime(cur_start, "week.start_at")
162
+ if not (cur_start_dt < nxt_start_dt < cur_end_dt):
163
+ out.append(cur_end)
164
+ continue
165
+ elif nxt_start_dt >= cur_end_dt:
166
+ out.append(cur_end)
167
+ continue
168
+ out.append(nxt_start)
169
+ return out
170
+
171
+
172
+ def _apply_overlap_clamp_to_subweeks(weeks: list[SubWeek]) -> list[SubWeek]:
173
+ """Clamp each SubWeek's end_ts to the next SubWeek's start_ts on overlap.
174
+
175
+ The early-reset fix: weekly_usage_snapshots.week_end_at stays stale
176
+ across Anthropic early resets, so _compute_subscription_weeks() emits
177
+ SubWeeks whose end_ts may sit past the real end. The next week's
178
+ start_ts (itself from a fresh snapshot) reveals the true boundary.
179
+ Clamping here corrects both display (--json weekEndAt) and the
180
+ _aggregate_weekly bucketing interval [start_ts, end_ts).
181
+
182
+ Input must be sorted by start_ts ascending (invariant of
183
+ _compute_subscription_weeks in all three branches).
184
+ """
185
+ parse_iso_datetime = _cctally().parse_iso_datetime
186
+ if len(weeks) < 2:
187
+ return weeks
188
+ pairs: list[tuple[str | None, str | None]] = [(w.start_ts, w.end_ts) for w in weeks]
189
+ new_ends = _clamp_end_ats_to_next_start(pairs)
190
+ result: list[SubWeek] = []
191
+ for w, new_end in zip(weeks, new_ends):
192
+ if new_end is None or new_end == w.end_ts:
193
+ result.append(w)
194
+ continue
195
+ new_end_dt = parse_iso_datetime(new_end, "week.end_ts (clamped)")
196
+ # internal fallback: host-local intentional
197
+ new_end_date = (new_end_dt - dt.timedelta(seconds=1)).astimezone().date()
198
+ result.append(replace(w, end_ts=new_end, end_date=new_end_date))
199
+ return result
200
+
201
+
202
+ def _apply_reset_events_to_subweeks(
203
+ conn: sqlite3.Connection, weeks: list[SubWeek]
204
+ ) -> list[SubWeek]:
205
+ """Override SubWeek boundaries with reset-event effective moments.
206
+
207
+ Same semantics as `_apply_reset_events_to_weekrefs` but for SubWeek:
208
+ - SubWeek whose end_ts equals event.old_week_end_at (instant)
209
+ is the PRE-reset week → end_ts := effective_reset_at_utc
210
+ and end_date := (reset_dt - 1s).astimezone().date()
211
+ - SubWeek whose end_ts equals event.new_week_end_at (instant)
212
+ is the POST-reset week → start_ts := effective_reset_at_utc
213
+ (start_date kept; it is the bucket key + lookup key for
214
+ weekly_usage_snapshots.week_start_date).
215
+
216
+ Compares by parsed datetime instant — SubWeek.{start,end}_ts are
217
+ raw snapshot strings that may be written in non-UTC offsets while
218
+ `week_reset_events.{old,new}_week_end_at` are canonicalized UTC.
219
+ """
220
+ parse_iso_datetime = _cctally().parse_iso_datetime
221
+ rows = conn.execute(
222
+ "SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
223
+ "FROM week_reset_events"
224
+ ).fetchall()
225
+ if not rows:
226
+ return weeks
227
+ parsed_events: list[tuple[dt.datetime, dt.datetime, str]] = []
228
+ for r in rows:
229
+ try:
230
+ old_dt = parse_iso_datetime(r["old_week_end_at"], "evt.old_end")
231
+ new_dt = parse_iso_datetime(r["new_week_end_at"], "evt.new_end")
232
+ except ValueError:
233
+ continue
234
+ parsed_events.append((old_dt, new_dt, r["effective_reset_at_utc"]))
235
+ if not parsed_events:
236
+ return weeks
237
+
238
+ out: list[SubWeek] = []
239
+ for w in weeks:
240
+ new_w = w
241
+ try:
242
+ end_dt = parse_iso_datetime(w.end_ts, "subweek.end_ts")
243
+ except ValueError:
244
+ out.append(w)
245
+ continue
246
+ for old_dt, new_dt, reset_at in parsed_events:
247
+ if end_dt == old_dt:
248
+ try:
249
+ reset_dt = parse_iso_datetime(reset_at, "evt.eff")
250
+ except ValueError:
251
+ continue
252
+ # internal fallback: host-local intentional
253
+ new_end_date = (reset_dt - dt.timedelta(seconds=1)).astimezone().date()
254
+ new_w = replace(new_w, end_ts=reset_at, end_date=new_end_date)
255
+ if end_dt == new_dt:
256
+ try:
257
+ reset_dt = parse_iso_datetime(reset_at, "evt.eff")
258
+ except ValueError:
259
+ continue
260
+ # internal fallback: host-local intentional
261
+ new_display_start = reset_dt.astimezone().date()
262
+ new_w = replace(
263
+ new_w,
264
+ start_ts=reset_at,
265
+ display_start_date=new_display_start,
266
+ )
267
+ # start_date intentionally NOT touched — it is the bucket /
268
+ # lookup key into weekly_usage_snapshots.week_start_date.
269
+ out.append(new_w)
270
+ return out
271
+
272
+
273
+ def _compute_subscription_weeks(
274
+ conn: sqlite3.Connection,
275
+ range_start: dt.datetime,
276
+ range_end: dt.datetime,
277
+ ) -> list[SubWeek]:
278
+ """Generate the ordered list of subscription weeks overlapping [range_start, range_end].
279
+
280
+ Prefers snapshot rows (authoritative reset boundaries from actual data)
281
+ and extrapolates by 7-day multiples only for the range tail before the
282
+ earliest snapshot. When no snapshots exist at all, falls back to
283
+ config-based calendar-week boundaries with every week tagged
284
+ "extrapolated".
285
+
286
+ Anthropic's reset day-of-week is not strictly stable across long spans —
287
+ it can shift (observed: Thursday cycles in Feb, Friday cycles from Mar
288
+ onward). A single-anchor 7-day-multiple extrapolation therefore generates
289
+ dates that miss actual snapshot boundaries for middle weeks. Using
290
+ snapshot rows directly for weeks they cover avoids that drift.
291
+ """
292
+ cctally = _cctally()
293
+ parse_iso_datetime = cctally.parse_iso_datetime
294
+
295
+ # Case A: snapshots exist.
296
+ snap_rows = conn.execute(
297
+ "SELECT "
298
+ " MIN(week_start_at) AS week_start_at, "
299
+ " MIN(week_end_at) AS week_end_at, "
300
+ " week_start_date, "
301
+ " MIN(week_end_date) AS week_end_date "
302
+ "FROM weekly_usage_snapshots "
303
+ "WHERE week_start_at IS NOT NULL "
304
+ " AND week_end_at IS NOT NULL "
305
+ " AND week_start_date IS NOT NULL "
306
+ "GROUP BY week_start_date "
307
+ "ORDER BY MIN(week_start_at) ASC"
308
+ ).fetchall()
309
+
310
+ weeks: list[SubWeek] = []
311
+
312
+ if snap_rows:
313
+ parsed_snaps: list[tuple[dt.datetime, dt.datetime, str, str, str, str | None]] = []
314
+ for row in snap_rows:
315
+ start_ts, end_ts, start_date_s, end_date_s = row
316
+ start_dt = parse_iso_datetime(start_ts, "week_start_at")
317
+ end_dt = parse_iso_datetime(end_ts, "week_end_at")
318
+ parsed_snaps.append((start_dt, end_dt, start_ts, end_ts, start_date_s, end_date_s))
319
+
320
+ snap_start_dts = [r[0] for r in parsed_snaps]
321
+
322
+ # Pick initial anchor: first snapshot >= range_start; else last snapshot
323
+ # < range_start; else the earliest snapshot (only happens when all
324
+ # snapshots are before range_start — we'll step forward from it).
325
+ idx_ge = bisect.bisect_left(snap_start_dts, range_start)
326
+ if idx_ge < len(parsed_snaps):
327
+ anchor_dt = parsed_snaps[idx_ge][0]
328
+ elif parsed_snaps:
329
+ anchor_dt = parsed_snaps[-1][0]
330
+ else: # unreachable given `if snap_rows:` guard, defensive
331
+ anchor_dt = range_start
332
+
333
+ # Slide anchor back to land at-or-before range_start.
334
+ current = anchor_dt
335
+ while current > range_start:
336
+ current -= dt.timedelta(days=7)
337
+ # If anchor was already far before range_start, step forward until the
338
+ # slice [current, current+7d) overlaps range_start.
339
+ while current + dt.timedelta(days=7) <= range_start:
340
+ current += dt.timedelta(days=7)
341
+
342
+ # Walk forward. For each 7-day slice overlapping the range, emit a
343
+ # SubWeek. When a slice's local start_date matches a snapshot row,
344
+ # use that row's verbatim bounds (drives snapshot-based Used % join).
345
+ # After emitting, re-anchor to the next snapshot whenever it sits
346
+ # within MAX_REANCHOR of `current`. This handles three cases:
347
+ # - normal 7d cadence (~7d ahead): matches exactly
348
+ # - day-of-week drift (Thursday → Friday cycles, ~7±1d ahead)
349
+ # - early reset (snapshot ~1d after current when Anthropic ends the
350
+ # week before the original --resets-at); the previous heuristic
351
+ # (|cand - natural_next| <= HALF_WEEK) rejected early-reset
352
+ # snapshots because they sit ~6d from the +7d natural step.
353
+ # Snapshots farther than MAX_REANCHOR represent a multi-week data
354
+ # gap; extrapolate one week and retry on the next iteration.
355
+ MAX_REANCHOR = dt.timedelta(days=10, hours=12)
356
+ while current < range_end:
357
+ end = current + dt.timedelta(days=7)
358
+ if end > range_start and current < range_end:
359
+ # internal fallback: host-local intentional
360
+ local_start = current.astimezone().date()
361
+ # Match snapshots by datetime equality against the sorted
362
+ # snap_start_dts list — keying on local_start_s (current's
363
+ # date in the *reader's* local TZ) was TZ-unsafe: snapshots
364
+ # written in another TZ whose UTC hour sits near midnight
365
+ # would flip to a different local date on a machine in a
366
+ # different TZ (travel / WSL vs. host), missing the lookup
367
+ # and relabeling the week as "extrapolated" (dropping
368
+ # Used % / $/1%). Datetime equality is TZ-invariant.
369
+ idx = bisect.bisect_left(snap_start_dts, current)
370
+ if idx < len(snap_start_dts) and snap_start_dts[idx] == current:
371
+ rec = parsed_snaps[idx]
372
+ else:
373
+ rec = None
374
+ if rec is not None:
375
+ s_dt, e_dt, s_ts, e_ts, s_date_s, e_date_s = rec
376
+ start_date_obj = dt.date.fromisoformat(s_date_s)
377
+ if e_date_s:
378
+ end_date_obj = dt.date.fromisoformat(e_date_s)
379
+ else:
380
+ end_date_obj = start_date_obj + dt.timedelta(days=6)
381
+ weeks.append(SubWeek(
382
+ start_ts=s_ts,
383
+ end_ts=e_ts,
384
+ start_date=start_date_obj,
385
+ end_date=end_date_obj,
386
+ source="snapshot",
387
+ display_start_date=start_date_obj,
388
+ ))
389
+ else:
390
+ local_end = local_start + dt.timedelta(days=6)
391
+ weeks.append(SubWeek(
392
+ start_ts=current.isoformat(timespec="seconds"),
393
+ end_ts=end.isoformat(timespec="seconds"),
394
+ start_date=local_start,
395
+ end_date=local_end,
396
+ source="extrapolated",
397
+ display_start_date=local_start,
398
+ ))
399
+
400
+ # Determine next `current`: prefer the next snapshot's start_dt
401
+ # when it sits within MAX_REANCHOR of `current` (covers normal
402
+ # cadence, drift, and early-reset weeks). Otherwise step +7d
403
+ # to emit one extrapolated week inside a multi-week data gap.
404
+ natural_next = end
405
+ snap_idx = bisect.bisect_right(snap_start_dts, current)
406
+ re_anchored = False
407
+ while snap_idx < len(snap_start_dts):
408
+ cand = snap_start_dts[snap_idx]
409
+ if cand <= current: # strictly ahead only
410
+ snap_idx += 1
411
+ continue
412
+ if (cand - current) <= MAX_REANCHOR:
413
+ current = cand
414
+ re_anchored = True
415
+ break
416
+ # Next snapshot is far ahead — keep natural step.
417
+ break
418
+ if not re_anchored:
419
+ current = natural_next
420
+
421
+ return _apply_overlap_clamp_to_subweeks(
422
+ _apply_reset_events_to_subweeks(conn, weeks)
423
+ )
424
+
425
+ # Case A2 (spec A1.6 Step 1 fallback): no usage snapshots, but a
426
+ # cost-snapshot may carry a known reset boundary. Use that anchor to
427
+ # extrapolate 7-day cycles in both directions across the range
428
+ # before falling through to calendar-week math.
429
+ # NOTE: weekly_cost_snapshots contributes timing only — cost is
430
+ # always recomputed from session_entries (see CLAUDE.md gotcha
431
+ # "weekly ignores weekly_cost_snapshots for cost").
432
+ anchor_ts = _discover_week_anchor(conn)
433
+ if anchor_ts is not None:
434
+ anchor_dt = parse_iso_datetime(anchor_ts, "week_start_at (anchor)")
435
+ # Slide anchor back by full weeks until we're at-or-before range_start.
436
+ current = anchor_dt
437
+ while current > range_start:
438
+ current -= dt.timedelta(days=7)
439
+ # If anchor was already far past range_end, current may still be
440
+ # past range_end; outer while loop handles that naturally (zero
441
+ # iterations). Conversely if anchor is before range_start we need
442
+ # to step forward to the first week overlapping the range.
443
+ while current + dt.timedelta(days=7) <= range_start:
444
+ current += dt.timedelta(days=7)
445
+ # Emit one SubWeek per 7-day slice until we pass range_end.
446
+ while current < range_end:
447
+ end = current + dt.timedelta(days=7)
448
+ # internal fallback: host-local intentional
449
+ local_start = current.astimezone().date()
450
+ local_end = local_start + dt.timedelta(days=6)
451
+ weeks.append(SubWeek(
452
+ start_ts=current.isoformat(timespec="seconds"),
453
+ end_ts=end.isoformat(timespec="seconds"),
454
+ start_date=local_start,
455
+ end_date=local_end,
456
+ source="extrapolated",
457
+ display_start_date=local_start,
458
+ ))
459
+ current = end
460
+ return _apply_overlap_clamp_to_subweeks(weeks)
461
+
462
+ # Case B: no snapshots — config-based calendar-week fallback.
463
+ config = cctally.load_config()
464
+ week_start_name = cctally.get_week_start_name(config)
465
+ week_start_idx = cctally.WEEKDAY_MAP[week_start_name]
466
+ # internal fallback: host-local intentional
467
+ local_start_date = range_start.astimezone().date()
468
+ diff = (local_start_date.weekday() - week_start_idx) % 7
469
+ current_date = local_start_date - dt.timedelta(days=diff)
470
+ # Use the IANA ZoneInfo so `datetime.combine(date, time, tzinfo=tz)`
471
+ # produces the correct historical offset per-date (handles DST
472
+ # transitions across a long range). Fall back to the fixed-offset
473
+ # snapshot on exotic platforms where IANA resolution fails.
474
+ # internal fallback: host-local intentional (datetime.now().astimezone().tzinfo)
475
+ tz = _resolve_tz(_local_tz_name()) or dt.datetime.now().astimezone().tzinfo
476
+ while True:
477
+ end_date = current_date + dt.timedelta(days=7)
478
+ start_dt = dt.datetime.combine(current_date, dt.time(0, 0), tzinfo=tz)
479
+ end_dt = dt.datetime.combine(end_date, dt.time(0, 0), tzinfo=tz)
480
+ if start_dt >= range_end:
481
+ break
482
+ if end_dt > range_start:
483
+ weeks.append(SubWeek(
484
+ start_ts=start_dt.isoformat(timespec="seconds"),
485
+ end_ts=end_dt.isoformat(timespec="seconds"),
486
+ start_date=current_date,
487
+ end_date=end_date - dt.timedelta(days=1),
488
+ source="extrapolated",
489
+ display_start_date=current_date,
490
+ ))
491
+ current_date = end_date
492
+ return _apply_overlap_clamp_to_subweeks(weeks)