cctally 1.48.0 → 1.50.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 +14 -0
- package/bin/_cctally_cache.py +44 -0
- package/bin/_cctally_dashboard.py +109 -3
- package/bin/_cctally_db.py +46 -7
- package/bin/_lib_conversation_query.py +173 -18
- package/bin/_lib_dashboard_dates.py +166 -0
- package/dashboard/static/assets/index-C96dGQ7N.js +68 -0
- package/dashboard/static/assets/index-ChyJiUWl.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-De72H7wU.js +0 -68
- package/dashboard/static/assets/index-QLGET4cL.css +0 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pure date-range parsing for dashboard browse filters (spec §2).
|
|
2
|
+
|
|
3
|
+
The CLI's ``_parse_cli_date_range`` is argparse-coupled and returns a CLI exit
|
|
4
|
+
code (Codex pre-plan P1 #3), so the dashboard cannot reuse it directly. This
|
|
5
|
+
module is the decoupled sibling: it maps a ``(date_from, date_to)`` pair to
|
|
6
|
+
UTC-ISO boundary strings that lexicographically compare against the stored
|
|
7
|
+
``last_activity_utc`` / ``MAX(timestamp_utc)`` value, or raises ``ValueError`` —
|
|
8
|
+
which the handler maps to HTTP 400.
|
|
9
|
+
|
|
10
|
+
HALF-OPEN, precision-safe bounds (Task 2 review Finding 1)
|
|
11
|
+
----------------------------------------------------------
|
|
12
|
+
Stored timestamps are RAW JSONL passthrough of MIXED precision: both
|
|
13
|
+
whole-second ``YYYY-MM-DDTHH:MM:SSZ`` and millisecond
|
|
14
|
+
``YYYY-MM-DDTHH:MM:SS.mmmZ`` occur in real ``~/.claude/projects`` data. A
|
|
15
|
+
lexicographic SQL string compare on those values is only correct if the bounds
|
|
16
|
+
are chosen so the lex order matches the chronological order at the day edges.
|
|
17
|
+
ASCII ``Z`` (0x5A) sorts AFTER ``.`` (0x2E) and after the digits ``0``-``9``
|
|
18
|
+
(0x30-0x39), so a whole-second ``...00:00:00Z`` lower bound and an inclusive
|
|
19
|
+
``...23:59:59.999999Z`` upper bound BOTH mis-compare at the boundaries:
|
|
20
|
+
|
|
21
|
+
* a midnight row ``...T00:00:00.000Z`` is wrongly EXCLUDED by ``>=
|
|
22
|
+
...T00:00:00Z`` (``.000Z`` < ``00Z`` because ``.`` < ``Z``);
|
|
23
|
+
* a last-ms row ``...T23:59:59.999Z`` is wrongly EXCLUDED by ``<=
|
|
24
|
+
...T23:59:59.999999Z`` (``.999Z`` > ``.999999Z`` because ``Z`` > ``9``);
|
|
25
|
+
* a whole-second ``...T23:59:59Z`` row is wrongly EXCLUDED by the same upper
|
|
26
|
+
bound (``Z`` > ``.``).
|
|
27
|
+
|
|
28
|
+
The fix is a HALF-OPEN interval ``[start_of_day(date_from),
|
|
29
|
+
start_of_next_day(date_to))``:
|
|
30
|
+
|
|
31
|
+
* lower bound = start-of-day of ``date_from`` (in ``display.tz`` → UTC),
|
|
32
|
+
compared with ``>=``;
|
|
33
|
+
* upper bound = start-of-day of ``(date_to + 1 day)`` (in ``display.tz`` →
|
|
34
|
+
UTC), compared with a STRICT ``<`` (NOT the old inclusive end-of-day).
|
|
35
|
+
|
|
36
|
+
Both bounds are formatted with 6-digit microseconds ``...THH:MM:SS.000000Z``.
|
|
37
|
+
The lex algebra then holds for stored values of EVERY precision: ``.000000`` is
|
|
38
|
+
``<=`` any real fractional part and ``Z`` follows it, so a ``>=`` lower bound of
|
|
39
|
+
``<day>T00:00:00.000000Z`` includes the same-day midnight row at any precision,
|
|
40
|
+
and a strict-``<`` upper bound of ``<nextday>T00:00:00.000000Z`` includes every
|
|
41
|
+
same-day row (any precision) while excluding next-day midnight. The SQL callers
|
|
42
|
+
(``_lib_conversation_query._rollup_where`` and ``_live_having``) therefore use
|
|
43
|
+
``last_activity_utc >= ?`` for the lower bound and ``last_activity_utc < ?`` for
|
|
44
|
+
the upper.
|
|
45
|
+
|
|
46
|
+
Semantics mirror the CLI's display-tz date posture (``docs/dashboard-gotchas``):
|
|
47
|
+
a naive *date-only* bound is interpreted in ``display.tz`` (start-of-day for the
|
|
48
|
+
lower bound, start-of-NEXT-day for the upper), then converted to UTC for the
|
|
49
|
+
stored-UTC comparison; a *full-ISO* bound carries its own explicit offset and
|
|
50
|
+
bypasses the dual-form parse (tz-independent), used as a precise instant.
|
|
51
|
+
"""
|
|
52
|
+
import datetime as dt
|
|
53
|
+
from zoneinfo import ZoneInfo
|
|
54
|
+
|
|
55
|
+
# Dual-form date spellings the CLI's ``_try_dual_form_date`` accepts for a
|
|
56
|
+
# naive date-only value (no time component): ISO ``YYYY-MM-DD`` and the compact
|
|
57
|
+
# ``YYYYMMDD``. Anything else is rejected as a malformed date.
|
|
58
|
+
_DUAL_FORMS = ("%Y-%m-%d", "%Y%m%d")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_explicit_offset(raw):
|
|
62
|
+
"""True iff ``raw`` carries an explicit UTC offset / ``Z`` — i.e. it is a
|
|
63
|
+
PRECISE instant, not a wall-clock date(-time) needing localization.
|
|
64
|
+
|
|
65
|
+
Finding 3: an offset-less ``T`` form like ``2026-06-15T08:30:00`` parses to a
|
|
66
|
+
NAIVE datetime; the old predicate routed it to the full-ISO bypass and then
|
|
67
|
+
the day-bound logic overwrote its time, silently discarding ``08:30:00``. We
|
|
68
|
+
require a trailing ``Z`` or a ``+``/``-`` sign in the TIME portion (after the
|
|
69
|
+
``T``) before treating the value as a precise timestamp; an offset-less ``T``
|
|
70
|
+
falls through to the date-only day-bound path (its date wins, its naive time
|
|
71
|
+
is intentionally not used as a sub-day cut)."""
|
|
72
|
+
if raw.endswith("Z"):
|
|
73
|
+
return True
|
|
74
|
+
t = raw.find("T")
|
|
75
|
+
if t == -1:
|
|
76
|
+
return False
|
|
77
|
+
# A '+' or '-' in the time portion is an explicit offset (the date portion's
|
|
78
|
+
# own '-' separators are before the 'T', so they never match here).
|
|
79
|
+
tail = raw[t + 1:]
|
|
80
|
+
return "+" in tail or "-" in tail
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_one(raw):
|
|
84
|
+
"""``raw`` -> a naive ``datetime`` (date-only / offset-less spelling, to be
|
|
85
|
+
localized in display.tz by ``_to_utc_iso``) OR an aware ``datetime``
|
|
86
|
+
(full-ISO spelling WITH an explicit offset, carries its own instant).
|
|
87
|
+
``None``/empty passes through as ``None``. Raises ``ValueError`` on an
|
|
88
|
+
unrecognized spelling.
|
|
89
|
+
|
|
90
|
+
Finding 3: only an input with an explicit offset (``Z`` or a ``+``/``-`` in
|
|
91
|
+
the time portion, or a parsed non-None ``tzinfo``) is treated as a precise
|
|
92
|
+
instant; an offset-less ``…THH:MM:SS`` is parsed but returned NAIVE so the
|
|
93
|
+
day-bound localization applies (its date is what matters)."""
|
|
94
|
+
if raw is None or raw == "":
|
|
95
|
+
return None
|
|
96
|
+
# Precise instant: explicit offset present — bypass the dual-form parse so it
|
|
97
|
+
# stays tz-independent, matching the CLI's full-ISO posture.
|
|
98
|
+
if _has_explicit_offset(raw):
|
|
99
|
+
d = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
100
|
+
if d.tzinfo is not None:
|
|
101
|
+
return d
|
|
102
|
+
# Belt-and-suspenders: a recognized-offset spelling that still parsed
|
|
103
|
+
# naive (should not happen) degrades to the day-bound path below.
|
|
104
|
+
return d
|
|
105
|
+
if "T" in raw:
|
|
106
|
+
# Offset-less datetime (e.g. 2026-06-15T08:30:00) -> NAIVE; the day-bound
|
|
107
|
+
# path uses only its date (Finding 3 — do not silently keep the time).
|
|
108
|
+
return dt.datetime.fromisoformat(raw)
|
|
109
|
+
for fmt in _DUAL_FORMS:
|
|
110
|
+
try:
|
|
111
|
+
return dt.datetime.strptime(raw, fmt)
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
raise ValueError(f"bad date: {raw!r}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _utc_micro_iso(u):
|
|
118
|
+
"""Format an aware UTC ``datetime`` as the lex-safe boundary string
|
|
119
|
+
``YYYY-MM-DDTHH:MM:SS.000000Z`` (always 6-digit microseconds + ``Z``)."""
|
|
120
|
+
return u.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _to_utc_iso(d, tz, *, upper):
|
|
124
|
+
"""An aware-or-naive ``datetime`` -> a half-open UTC-ISO boundary string.
|
|
125
|
+
|
|
126
|
+
Naive (date-only / offset-less) value: localized in ``tz``, snapped to
|
|
127
|
+
start-of-day; for the UPPER bound it is the start of the NEXT day (so the
|
|
128
|
+
strict-``<`` comparison covers the whole requested last day). Aware value
|
|
129
|
+
(explicit-offset full-ISO): used as the precise instant verbatim, converted
|
|
130
|
+
to UTC. Always emits 6-digit-microsecond ``...Z`` so the lex compare is
|
|
131
|
+
precision-safe against any stored value (see module docstring)."""
|
|
132
|
+
if d.tzinfo is None: # naive date-only / offset-less -> localize in display tz
|
|
133
|
+
d = d.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
|
|
134
|
+
if upper:
|
|
135
|
+
# start-of-NEXT-day: half-open exclusive upper. Add a day in the LOCAL
|
|
136
|
+
# zone (DST-correct: re-localize the resulting wall date so a 23/25h
|
|
137
|
+
# day still lands on the next midnight) before converting to UTC.
|
|
138
|
+
nxt = (d + dt.timedelta(days=1)).date()
|
|
139
|
+
d = dt.datetime(nxt.year, nxt.month, nxt.day, tzinfo=tz)
|
|
140
|
+
u = d.astimezone(dt.timezone.utc)
|
|
141
|
+
return _utc_micro_iso(u)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_filter_date_range(date_from, date_to, *, tz_name):
|
|
145
|
+
"""``(date_from, date_to)`` -> ``(start_iso|None, end_iso|None)``.
|
|
146
|
+
|
|
147
|
+
The returned pair is a HALF-OPEN interval ``[start, end)``: callers compare
|
|
148
|
+
``stored >= start`` (inclusive lower) and ``stored < end`` (EXCLUSIVE upper —
|
|
149
|
+
``end`` is the start of the day AFTER ``date_to``, NOT an inclusive
|
|
150
|
+
end-of-day). Both bounds are 6-digit-microsecond ``...Z`` strings chosen so a
|
|
151
|
+
lexicographic compare against the mixed-precision stored timestamps is
|
|
152
|
+
chronologically correct at the day edges (see the module docstring).
|
|
153
|
+
|
|
154
|
+
Each input is an optional ``YYYY-MM-DD`` / ``YYYYMMDD`` (date-only,
|
|
155
|
+
localized in ``tz_name``), an offset-less ``...THH:MM:SS`` (date-only day
|
|
156
|
+
bound — its naive time is NOT used, Finding 3), or a full ISO-8601 string
|
|
157
|
+
WITH an explicit offset (used as a precise instant; the upper stays
|
|
158
|
+
exclusive). ``tz_name`` is an IANA key (e.g. ``Etc/UTC``,
|
|
159
|
+
``America/New_York``); a falsy ``tz_name`` defaults to UTC. Raises
|
|
160
|
+
``ValueError`` on a malformed date — the handler maps that to HTTP 400."""
|
|
161
|
+
tz = ZoneInfo(tz_name) if tz_name else dt.timezone.utc
|
|
162
|
+
df = _parse_one(date_from)
|
|
163
|
+
dtt = _parse_one(date_to)
|
|
164
|
+
start = _to_utc_iso(df, tz, upper=False) if df is not None else None
|
|
165
|
+
end = _to_utc_iso(dtt, tz, upper=True) if dtt is not None else None
|
|
166
|
+
return start, end
|