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.
@@ -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