cctally 1.93.0 → 1.94.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 +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
package/bin/_cctally_share.py
CHANGED
|
@@ -18,7 +18,7 @@ import subprocess
|
|
|
18
18
|
import sys
|
|
19
19
|
|
|
20
20
|
import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
|
|
21
|
-
from _lib_display_tz import format_display_dt
|
|
21
|
+
from _lib_display_tz import _resolve_tz, format_display_dt, resolve_display_tz_name
|
|
22
22
|
from _lib_fmt import stable_sum
|
|
23
23
|
from _lib_render import _project_disambiguate_labels
|
|
24
24
|
|
|
@@ -270,28 +270,68 @@ def _share_period_label(
|
|
|
270
270
|
) -> str:
|
|
271
271
|
"""Render the canonical "<start> → <end> (<tz>)" period label.
|
|
272
272
|
|
|
273
|
-
Used by
|
|
274
|
-
|
|
273
|
+
Used by every CLI share snapshot builder so the period label format
|
|
274
|
+
stays consistent across share-enabled subcommands.
|
|
275
|
+
|
|
276
|
+
FULL ISO (#503 S2 D4). It used to render `%b %d`, so a report spanning
|
|
277
|
+
2020 to 2026 read `Jan 01 → May 09` and named neither year. This
|
|
278
|
+
matches the shape `bin/_cctally_codex.py` and
|
|
279
|
+
`bin/_cctally_source_analytics.py` already use.
|
|
275
280
|
"""
|
|
276
281
|
return (
|
|
277
|
-
f"{period_start.
|
|
278
|
-
f"{period_end.
|
|
282
|
+
f"{period_start.date().isoformat()} → "
|
|
283
|
+
f"{period_end.date().isoformat()} ({display_tz_label})"
|
|
279
284
|
)
|
|
280
285
|
|
|
281
286
|
|
|
287
|
+
def _share_display_zone(tz: "ZoneInfo | None"):
|
|
288
|
+
"""The concrete zone `_share_display_tz_label` names, as a tzinfo.
|
|
289
|
+
|
|
290
|
+
`resolve_display_tz` returns `None` for the `local` configuration
|
|
291
|
+
token, which is the DEFAULT. Every site that lifts a calendar label
|
|
292
|
+
into a `PeriodSpec` boundary must anchor it in this zone, because that
|
|
293
|
+
is the zone the artifact's period is labelled with (#503 S2 D7).
|
|
294
|
+
Falls back to UTC only when the resolved name is not loadable, which
|
|
295
|
+
keeps a stale host zone from turning a render into an exception.
|
|
296
|
+
"""
|
|
297
|
+
if tz is not None:
|
|
298
|
+
return tz
|
|
299
|
+
resolved = _resolve_tz(_share_display_tz_label(None), strict_iana=False,
|
|
300
|
+
fallback=None)
|
|
301
|
+
return resolved if resolved is not None else dt.timezone.utc
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _share_ground_civil_date(d: "dt.date", tz: "ZoneInfo | None") -> dt.datetime:
|
|
305
|
+
"""Lift a calendar label to midnight IN THE ZONE the artifact names.
|
|
306
|
+
|
|
307
|
+
`2026-04-13` is a calendar label, not an instant. Lifting it to UTC
|
|
308
|
+
midnight and then converting it into `America/New_York` — which is
|
|
309
|
+
what `period_civil_dates` does for a non-`civil_bucket` period —
|
|
310
|
+
reports `2026-04-12`, a day the artifact's own table does not contain
|
|
311
|
+
(#503 S2 D7). Grounding it here keeps the boundary a real instant, so
|
|
312
|
+
the conversion is the identity and the two boundary kinds stay
|
|
313
|
+
distinguishable without a second flag on the command-line path.
|
|
314
|
+
"""
|
|
315
|
+
return dt.datetime(d.year, d.month, d.day, tzinfo=_share_display_zone(tz))
|
|
316
|
+
|
|
317
|
+
|
|
282
318
|
def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
|
|
283
319
|
"""Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
|
|
284
320
|
|
|
285
321
|
Used by the share gate sites to lift week-boundary date strings
|
|
286
|
-
(`weekStartDate`, `weekEndDate`)
|
|
287
|
-
`PeriodSpec` expects. None / empty /
|
|
288
|
-
caller already gated on a non-empty
|
|
289
|
-
so the fallback is purely defensive
|
|
322
|
+
(`weekStartDate`, `weekEndDate`) and 5-hour block date parts into the
|
|
323
|
+
tz-aware datetimes that `PeriodSpec` expects. None / empty /
|
|
324
|
+
unparseable -> current UTC; the caller already gated on a non-empty
|
|
325
|
+
trend before reaching this path, so the fallback is purely defensive
|
|
326
|
+
against missing-data corner cases.
|
|
327
|
+
|
|
328
|
+
A parsed calendar label is grounded through `_share_ground_civil_date`.
|
|
290
329
|
"""
|
|
291
330
|
if value is None:
|
|
292
331
|
return _share_now_utc()
|
|
293
332
|
if isinstance(value, dt.datetime):
|
|
294
|
-
return value if value.tzinfo
|
|
333
|
+
return (value if value.tzinfo
|
|
334
|
+
else value.replace(tzinfo=_share_display_zone(tz)))
|
|
295
335
|
if isinstance(value, dt.date):
|
|
296
336
|
d = value
|
|
297
337
|
else:
|
|
@@ -299,18 +339,23 @@ def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
|
|
|
299
339
|
d = dt.date.fromisoformat(str(value))
|
|
300
340
|
except ValueError:
|
|
301
341
|
return _share_now_utc()
|
|
302
|
-
|
|
303
|
-
return midnight.replace(tzinfo=tz or dt.timezone.utc)
|
|
342
|
+
return _share_ground_civil_date(d, tz)
|
|
304
343
|
|
|
305
344
|
|
|
306
345
|
def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
|
|
307
|
-
"""Render
|
|
346
|
+
"""Render the concrete IANA zone name for `PeriodSpec.display_tz`.
|
|
347
|
+
|
|
348
|
+
`resolve_display_tz` returns `None` for the "local" configuration
|
|
349
|
+
token (its callers then do a bare `astimezone()`). This used to map
|
|
350
|
+
that `None` to the literal string `"local"`, and every v1 share golden
|
|
351
|
+
printed `(local)` as a result — a label that names no zone, so a
|
|
352
|
+
reader could not tell which day boundary the artifact's dates use.
|
|
308
353
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
354
|
+
#503 S2 D7: resolve the token to the host's IANA zone instead. That is
|
|
355
|
+
the same zone the bare `astimezone()` produced, so the dates do not
|
|
356
|
+
move; only the label becomes truthful.
|
|
312
357
|
"""
|
|
313
|
-
return tz.key if tz is not None else "local"
|
|
358
|
+
return tz.key if tz is not None else resolve_display_tz_name("local")
|
|
314
359
|
|
|
315
360
|
|
|
316
361
|
def _build_report_snapshot(
|
|
@@ -320,8 +365,6 @@ def _build_report_snapshot(
|
|
|
320
365
|
period_end: dt.datetime,
|
|
321
366
|
display_tz: str,
|
|
322
367
|
version: str,
|
|
323
|
-
theme: str,
|
|
324
|
-
reveal_projects: bool,
|
|
325
368
|
) -> "ShareSnapshot":
|
|
326
369
|
"""Build a ShareSnapshot for `cctally report`.
|
|
327
370
|
|
|
@@ -358,7 +401,7 @@ def _build_report_snapshot(
|
|
|
358
401
|
wsd = r.week_start_date.isoformat() if r.week_start_date else None
|
|
359
402
|
if isinstance(wsd, str) and wsd:
|
|
360
403
|
try:
|
|
361
|
-
week_label = dt.date.fromisoformat(wsd).
|
|
404
|
+
week_label = dt.date.fromisoformat(wsd).isoformat()
|
|
362
405
|
except ValueError:
|
|
363
406
|
week_label = wsd
|
|
364
407
|
else:
|
|
@@ -418,15 +461,12 @@ def _build_report_snapshot(
|
|
|
418
461
|
else:
|
|
419
462
|
title = "Weekly $ / % trend — no data"
|
|
420
463
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
421
|
-
subtitle = " · ".join([
|
|
422
|
-
period_label,
|
|
423
|
-
theme,
|
|
424
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
425
|
-
])
|
|
426
464
|
return _lib_share.ShareSnapshot(
|
|
427
465
|
cmd="report",
|
|
428
466
|
title=title,
|
|
429
|
-
|
|
467
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
468
|
+
# privacy mode; the theme is dropped deliberately.
|
|
469
|
+
subtitle=None,
|
|
430
470
|
period=_lib_share.PeriodSpec(
|
|
431
471
|
start=period_start, end=period_end,
|
|
432
472
|
display_tz=display_tz, label=period_label,
|
|
@@ -444,8 +484,6 @@ def _build_daily_snapshot(
|
|
|
444
484
|
period_end: dt.datetime,
|
|
445
485
|
display_tz: str,
|
|
446
486
|
version: str,
|
|
447
|
-
theme: str,
|
|
448
|
-
reveal_projects: bool,
|
|
449
487
|
) -> "ShareSnapshot":
|
|
450
488
|
"""Build a ShareSnapshot for `cctally daily`.
|
|
451
489
|
|
|
@@ -496,7 +534,7 @@ def _build_daily_snapshot(
|
|
|
496
534
|
bucket = getattr(r, "bucket", None)
|
|
497
535
|
if isinstance(bucket, str) and bucket:
|
|
498
536
|
try:
|
|
499
|
-
date_str = dt.date.fromisoformat(bucket).
|
|
537
|
+
date_str = dt.date.fromisoformat(bucket).isoformat()
|
|
500
538
|
except ValueError:
|
|
501
539
|
date_str = bucket
|
|
502
540
|
else:
|
|
@@ -528,21 +566,18 @@ def _build_daily_snapshot(
|
|
|
528
566
|
)
|
|
529
567
|
if rows:
|
|
530
568
|
title = (
|
|
531
|
-
f"Daily usage — {period_start.
|
|
532
|
-
f"{period_end.
|
|
569
|
+
f"Daily usage — {period_start.date().isoformat()} → "
|
|
570
|
+
f"{period_end.date().isoformat()}"
|
|
533
571
|
)
|
|
534
572
|
else:
|
|
535
573
|
title = "Daily usage — no data"
|
|
536
574
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
537
|
-
subtitle = " · ".join([
|
|
538
|
-
period_label,
|
|
539
|
-
theme,
|
|
540
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
541
|
-
])
|
|
542
575
|
return _lib_share.ShareSnapshot(
|
|
543
576
|
cmd="daily",
|
|
544
577
|
title=title,
|
|
545
|
-
|
|
578
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
579
|
+
# privacy mode; the theme is dropped deliberately.
|
|
580
|
+
subtitle=None,
|
|
546
581
|
period=_lib_share.PeriodSpec(
|
|
547
582
|
start=period_start, end=period_end,
|
|
548
583
|
display_tz=display_tz, label=period_label,
|
|
@@ -560,8 +595,6 @@ def _build_monthly_snapshot(
|
|
|
560
595
|
period_end: dt.datetime,
|
|
561
596
|
display_tz: str,
|
|
562
597
|
version: str,
|
|
563
|
-
theme: str,
|
|
564
|
-
reveal_projects: bool,
|
|
565
598
|
) -> "ShareSnapshot":
|
|
566
599
|
"""Build a ShareSnapshot for `cctally monthly`.
|
|
567
600
|
|
|
@@ -646,15 +679,12 @@ def _build_monthly_snapshot(
|
|
|
646
679
|
f"{period_start.strftime('%Y-%m')} → "
|
|
647
680
|
f"{period_end.strftime('%Y-%m')} ({display_tz})"
|
|
648
681
|
)
|
|
649
|
-
subtitle = " · ".join([
|
|
650
|
-
period_label,
|
|
651
|
-
theme,
|
|
652
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
653
|
-
])
|
|
654
682
|
return _lib_share.ShareSnapshot(
|
|
655
683
|
cmd="monthly",
|
|
656
684
|
title=title,
|
|
657
|
-
|
|
685
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
686
|
+
# privacy mode; the theme is dropped deliberately.
|
|
687
|
+
subtitle=None,
|
|
658
688
|
period=_lib_share.PeriodSpec(
|
|
659
689
|
start=period_start, end=period_end,
|
|
660
690
|
display_tz=display_tz, label=period_label,
|
|
@@ -672,8 +702,6 @@ def _build_weekly_snapshot(
|
|
|
672
702
|
period_end: dt.datetime,
|
|
673
703
|
display_tz: str,
|
|
674
704
|
version: str,
|
|
675
|
-
theme: str,
|
|
676
|
-
reveal_projects: bool,
|
|
677
705
|
breakdown_model: bool,
|
|
678
706
|
) -> "ShareSnapshot":
|
|
679
707
|
"""Build a ShareSnapshot for `cctally weekly`.
|
|
@@ -759,7 +787,7 @@ def _build_weekly_snapshot(
|
|
|
759
787
|
bucket = getattr(r, "bucket", None)
|
|
760
788
|
if isinstance(bucket, str) and bucket:
|
|
761
789
|
try:
|
|
762
|
-
week_label = dt.date.fromisoformat(bucket).
|
|
790
|
+
week_label = dt.date.fromisoformat(bucket).isoformat()
|
|
763
791
|
except ValueError:
|
|
764
792
|
week_label = bucket
|
|
765
793
|
else:
|
|
@@ -830,15 +858,12 @@ def _build_weekly_snapshot(
|
|
|
830
858
|
else "Weekly usage — no data"
|
|
831
859
|
)
|
|
832
860
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
833
|
-
subtitle = " · ".join([
|
|
834
|
-
period_label,
|
|
835
|
-
theme,
|
|
836
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
837
|
-
])
|
|
838
861
|
return _lib_share.ShareSnapshot(
|
|
839
862
|
cmd="weekly",
|
|
840
863
|
title=title,
|
|
841
|
-
|
|
864
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
865
|
+
# privacy mode; the theme is dropped deliberately.
|
|
866
|
+
subtitle=None,
|
|
842
867
|
period=_lib_share.PeriodSpec(
|
|
843
868
|
start=period_start, end=period_end,
|
|
844
869
|
display_tz=display_tz, label=period_label,
|
|
@@ -855,8 +880,6 @@ def _build_forecast_snapshot(
|
|
|
855
880
|
week_end: dt.datetime,
|
|
856
881
|
display_tz: str,
|
|
857
882
|
version: str,
|
|
858
|
-
theme: str,
|
|
859
|
-
reveal_projects: bool,
|
|
860
883
|
actual_series: list[tuple[str, float, float]],
|
|
861
884
|
projected_series: list[tuple[str, float, float]],
|
|
862
885
|
current_pct: float,
|
|
@@ -978,21 +1001,18 @@ def _build_forecast_snapshot(
|
|
|
978
1001
|
("LOW CONF — data thin",) if low_conf else ()
|
|
979
1002
|
)
|
|
980
1003
|
if actual_pts:
|
|
981
|
-
title = f"Forecast — week of {week_start.
|
|
1004
|
+
title = f"Forecast — week of {week_start.date().isoformat()}"
|
|
982
1005
|
else:
|
|
983
1006
|
title = "Forecast — no data"
|
|
984
1007
|
# Reuse the shared period-label helper so forecast's subtitle period
|
|
985
1008
|
# format matches sibling builders (cmd_daily / cmd_project / etc.).
|
|
986
1009
|
period_label = _share_period_label(week_start, week_end, display_tz)
|
|
987
|
-
subtitle = " · ".join([
|
|
988
|
-
period_label,
|
|
989
|
-
theme,
|
|
990
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
991
|
-
])
|
|
992
1010
|
return _lib_share.ShareSnapshot(
|
|
993
1011
|
cmd="forecast",
|
|
994
1012
|
title=title,
|
|
995
|
-
|
|
1013
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
1014
|
+
# privacy mode; the theme is dropped deliberately.
|
|
1015
|
+
subtitle=None,
|
|
996
1016
|
period=_lib_share.PeriodSpec(
|
|
997
1017
|
start=week_start, end=week_end,
|
|
998
1018
|
display_tz=display_tz, label=period_label,
|
|
@@ -1010,8 +1030,6 @@ def _build_project_snapshot(
|
|
|
1010
1030
|
period_end: dt.datetime,
|
|
1011
1031
|
display_tz: str,
|
|
1012
1032
|
version: str,
|
|
1013
|
-
theme: str,
|
|
1014
|
-
reveal_projects: bool,
|
|
1015
1033
|
) -> "ShareSnapshot":
|
|
1016
1034
|
"""Build a ShareSnapshot for `cctally project`.
|
|
1017
1035
|
|
|
@@ -1155,21 +1173,18 @@ def _build_project_snapshot(
|
|
|
1155
1173
|
)
|
|
1156
1174
|
if rows:
|
|
1157
1175
|
title = (
|
|
1158
|
-
f"Per-project usage — {period_start.
|
|
1159
|
-
f"{period_end.
|
|
1176
|
+
f"Per-project usage — {period_start.date().isoformat()} → "
|
|
1177
|
+
f"{period_end.date().isoformat()}"
|
|
1160
1178
|
)
|
|
1161
1179
|
else:
|
|
1162
1180
|
title = "Per-project usage — no data"
|
|
1163
1181
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
1164
|
-
subtitle = " · ".join([
|
|
1165
|
-
period_label,
|
|
1166
|
-
theme,
|
|
1167
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
1168
|
-
])
|
|
1169
1182
|
return _lib_share.ShareSnapshot(
|
|
1170
1183
|
cmd="project",
|
|
1171
1184
|
title=title,
|
|
1172
|
-
|
|
1185
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
1186
|
+
# privacy mode; the theme is dropped deliberately.
|
|
1187
|
+
subtitle=None,
|
|
1173
1188
|
period=_lib_share.PeriodSpec(
|
|
1174
1189
|
start=period_start, end=period_end,
|
|
1175
1190
|
display_tz=display_tz, label=period_label,
|
|
@@ -1187,8 +1202,6 @@ def _build_five_hour_blocks_snapshot(
|
|
|
1187
1202
|
period_end: dt.datetime,
|
|
1188
1203
|
display_tz: str,
|
|
1189
1204
|
version: str,
|
|
1190
|
-
theme: str,
|
|
1191
|
-
reveal_projects: bool,
|
|
1192
1205
|
tz: "ZoneInfo | None",
|
|
1193
1206
|
) -> "ShareSnapshot":
|
|
1194
1207
|
"""Build a ShareSnapshot for `cctally five-hour-blocks`.
|
|
@@ -1277,7 +1290,7 @@ def _build_five_hour_blocks_snapshot(
|
|
|
1277
1290
|
block_iso.replace("Z", "+00:00")
|
|
1278
1291
|
)
|
|
1279
1292
|
block_lbl = format_display_dt(
|
|
1280
|
-
parsed, tz, fmt="%
|
|
1293
|
+
parsed, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
|
|
1281
1294
|
)
|
|
1282
1295
|
except (ValueError, AttributeError):
|
|
1283
1296
|
block_lbl = str(block_iso)
|
|
@@ -1343,15 +1356,12 @@ def _build_five_hour_blocks_snapshot(
|
|
|
1343
1356
|
else:
|
|
1344
1357
|
title = "5-hour blocks — no data"
|
|
1345
1358
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
1346
|
-
subtitle = " · ".join([
|
|
1347
|
-
period_label,
|
|
1348
|
-
theme,
|
|
1349
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
1350
|
-
])
|
|
1351
1359
|
return _lib_share.ShareSnapshot(
|
|
1352
1360
|
cmd="five-hour-blocks",
|
|
1353
1361
|
title=title,
|
|
1354
|
-
|
|
1362
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
1363
|
+
# privacy mode; the theme is dropped deliberately.
|
|
1364
|
+
subtitle=None,
|
|
1355
1365
|
period=_lib_share.PeriodSpec(
|
|
1356
1366
|
start=period_start, end=period_end,
|
|
1357
1367
|
display_tz=display_tz, label=period_label,
|
|
@@ -1426,8 +1436,6 @@ def _build_session_snapshot(
|
|
|
1426
1436
|
period_end: dt.datetime,
|
|
1427
1437
|
display_tz: str,
|
|
1428
1438
|
version: str,
|
|
1429
|
-
theme: str,
|
|
1430
|
-
reveal_projects: bool,
|
|
1431
1439
|
top_n: int | None,
|
|
1432
1440
|
tz: "ZoneInfo | None",
|
|
1433
1441
|
) -> "ShareSnapshot":
|
|
@@ -1573,21 +1581,18 @@ def _build_session_snapshot(
|
|
|
1573
1581
|
title = f"Top {len(snap_rows)} sessions"
|
|
1574
1582
|
else:
|
|
1575
1583
|
title = (
|
|
1576
|
-
f"Sessions — {period_start.
|
|
1577
|
-
f"{period_end.
|
|
1584
|
+
f"Sessions — {period_start.date().isoformat()} → "
|
|
1585
|
+
f"{period_end.date().isoformat()}"
|
|
1578
1586
|
)
|
|
1579
1587
|
else:
|
|
1580
1588
|
title = "Sessions — no data"
|
|
1581
1589
|
period_label = _share_period_label(period_start, period_end, display_tz)
|
|
1582
|
-
subtitle = " · ".join([
|
|
1583
|
-
period_label,
|
|
1584
|
-
theme,
|
|
1585
|
-
"real projects" if reveal_projects else "projects anonymized",
|
|
1586
|
-
])
|
|
1587
1590
|
return _lib_share.ShareSnapshot(
|
|
1588
1591
|
cmd="session",
|
|
1589
1592
|
title=title,
|
|
1590
|
-
|
|
1593
|
+
# #503 S2 D5 — the facts strip states the period and the
|
|
1594
|
+
# privacy mode; the theme is dropped deliberately.
|
|
1595
|
+
subtitle=None,
|
|
1591
1596
|
period=_lib_share.PeriodSpec(
|
|
1592
1597
|
start=period_start, end=period_end,
|
|
1593
1598
|
display_tz=display_tz, label=period_label,
|
|
@@ -1308,7 +1308,15 @@ def _share_int(value: object, default: int = 0) -> int:
|
|
|
1308
1308
|
def _claude_project_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
|
|
1309
1309
|
"""Adapt the established ``project`` JSON payload without Codex field guesses."""
|
|
1310
1310
|
columns = (
|
|
1311
|
-
|
|
1311
|
+
# NO `kind="project"` (#503 S2 F4). That flag declares the
|
|
1312
|
+
# column LABEL to be a project display name — true for the
|
|
1313
|
+
# cross-tab Detail templates, whose headers are project names,
|
|
1314
|
+
# and false here, where the label is the literal header word.
|
|
1315
|
+
# With it, the anonymizer rewrote the header itself, so the
|
|
1316
|
+
# column ended up titled `project-1` and the artifact
|
|
1317
|
+
# misstated what it was. The CELLS are `ProjectCell`s and are
|
|
1318
|
+
# anonymized through the cell site, which is the correct one.
|
|
1319
|
+
lib.ColumnSpec(key="project", label="Project"),
|
|
1312
1320
|
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1313
1321
|
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1314
1322
|
)
|
|
@@ -1516,7 +1524,9 @@ def _claude_report_share_rows(data: dict[str, object], lib) -> tuple[tuple, tupl
|
|
|
1516
1524
|
continue
|
|
1517
1525
|
date_value = str(payload_row.get("weekStartDate", "—"))
|
|
1518
1526
|
try:
|
|
1519
|
-
|
|
1527
|
+
# Full ISO (#503 S2 D4/F15): a `%b %d` week cell names no year,
|
|
1528
|
+
# so a multi-year artifact is ambiguous.
|
|
1529
|
+
display_week = dt.date.fromisoformat(date_value).isoformat()
|
|
1520
1530
|
except ValueError:
|
|
1521
1531
|
display_week = date_value
|
|
1522
1532
|
used = payload_row.get("weeklyPercent")
|
|
@@ -1562,7 +1572,8 @@ def _source_share_rows(command: str, result: SourceResult, lib) -> tuple[tuple,
|
|
|
1562
1572
|
|
|
1563
1573
|
if command == "project":
|
|
1564
1574
|
columns = (
|
|
1565
|
-
|
|
1575
|
+
# No `kind="project"` — see `_claude_project_share_rows`.
|
|
1576
|
+
lib.ColumnSpec(key="project", label="Project"),
|
|
1566
1577
|
lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
|
|
1567
1578
|
lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
|
|
1568
1579
|
)
|
|
@@ -1687,13 +1698,18 @@ def build_source_share_snapshot(
|
|
|
1687
1698
|
"empty" if source_result.status == "empty" else "ok"
|
|
1688
1699
|
)
|
|
1689
1700
|
source_label = "Claude" if source_result.source == "claude" else "Codex"
|
|
1690
|
-
|
|
1701
|
+
# The all-source CLI path has no display-tz plumbing; its bounds are
|
|
1702
|
+
# UTC instants, so `Etc/UTC` is both the honest and the concrete IANA
|
|
1703
|
+
# name for them (#503 S2 D7).
|
|
1704
|
+
period_label = f"{start.date().isoformat()} → {end.date().isoformat()} (Etc/UTC)"
|
|
1691
1705
|
notes = tuple(warning.message for warning in source_result.warnings if source_result.status != "unavailable")
|
|
1692
1706
|
return lib.ShareSnapshot(
|
|
1693
1707
|
cmd=command,
|
|
1694
1708
|
title=_TERMINAL_TITLES[command],
|
|
1695
|
-
|
|
1696
|
-
|
|
1709
|
+
# #503 S2 D5 — the facts strip states this period, so a
|
|
1710
|
+
# subtitle repeating it is duplicate chrome.
|
|
1711
|
+
subtitle=None,
|
|
1712
|
+
period=lib.PeriodSpec(start=start, end=end, display_tz="Etc/UTC", label=period_label),
|
|
1697
1713
|
columns=columns,
|
|
1698
1714
|
rows=rows,
|
|
1699
1715
|
chart=None,
|
|
@@ -1927,16 +1943,28 @@ def _emit_source_share(
|
|
|
1927
1943
|
lib.ComposedSection(snap=claude_snap, drift_detected=False),
|
|
1928
1944
|
lib.ComposedSection(snap=codex_snap, drift_detected=False),
|
|
1929
1945
|
)
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1946
|
+
# A privacy refusal is a MESSAGE and exit 3, exactly as on the
|
|
1947
|
+
# single-source path (`_cctally_share._share_render_and_emit`).
|
|
1948
|
+
# Without this catch the same `SharePrivacyViolation` reached the
|
|
1949
|
+
# generic handler in `bin/cctally` and became exit 1, so `share.md`
|
|
1950
|
+
# would have documented an exit code one of the two paths did not
|
|
1951
|
+
# produce (#503 S2 F19). The refusal precedes destination
|
|
1952
|
+
# resolution, so nothing is written.
|
|
1953
|
+
try:
|
|
1954
|
+
content = lib.compose(
|
|
1955
|
+
sections,
|
|
1956
|
+
opts=lib.ComposeOptions(
|
|
1957
|
+
title=f"{_TERMINAL_TITLES[command]} — Claude + Codex",
|
|
1958
|
+
theme=getattr(args, "theme", "light"),
|
|
1959
|
+
format=args.format,
|
|
1960
|
+
no_branding=bool(getattr(args, "no_branding", False)),
|
|
1961
|
+
reveal_projects=reveal_projects,
|
|
1962
|
+
),
|
|
1963
|
+
)
|
|
1964
|
+
except lib.SharePrivacyViolation as exc:
|
|
1965
|
+
print(f"cctally: refused to write a share artifact — {exc}",
|
|
1966
|
+
file=sys.stderr)
|
|
1967
|
+
sys.exit(3)
|
|
1940
1968
|
utc_date = codex_snap.generated_at.astimezone(UTC).strftime("%Y-%m-%d")
|
|
1941
1969
|
kind, value = c._resolve_destination(args, cmd=command, generated_at_utc_date=utc_date)
|
|
1942
1970
|
c._emit(content, kind=kind, value=value)
|