cctally 1.95.4 → 1.96.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 +71 -0
- package/README.md +4 -5
- package/bin/_cctally_config.py +46 -22
- package/bin/_cctally_core.py +125 -42
- package/bin/_cctally_dashboard.py +258 -100
- package/bin/_cctally_dashboard_cache_report.py +24 -1
- package/bin/_cctally_dashboard_envelope.py +11 -0
- package/bin/_cctally_dashboard_share.py +38 -4
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_journal.py +272 -20
- package/bin/_cctally_parser.py +14 -3
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_reporting.py +12 -8
- package/bin/_cctally_share.py +86 -12
- package/bin/_cctally_store.py +26 -21
- package/bin/_cctally_tui.py +11 -5
- package/bin/_lib_dashboard_settings_contract.py +86 -0
- package/bin/_lib_journal.py +4 -2
- package/bin/_lib_render.py +47 -20
- package/bin/_lib_share.py +191 -48
- package/bin/cctally +5 -1
- package/dashboard/static/assets/index-Bhr5gZ14.js +97 -0
- package/dashboard/static/assets/index-DfN_fsLZ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BvCbpJJA.css +0 -1
- package/dashboard/static/assets/index-CRZMxeYI.js +0 -97
package/bin/_lib_share.py
CHANGED
|
@@ -17,7 +17,7 @@ import re
|
|
|
17
17
|
import unicodedata
|
|
18
18
|
from collections.abc import Callable, Mapping, Sequence
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
|
-
from datetime import datetime, timezone
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
21
21
|
from typing import Literal
|
|
22
22
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
23
23
|
|
|
@@ -122,12 +122,19 @@ class PeriodSpec:
|
|
|
122
122
|
`display_tz`. True means they are ALREADY civil calendar labels — a
|
|
123
123
|
`daily` bucket named `2026-05-04`, lifted to a UTC-midnight sentinel —
|
|
124
124
|
and converting one shifts it by a day in every zone west of UTC.
|
|
125
|
+
|
|
126
|
+
`stated_*_date` are effective-rendering overrides, not builder inputs.
|
|
127
|
+
They let the provenance strip retain the civil dates found in rendered
|
|
128
|
+
text when an absolute frontmatter bound must extend across a timezone
|
|
129
|
+
boundary to contain a displayed UTC instant (#528).
|
|
125
130
|
"""
|
|
126
131
|
start: datetime
|
|
127
132
|
end: datetime
|
|
128
133
|
display_tz: str
|
|
129
134
|
label: str
|
|
130
135
|
civil_bucket: bool = False
|
|
136
|
+
stated_start_date: str | None = None
|
|
137
|
+
stated_end_date: str | None = None
|
|
131
138
|
|
|
132
139
|
|
|
133
140
|
def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
|
|
@@ -138,7 +145,8 @@ def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
|
|
|
138
145
|
builder sets a SEMANTIC label (`This week`, `Last 8 weeks`, `Recent
|
|
139
146
|
sessions`), and none of them names a date.
|
|
140
147
|
|
|
141
|
-
A `civil_bucket` period
|
|
148
|
+
Effective-rendering overrides win when present. A `civil_bucket` period
|
|
149
|
+
is otherwise returned verbatim, because its boundaries
|
|
142
150
|
are already calendar labels; anything else is converted into the
|
|
143
151
|
labelled zone, because its boundaries are instants.
|
|
144
152
|
|
|
@@ -148,13 +156,20 @@ def period_civil_dates(period: PeriodSpec) -> tuple[str, str]:
|
|
|
148
156
|
date rather than an outage.
|
|
149
157
|
"""
|
|
150
158
|
if period.civil_bucket:
|
|
151
|
-
|
|
159
|
+
derived = (period.start.date().isoformat(),
|
|
160
|
+
period.end.date().isoformat())
|
|
161
|
+
return (period.stated_start_date or derived[0],
|
|
162
|
+
period.stated_end_date or derived[1])
|
|
152
163
|
try:
|
|
153
164
|
zone = ZoneInfo(period.display_tz)
|
|
154
165
|
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
derived = (period.start.date().isoformat(),
|
|
167
|
+
period.end.date().isoformat())
|
|
168
|
+
else:
|
|
169
|
+
derived = (period.start.astimezone(zone).date().isoformat(),
|
|
170
|
+
period.end.astimezone(zone).date().isoformat())
|
|
171
|
+
return (period.stated_start_date or derived[0],
|
|
172
|
+
period.stated_end_date or derived[1])
|
|
158
173
|
|
|
159
174
|
|
|
160
175
|
# --- Chart primitives ---
|
|
@@ -510,6 +525,13 @@ _HBAR_LABEL_PAD = 4.0 # gap between a gutter label and its bar
|
|
|
510
525
|
_HBAR_VALUE_PAD = 4.0 # gap between a bar's end and its value label
|
|
511
526
|
_HBAR_LABEL_FONT = 11.0
|
|
512
527
|
_HBAR_VALUE_FONT = 10.0
|
|
528
|
+
_HBAR_VERTICAL_PADDING = 12.0
|
|
529
|
+
_HBAR_COMFORTABLE_ROW_PITCH = 14.0
|
|
530
|
+
|
|
531
|
+
_STACKED_LEGEND_TOP_PAD = 4.0
|
|
532
|
+
_STACKED_LEGEND_ROW_H = 12.0
|
|
533
|
+
_STACKED_LEGEND_BOTTOM_PAD = 4.0
|
|
534
|
+
_STACKED_PLOT_MIN_H = 80.0
|
|
513
535
|
|
|
514
536
|
# Distance from the plot's left edge back to the end-anchored y-axis
|
|
515
537
|
# label. With no reservation for the label's own width, `projected %`
|
|
@@ -576,6 +598,17 @@ def _hbar_right_reserve(points) -> float:
|
|
|
576
598
|
return _HBAR_VALUE_PAD + widest + _HBAR_RIGHT_PAD
|
|
577
599
|
|
|
578
600
|
|
|
601
|
+
def _stacked_legend_height(series_count: int) -> float:
|
|
602
|
+
"""Height reserved above stacked bars for their model key."""
|
|
603
|
+
if series_count <= 0:
|
|
604
|
+
return 0.0
|
|
605
|
+
return (
|
|
606
|
+
_STACKED_LEGEND_TOP_PAD
|
|
607
|
+
+ series_count * _STACKED_LEGEND_ROW_H
|
|
608
|
+
+ _STACKED_LEGEND_BOTTOM_PAD
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
579
612
|
def chart_required_width(chart: "ChartSpec | None", *,
|
|
580
613
|
nominal_width: float) -> float:
|
|
581
614
|
"""The canvas width this chart needs so no label leaves the plot.
|
|
@@ -808,16 +841,21 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
|
|
|
808
841
|
if not pts:
|
|
809
842
|
return _render_chart_no_data(palette, x=x, y=y, width=width, height=height)
|
|
810
843
|
|
|
811
|
-
n = len(pts)
|
|
812
|
-
bar_gap = 4.0
|
|
813
|
-
total_gap = bar_gap * (n - 1) if n > 1 else 0.0
|
|
814
|
-
bar_w = max(2.0, (iw - total_gap) / n)
|
|
815
|
-
|
|
816
844
|
has_stacks = bool(chart.stacks)
|
|
817
845
|
# Sorted keys give deterministic stack ordering; matches the
|
|
818
846
|
# `sorted(all_model_keys)` ordering builders use for table columns,
|
|
819
847
|
# so legend swatch -> table column line up by position.
|
|
820
848
|
series_keys = sorted(chart.stacks.keys()) if has_stacks else []
|
|
849
|
+
legend_iy = iy
|
|
850
|
+
if has_stacks:
|
|
851
|
+
legend_h = _stacked_legend_height(len(series_keys))
|
|
852
|
+
iy += legend_h
|
|
853
|
+
ih -= legend_h
|
|
854
|
+
|
|
855
|
+
n = len(pts)
|
|
856
|
+
bar_gap = 4.0
|
|
857
|
+
total_gap = bar_gap * (n - 1) if n > 1 else 0.0
|
|
858
|
+
bar_w = max(2.0, (iw - total_gap) / n)
|
|
821
859
|
|
|
822
860
|
if has_stacks:
|
|
823
861
|
per_bar_totals: list[float] = []
|
|
@@ -877,18 +915,19 @@ def _render_bar_chart_svg(chart: BarChart, *, palette: dict,
|
|
|
877
915
|
font_size=10, fill=palette["muted"],
|
|
878
916
|
anchor="middle"))
|
|
879
917
|
|
|
880
|
-
# Legend (top-right of
|
|
881
|
-
# SVG is the only artifact where the table
|
|
882
|
-
# the legend
|
|
883
|
-
# inner box so total chart dimensions stay byte-stable.
|
|
918
|
+
# Legend (top-right of a band reserved above the plot when stacks are
|
|
919
|
+
# present). SVG is the only artifact where the table does not double as a
|
|
920
|
+
# key, so the legend must remain legible even when a bar reaches y_max.
|
|
884
921
|
if has_stacks:
|
|
885
922
|
legend_swatch_w = 8.0
|
|
886
923
|
legend_swatch_h = 8.0
|
|
887
|
-
legend_row_h = 12.0
|
|
888
924
|
legend_col_w = 160.0
|
|
889
925
|
legend_left = ix + iw - legend_col_w
|
|
890
926
|
for k_idx, k in enumerate(series_keys):
|
|
891
|
-
row_y =
|
|
927
|
+
row_y = (
|
|
928
|
+
legend_iy + _STACKED_LEGEND_TOP_PAD
|
|
929
|
+
+ k_idx * _STACKED_LEGEND_ROW_H
|
|
930
|
+
)
|
|
892
931
|
color = series_palette[k_idx % len(series_palette)]
|
|
893
932
|
elements.append(svg_rect(
|
|
894
933
|
legend_left, row_y, legend_swatch_w, legend_swatch_h,
|
|
@@ -2271,6 +2310,10 @@ _DISPLAYED_DATE_RE = re.compile(
|
|
|
2271
2310
|
r"^(\d{4}-\d{2})(-\d{2})?"
|
|
2272
2311
|
r"(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$")
|
|
2273
2312
|
|
|
2313
|
+
_DISPLAYED_INSTANT_RE = re.compile(
|
|
2314
|
+
r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}"
|
|
2315
|
+
r"(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$")
|
|
2316
|
+
|
|
2274
2317
|
|
|
2275
2318
|
def _displayed_date_token(text: str) -> "str | None":
|
|
2276
2319
|
"""`YYYY-MM` or `YYYY-MM-DD` when `text` is entirely a date."""
|
|
@@ -2280,6 +2323,22 @@ def _displayed_date_token(text: str) -> "str | None":
|
|
|
2280
2323
|
return match.group(1) + (match.group(2) or "")
|
|
2281
2324
|
|
|
2282
2325
|
|
|
2326
|
+
def _displayed_instant(text: str) -> "datetime | None":
|
|
2327
|
+
"""An aware datetime when the whole rendered value is an ISO instant."""
|
|
2328
|
+
raw = text.strip()
|
|
2329
|
+
if _DISPLAYED_INSTANT_RE.match(raw) is None:
|
|
2330
|
+
return None
|
|
2331
|
+
if raw.endswith("Z"):
|
|
2332
|
+
raw = raw[:-1] + "+00:00"
|
|
2333
|
+
try:
|
|
2334
|
+
value = datetime.fromisoformat(raw)
|
|
2335
|
+
except ValueError:
|
|
2336
|
+
return None
|
|
2337
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
2338
|
+
return None
|
|
2339
|
+
return value
|
|
2340
|
+
|
|
2341
|
+
|
|
2283
2342
|
def _chart_points(chart) -> list:
|
|
2284
2343
|
"""Every point a chart draws — primary series, rays and stacks.
|
|
2285
2344
|
|
|
@@ -2311,8 +2370,24 @@ def _chart_points(chart) -> list:
|
|
|
2311
2370
|
return points
|
|
2312
2371
|
|
|
2313
2372
|
|
|
2373
|
+
def _displayed_temporal_texts(snap: ShareSnapshot, *, shows_chart: bool,
|
|
2374
|
+
shows_table: bool) -> list[str]:
|
|
2375
|
+
"""Rendered table/chart strings that can carry dates or instants."""
|
|
2376
|
+
values: list[str] = []
|
|
2377
|
+
if shows_table and _has_table(snap):
|
|
2378
|
+
values.extend(column.label or "" for column in snap.columns)
|
|
2379
|
+
for row in snap.rows:
|
|
2380
|
+
for column in snap.columns:
|
|
2381
|
+
cell = row.cells.get(column.key)
|
|
2382
|
+
if cell is not None:
|
|
2383
|
+
values.append(_render_cell_text(cell))
|
|
2384
|
+
if shows_chart:
|
|
2385
|
+
values.extend(point.x_label or "" for point in _chart_points(snap.chart))
|
|
2386
|
+
return values
|
|
2387
|
+
|
|
2388
|
+
|
|
2314
2389
|
def displayed_dates(snap: ShareSnapshot, *, shows_chart: bool,
|
|
2315
|
-
shows_table: bool) -> list:
|
|
2390
|
+
shows_table: bool) -> list[str]:
|
|
2316
2391
|
"""Every date this rendering DISPLAYS, as a `YYYY-MM[-DD]` token.
|
|
2317
2392
|
|
|
2318
2393
|
Read from the text the renderers themselves print — `_render_cell_text`
|
|
@@ -2325,26 +2400,17 @@ def displayed_dates(snap: ShareSnapshot, *, shows_chart: bool,
|
|
|
2325
2400
|
Only cells reachable through `snap.columns` are visited, because only
|
|
2326
2401
|
those are rendered.
|
|
2327
2402
|
"""
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
token = _displayed_date_token(_render_cell_text(cell))
|
|
2340
|
-
if token:
|
|
2341
|
-
tokens.append(token)
|
|
2342
|
-
if shows_chart:
|
|
2343
|
-
for point in _chart_points(snap.chart):
|
|
2344
|
-
token = _displayed_date_token(point.x_label or "")
|
|
2345
|
-
if token:
|
|
2346
|
-
tokens.append(token)
|
|
2347
|
-
return tokens
|
|
2403
|
+
return [token for text in _displayed_temporal_texts(
|
|
2404
|
+
snap, shows_chart=shows_chart, shows_table=shows_table)
|
|
2405
|
+
if (token := _displayed_date_token(text)) is not None]
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def displayed_instants(snap: ShareSnapshot, *, shows_chart: bool,
|
|
2409
|
+
shows_table: bool) -> list[datetime]:
|
|
2410
|
+
"""Every absolute ISO instant this rendering displays verbatim."""
|
|
2411
|
+
return [instant for text in _displayed_temporal_texts(
|
|
2412
|
+
snap, shows_chart=shows_chart, shows_table=shows_table)
|
|
2413
|
+
if (instant := _displayed_instant(text)) is not None]
|
|
2348
2414
|
|
|
2349
2415
|
|
|
2350
2416
|
def _period_boundary_at(period: PeriodSpec, iso_date: str, *,
|
|
@@ -2429,22 +2495,40 @@ def effective_period(snap: ShareSnapshot, *, shows_chart: bool,
|
|
|
2429
2495
|
period = snap.period
|
|
2430
2496
|
tokens = displayed_dates(snap, shows_chart=shows_chart,
|
|
2431
2497
|
shows_table=shows_table)
|
|
2432
|
-
|
|
2498
|
+
instants = (displayed_instants(snap, shows_chart=shows_chart,
|
|
2499
|
+
shows_table=shows_table)
|
|
2500
|
+
if not period.civil_bucket else [])
|
|
2501
|
+
if not tokens and not instants:
|
|
2433
2502
|
return period
|
|
2434
2503
|
start_civil, end_civil = period_civil_dates(period)
|
|
2435
2504
|
# Compared at the token's OWN precision, so a `2026-05` month bucket
|
|
2436
2505
|
# is covered by any period whose bounds fall in that month.
|
|
2437
2506
|
below = [t for t in tokens if t < start_civil[:len(t)]]
|
|
2438
2507
|
above = [t for t in tokens if t > end_civil[:len(t)]]
|
|
2439
|
-
|
|
2440
|
-
|
|
2508
|
+
stated_start = (_whole_date(min(below), side="start")
|
|
2509
|
+
if below else start_civil)
|
|
2510
|
+
stated_end = (_whole_date(max(above), side="end")
|
|
2511
|
+
if above else end_civil)
|
|
2441
2512
|
start = (_period_boundary_at(period, _whole_date(min(below), side="start"),
|
|
2442
2513
|
side="start")
|
|
2443
2514
|
if below else period.start)
|
|
2444
2515
|
end = (_period_boundary_at(period, _whole_date(max(above), side="end"),
|
|
2445
2516
|
side="end")
|
|
2446
2517
|
if above else period.end)
|
|
2447
|
-
|
|
2518
|
+
if instants:
|
|
2519
|
+
start = min(start, min(instants))
|
|
2520
|
+
end = max(end, max(instants))
|
|
2521
|
+
# Frontmatter is intentionally second-precision. Its formatter
|
|
2522
|
+
# floors microseconds, which is safe for a start but would move an
|
|
2523
|
+
# end before a displayed fractional-second instant. Ceiling the
|
|
2524
|
+
# effective end to the next representable serialized second.
|
|
2525
|
+
if end.microsecond:
|
|
2526
|
+
end = end.replace(microsecond=0) + timedelta(seconds=1)
|
|
2527
|
+
if start == period.start and end == period.end:
|
|
2528
|
+
return period
|
|
2529
|
+
return dataclasses.replace(
|
|
2530
|
+
period, start=start, end=end,
|
|
2531
|
+
stated_start_date=stated_start, stated_end_date=stated_end)
|
|
2448
2532
|
|
|
2449
2533
|
|
|
2450
2534
|
def _md_effective_period(snap: ShareSnapshot) -> PeriodSpec:
|
|
@@ -2675,9 +2759,45 @@ _SVG_SECTION_GAP = 20.0
|
|
|
2675
2759
|
# 48px band) and footer (10pt at a +18 baseline inside a 30px band).
|
|
2676
2760
|
_SVG_COMPOSITE_HEADER_H = 48.0
|
|
2677
2761
|
_SVG_COMPOSITE_TITLE_BASELINE = 30.0
|
|
2762
|
+
_SVG_COMPOSITE_ALIAS_SCOPE_EXTRA_H = 20.0
|
|
2763
|
+
_SVG_COMPOSITE_ALIAS_SCOPE_BASELINE = 52.0
|
|
2678
2764
|
_SVG_COMPOSITE_FOOTER_H = 30.0
|
|
2679
2765
|
_SVG_COMPOSITE_FOOTER_BASELINE = 18.0
|
|
2680
2766
|
|
|
2767
|
+
_COMPOSITE_ALIAS_SCOPE_TEXT = "Project aliases are shared across sections."
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
def chart_required_height(chart: "ChartSpec | None", *,
|
|
2771
|
+
nominal_height: float) -> float:
|
|
2772
|
+
"""Return the chart slot height needed for non-crowded chart content.
|
|
2773
|
+
|
|
2774
|
+
The fixed 220px slot remains byte-stable for the historical hbar estate,
|
|
2775
|
+
including the CLI's 15-row cap. Detail templates may intentionally carry
|
|
2776
|
+
more rows, so once the nominal slot's rounded comfortable capacity is
|
|
2777
|
+
exceeded the canvas grows instead of compressing 11pt labels together.
|
|
2778
|
+
Stacked bars likewise keep a minimum plot below their reserved legend band.
|
|
2779
|
+
"""
|
|
2780
|
+
if isinstance(chart, HorizontalBarChart):
|
|
2781
|
+
rows = len(_hbar_visible_points(chart))
|
|
2782
|
+
nominal_capacity = math.ceil(
|
|
2783
|
+
max(0.0, nominal_height - _HBAR_VERTICAL_PADDING)
|
|
2784
|
+
/ _HBAR_COMFORTABLE_ROW_PITCH
|
|
2785
|
+
)
|
|
2786
|
+
if rows > nominal_capacity:
|
|
2787
|
+
return max(
|
|
2788
|
+
nominal_height,
|
|
2789
|
+
_HBAR_VERTICAL_PADDING
|
|
2790
|
+
+ rows * _HBAR_COMFORTABLE_ROW_PITCH,
|
|
2791
|
+
)
|
|
2792
|
+
if isinstance(chart, BarChart) and chart.stacks:
|
|
2793
|
+
return max(
|
|
2794
|
+
nominal_height,
|
|
2795
|
+
_PADDING_TOP + _PADDING_BOTTOM
|
|
2796
|
+
+ _stacked_legend_height(len(chart.stacks))
|
|
2797
|
+
+ _STACKED_PLOT_MIN_H,
|
|
2798
|
+
)
|
|
2799
|
+
return nominal_height
|
|
2800
|
+
|
|
2681
2801
|
# --- SVG table geometry (issue #38) ---
|
|
2682
2802
|
_SVG_TABLE_FONT = 11
|
|
2683
2803
|
_SVG_TABLE_CELL_PAD_X = 8
|
|
@@ -3118,7 +3238,10 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
3118
3238
|
is rendered separately as a sibling element).
|
|
3119
3239
|
"""
|
|
3120
3240
|
has_table = include_table and _has_table(snap)
|
|
3121
|
-
chart_h =
|
|
3241
|
+
chart_h = (
|
|
3242
|
+
chart_required_height(snap.chart, nominal_height=_SVG_CHART_H)
|
|
3243
|
+
if snap.chart is not None else 0
|
|
3244
|
+
)
|
|
3122
3245
|
header_h = _svg_header_height(snap, include_chrome=include_chrome,
|
|
3123
3246
|
shows_table=has_table)
|
|
3124
3247
|
|
|
@@ -3198,17 +3321,17 @@ def _render_svg(snap: ShareSnapshot, *, palette: dict,
|
|
|
3198
3321
|
if isinstance(snap.chart, LineChart):
|
|
3199
3322
|
pieces.append(_render_line_chart_svg(
|
|
3200
3323
|
snap.chart, palette=palette,
|
|
3201
|
-
x=_SVG_PADDING, y=chart_y, width=content_w, height=
|
|
3324
|
+
x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
|
|
3202
3325
|
))
|
|
3203
3326
|
elif isinstance(snap.chart, BarChart):
|
|
3204
3327
|
pieces.append(_render_bar_chart_svg(
|
|
3205
3328
|
snap.chart, palette=palette,
|
|
3206
|
-
x=_SVG_PADDING, y=chart_y, width=content_w, height=
|
|
3329
|
+
x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
|
|
3207
3330
|
))
|
|
3208
3331
|
elif isinstance(snap.chart, HorizontalBarChart):
|
|
3209
3332
|
pieces.append(_render_hbar_chart_svg(
|
|
3210
3333
|
snap.chart, palette=palette,
|
|
3211
|
-
x=_SVG_PADDING, y=chart_y, width=content_w, height=
|
|
3334
|
+
x=_SVG_PADDING, y=chart_y, width=content_w, height=chart_h,
|
|
3212
3335
|
))
|
|
3213
3336
|
|
|
3214
3337
|
if has_table:
|
|
@@ -3937,9 +4060,15 @@ def _stitch_html(sections: tuple[ComposedSection, ...], *,
|
|
|
3937
4060
|
# resolved to the user agent's default black and rendered invisible on
|
|
3938
4061
|
# the dark palette's #0b0f17 background. Every other element in both
|
|
3939
4062
|
# the stitcher and the fragment carries an explicit inline colour.
|
|
4063
|
+
alias_scope = (
|
|
4064
|
+
f'<p class="composite-alias-scope" '
|
|
4065
|
+
f'style="color:{palette["muted"]};font-size:12px;margin:0 0 18px">'
|
|
4066
|
+
f'{_COMPOSITE_ALIAS_SCOPE_TEXT}</p>'
|
|
4067
|
+
if not opts.reveal_projects else ""
|
|
4068
|
+
)
|
|
3940
4069
|
header = (
|
|
3941
4070
|
f'<header><h1 style="color:{palette["fg"]}">'
|
|
3942
|
-
f'{_xml_escape(opts.title)}</h1
|
|
4071
|
+
f'{_xml_escape(opts.title)}</h1>{alias_scope}</header>'
|
|
3943
4072
|
)
|
|
3944
4073
|
blocks = []
|
|
3945
4074
|
for sec in sections:
|
|
@@ -4019,6 +4148,8 @@ def _stitch_md(sections: tuple[ComposedSection, ...], *,
|
|
|
4019
4148
|
# otherwise inline HTML or MD specials in a user-entered title
|
|
4020
4149
|
# would survive into the export unescaped.
|
|
4021
4150
|
parts.append(f"# {_md_escape(opts.title)}\n\n")
|
|
4151
|
+
if not opts.reveal_projects:
|
|
4152
|
+
parts.append(f"_{_COMPOSITE_ALIAS_SCOPE_TEXT}_\n\n")
|
|
4022
4153
|
last_idx = len(sections) - 1
|
|
4023
4154
|
for idx, sec in enumerate(sections):
|
|
4024
4155
|
# The section heading is the FRAGMENT's own heading, rendered at
|
|
@@ -4057,6 +4188,7 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
|
|
|
4057
4188
|
palette=palette, branding=False)
|
|
4058
4189
|
inners.append((inner, w, h))
|
|
4059
4190
|
footer_text = _attribution_text(sections[0].snap.version)
|
|
4191
|
+
alias_scope = None if opts.reveal_projects else _COMPOSITE_ALIAS_SCOPE_TEXT
|
|
4060
4192
|
# The composite title and footer CONTRIBUTE to the width (#503 S2
|
|
4061
4193
|
# review F8). It used to be the section maximum alone, so an 18pt
|
|
4062
4194
|
# title longer than the widest section ran off the viewBox — the
|
|
@@ -4064,12 +4196,17 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
|
|
|
4064
4196
|
total_w = max(
|
|
4065
4197
|
max(w for _, w, _ in inners),
|
|
4066
4198
|
_SVG_PADDING * 2 + _svg_text_width(opts.title, 18.0),
|
|
4199
|
+
0.0 if alias_scope is None
|
|
4200
|
+
else _SVG_PADDING * 2 + _svg_text_width(alias_scope, 11.0),
|
|
4067
4201
|
0.0 if opts.no_branding
|
|
4068
4202
|
else _SVG_PADDING * 2 + _svg_text_width(footer_text, 10.0),
|
|
4069
4203
|
)
|
|
4070
4204
|
stack_h = (sum(h for _, _, h in inners)
|
|
4071
4205
|
+ _SVG_SECTION_GAP * (len(inners) - 1))
|
|
4072
|
-
header_h =
|
|
4206
|
+
header_h = (
|
|
4207
|
+
_SVG_COMPOSITE_HEADER_H
|
|
4208
|
+
+ (_SVG_COMPOSITE_ALIAS_SCOPE_EXTRA_H if alias_scope else 0.0)
|
|
4209
|
+
)
|
|
4073
4210
|
footer_h = 0.0 if opts.no_branding else _SVG_COMPOSITE_FOOTER_H
|
|
4074
4211
|
total_h = header_h + stack_h + footer_h
|
|
4075
4212
|
|
|
@@ -4078,6 +4215,12 @@ def _stitch_svg(sections: tuple[ComposedSection, ...], *,
|
|
|
4078
4215
|
svg_group([
|
|
4079
4216
|
svg_text(_SVG_PADDING, _SVG_COMPOSITE_TITLE_BASELINE, opts.title,
|
|
4080
4217
|
font_size=18, fill=palette["fg"], weight="bold"),
|
|
4218
|
+
*([] if alias_scope is None else [
|
|
4219
|
+
svg_text(
|
|
4220
|
+
_SVG_PADDING, _SVG_COMPOSITE_ALIAS_SCOPE_BASELINE,
|
|
4221
|
+
alias_scope, font_size=11, fill=palette["muted"],
|
|
4222
|
+
),
|
|
4223
|
+
]),
|
|
4081
4224
|
]),
|
|
4082
4225
|
]
|
|
4083
4226
|
y = header_h
|
package/bin/cctally
CHANGED
|
@@ -320,6 +320,10 @@ STATUSLINE_SELECTED_PATH = _cctally_core.STATUSLINE_SELECTED_PATH
|
|
|
320
320
|
STATUSLINE_TRANSPORT_MARKER_PATH = _cctally_core.STATUSLINE_TRANSPORT_MARKER_PATH
|
|
321
321
|
STATUSLINE_AUTHORITATIVE_7D_PATH = _cctally_core.STATUSLINE_AUTHORITATIVE_7D_PATH
|
|
322
322
|
STATUSLINE_AUTHORITATIVE_5H_PATH = _cctally_core.STATUSLINE_AUTHORITATIVE_5H_PATH
|
|
323
|
+
# Host-global, NOT APP_DIR-derived (#529 S4). Readers resolve it through
|
|
324
|
+
# _cctally_core at call time; this re-export exists for introspection parity
|
|
325
|
+
# with the other statusline constants.
|
|
326
|
+
STATUSLINE_OAUTH_CACHE_PATH = _cctally_core.STATUSLINE_OAUTH_CACHE_PATH
|
|
323
327
|
OAUTH_BACKOFF_MARKER_PATH = _cctally_core.OAUTH_BACKOFF_MARKER_PATH
|
|
324
328
|
STATUSLINE_CANDIDATE_TTL_SECONDS = _cctally_core.STATUSLINE_CANDIDATE_TTL_SECONDS
|
|
325
329
|
STATUSLINE_CANDIDATE_FUTURE_SKEW_SECONDS = _cctally_core.STATUSLINE_CANDIDATE_FUTURE_SKEW_SECONDS
|
|
@@ -1830,7 +1834,7 @@ def _decode_escaped_cwd(dir_name: str) -> str:
|
|
|
1830
1834
|
Claude stores JSONL files under ~/.claude/projects/<escaped-cwd>/<uuid>.jsonl
|
|
1831
1835
|
where `<escaped-cwd>` replaces path separators with '-' and adds a leading
|
|
1832
1836
|
'-'. This function reverses that transform:
|
|
1833
|
-
"-Volumes-
|
|
1837
|
+
"-Volumes-Scratch-repos-foo" -> "/Volumes/Scratch/repos/foo"
|
|
1834
1838
|
|
|
1835
1839
|
Lossy: original path dashes become slashes. Preferred alternative at the
|
|
1836
1840
|
caller site is to pull `cwd` from the JSONL itself when present.
|