88import sys
99import threading
1010import time
11+ from dataclasses import dataclass
1112from datetime import datetime , time as dt_time
1213from pathlib import Path
1314from typing import Final , Literal , Protocol , cast
3940
4041type _View = Literal ["home" , "detail" , "cost" ]
4142
42- _DATE_FORMATS : Final [list [str ]] = ["%Y-%m-%d" , "%Y-%m-%dT%H:%M:%S" ]
43+ # (format_string, has_explicit_time) pairs — single source of truth.
44+ _FORMAT_SPECS : Final [list [tuple [str , bool ]]] = [
45+ ("%Y-%m-%d" , False ),
46+ ("%Y-%m-%dT%H:%M:%S" , True ),
47+ ]
48+
49+ _DATE_FORMATS : Final [list [str ]] = [fmt for fmt , _ in _FORMAT_SPECS ]
4350
4451_WATCHDOG_DEBOUNCE_SECS : Final [float ] = (
4552 2.0 # Prevents rapid redraws during tool-use bursts
4653)
4754
4855
56+ @dataclass (frozen = True , slots = True )
57+ class _ParsedDateArg :
58+ """Carries a parsed datetime together with whether the user supplied a time."""
59+
60+ value : datetime
61+ has_explicit_time : bool
62+
63+
64+ class _DateTimeOrDate (click .ParamType ):
65+ """Click parameter type that distinguishes date-only from datetime inputs.
66+
67+ Parses ``%Y-%m-%d`` as date-only (``has_explicit_time=False``) and
68+ ``%Y-%m-%dT%H:%M:%S`` as datetime (``has_explicit_time=True``).
69+ Returns a :class:`_ParsedDateArg`.
70+ """
71+
72+ name : str = "datetime-or-date"
73+
74+ def convert ( # noqa: RET503
75+ self ,
76+ value : str | datetime ,
77+ param : click .Parameter | None ,
78+ ctx : click .Context | None ,
79+ ) -> _ParsedDateArg :
80+ """Parse *value* into a ``_ParsedDateArg``."""
81+ if isinstance (value , datetime ):
82+ # Already parsed (e.g. default value) — treat as explicit time.
83+ return _ParsedDateArg (value = value , has_explicit_time = True )
84+
85+ result = self ._try_parse (value )
86+ if result is not None :
87+ return result
88+
89+ msg = (
90+ f"invalid datetime format: { value !r} . "
91+ "Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS."
92+ )
93+ self .fail (msg , param , ctx )
94+
95+ @staticmethod
96+ def _try_parse (value : str ) -> _ParsedDateArg | None :
97+ """Attempt date-only then datetime parsing; return ``None`` on failure."""
98+ for fmt , explicit in _FORMAT_SPECS :
99+ try :
100+ return _ParsedDateArg (
101+ value = datetime .strptime (value , fmt ),
102+ has_explicit_time = explicit ,
103+ )
104+ except ValueError :
105+ continue
106+ return None
107+
108+
49109console : Final [Console ] = Console ()
50110
51111
52- def _normalize_until (dt : datetime | None ) -> datetime | None :
53- """Extend an ``--until`` timestamp at midnight to end-of-day (23:59:59.999999).
112+ def _normalize_until (arg : _ParsedDateArg | None ) -> datetime | None :
113+ """Extend a date-only ``--until`` value to end-of-day (23:59:59.999999).
54114
55- Because :class:`click.DateTime` discards the original string format, we
56- cannot distinguish ``--until 2026-03-07`` from an explicit
57- ``--until 2026-03-07T00:00:00``. Both are treated as "include the
58- entire day" and expanded to 23:59:59.999999 in the **same timezone**
59- as the input (naive inputs are made UTC-aware via :func:`ensure_aware`).
115+ Only expands to end-of-day when the user supplied a date without a time
116+ component (``has_explicit_time is False``). An explicit
117+ ``--until 2026-03-07T00:00:00`` is left as-is, giving strict
118+ before-midnight semantics.
60119 """
61- if dt is None :
120+ if arg is None :
62121 return None
63- aware = ensure_aware (dt )
64- if aware .time () == dt_time (0 , 0 , 0 ):
122+ aware = ensure_aware (arg . value )
123+ if not arg . has_explicit_time and aware .time () == dt_time (0 , 0 , 0 ):
65124 return aware .replace (hour = 23 , minute = 59 , second = 59 , microsecond = 999999 )
66125 return aware
67126
68127
69128def _validate_since_until (
70129 since : datetime | None ,
71- until : datetime | None ,
130+ until : _ParsedDateArg | None ,
72131) -> tuple [datetime | None , datetime | None ]:
73132 """Normalize and validate --since/--until, raising on reversed range."""
74133 aware_since = ensure_aware_opt (since )
@@ -437,9 +496,9 @@ def main(ctx: click.Context, path: Path | None) -> None:
437496)
438497@click .option (
439498 "--until" ,
440- type = click . DateTime ( formats = _DATE_FORMATS ),
499+ type = _DateTimeOrDate ( ),
441500 default = None ,
442- help = "Show sessions starting on or before this date (midnight values are expanded to end-of-day)." ,
501+ help = "Show sessions starting before or at this timestamp cutoff (date-only values are expanded to end-of-day)." ,
443502)
444503@click .option (
445504 "--path" ,
@@ -451,7 +510,7 @@ def main(ctx: click.Context, path: Path | None) -> None:
451510def summary (
452511 ctx : click .Context ,
453512 since : datetime | None ,
454- until : datetime | None ,
513+ until : _ParsedDateArg | None ,
455514 path : Path | None ,
456515) -> None :
457516 """Show usage summary across all sessions."""
@@ -533,9 +592,9 @@ def session(ctx: click.Context, session_id: str, path: Path | None) -> None:
533592)
534593@click .option (
535594 "--until" ,
536- type = click . DateTime ( formats = _DATE_FORMATS ),
595+ type = _DateTimeOrDate ( ),
537596 default = None ,
538- help = "Show sessions starting on or before this date (midnight values are expanded to end-of-day)." ,
597+ help = "Show sessions starting before or at this timestamp cutoff (date-only values are expanded to end-of-day)." ,
539598)
540599@click .option (
541600 "--path" ,
@@ -547,7 +606,7 @@ def session(ctx: click.Context, session_id: str, path: Path | None) -> None:
547606def cost (
548607 ctx : click .Context ,
549608 since : datetime | None ,
550- until : datetime | None ,
609+ until : _ParsedDateArg | None ,
551610 path : Path | None ,
552611) -> None :
553612 """Show premium request costs from shutdown data."""
0 commit comments