mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* fix(stats): stop over-counting totalDays by one
The /stats 'active X of Y days' denominator (totalDays) diffed the first and
last session ISO timestamps raw, ran Math.ceil over the millisecond gap, then
added 1. firstSessionDate/lastSessionDate are full timestamps, so ceil rounds
any sub-day remainder up to a whole day and the +1 double-counts it: all
activity on a single calendar day reported 2 days (50% active instead of
100%), and every non-24h-multiple span was one day long.
Extract inclusiveCalendarDaySpan, which snaps both endpoints to their UTC date
(via toDateString, the same basis activeDays/dailyActivity use) before
differencing, so the gap is an exact multiple of 24h and the inclusive +1 is
correct. Use it at both call sites (cacheToStats and
processedStatsToClaudeCodeStats).
* fix(stats): guard day-span helper against unparseable persisted dates
A same-version stats cache can carry a structurally-valid but unparseable
firstSessionDate (e.g. "not-a-date"). That value reached inclusiveCalendarDaySpan,
where new Date(...).toISOString() throws RangeError and aborts the whole /stats
render instead of degrading. Parse the endpoints up front and fall back to 0
(the same value callers use for a missing endpoint) when either is invalid.
* fix(stats): reject malformed persisted dates instead of trusting Date.parse
Date.parse accepts far more than the shapes this pipeline persists: "2026-07",
"2026", "123" and "01/01/2026" all resolve to real dates, so a truncated or
foreign-format cache value produced a plausible-but-wrong totalDays rather than
being rejected. Require the value to start with a full ISO calendar date — the
two shapes actually written are a session.timestamp instant and a bare
dailyActivity YYYY-MM-DD key — and fall back to 0 otherwise.
* test(stats): guard the off-by-one with spans that separate the formulas
The multi-day, identical-timestamp and adjacent-midnight cases return the same
value under both the old Math.ceil(gap)+1 and the new calendar-day formula, so
they could not catch a regression. Add the canonical failing spans (1.5 and 3.5
raw days, where the old formula reported 3 and 5 instead of 2 and 4), a bare
dailyActivity date-key case, and the malformed-date rejections.
* fix(stats): reject impossible calendar dates, not just non-date shapes
The corruption guard only checked for a date-shaped prefix, so Date.parse still
normalized impossible values — 2026-02-30 parsed as March 2 and
inclusiveCalendarDaySpan('2026-02-30', '2026-03-02') returned 1 instead of the
documented 0 fallback, letting a corrupt cache date fabricate the /stats
denominator. Validate the spelled year/month/day against the real calendar
(leap years included) before trusting the parse. Regression covers rollover,
month/day bounds, and Feb 29 in leap vs non-leap years.
* fix(stats): accept only the two persisted timestamp shapes
The prefix check also matched a space-delimited value such as
'2026-07-13 23:30:00', which Date.parse then read as a host-local
timestamp. That is neither a bare dailyActivity date key nor an ISO
instant emitted by the pipeline, so the computed span depended on the
machine's timezone — inclusiveCalendarDaySpan('2026-07-13 23:30:00',
'2026-07-14T00:30:00.000Z') returned 2 under UTC and 1 under
America/Los_Angeles instead of the documented 0 fallback for corrupt input.
Anchor the pattern at both ends and require the zone designator on the
instant form.
* fix(stats): select session endpoints chronologically and reject bad clocks
Both aggregation paths picked firstSessionDate/lastSessionDate by string
comparison, but offset-qualified instants do not sort that way:
2026-07-13T23:30:00-10:00 is later than 2026-07-14T00:00:00+14:00 while
sorting earlier, so it was chosen as the first endpoint and the span came
out 0 for two sessions that occupy different UTC days. Compare parsed
epochs, falling back to string order only for values that do not parse so
selection stays total.
The persisted-date guard also let out-of-range clock components through.
Date.parse normalizes 2026-07-13T24:00:00.000Z to midnight on July 14, so
a corrupt timestamp fabricated a day of span instead of taking the
documented 0 fallback. Validate hours/minutes/seconds and the offset the
same way the calendar components are already validated.
* fix(stats): order the cached first session chronologically too
The cache writer is the companion to the endpoint selection this PR fixed,
and it had the same lexical comparison. Offset-qualified timestamps do not
sort by the instant they denote, so merging 2026-07-14T00:00:00+14:00 (UTC
July 13) with 2026-07-13T23:30:00-10:00 (UTC July 14) stored the later one
as the cache's first session. A later cached /stats run has no session list
left to correct that, so it reports one total day for activity spanning two
UTC dates.
The persisted-date helpers move to statsCache.ts and are re-exported from
stats.ts: the cache writer needs them, and stats.ts already depends on that
module, so importing the other way would be a cycle.
Also assert the exact two-day span in the offset test instead of merely a
positive result, which a regression to 1 would have passed.
* fix(stats): heal a corrupt persisted firstSessionDate seed
Both first-session selection loops seed firstSessionDate from the persisted
cache, which can hold a corrupt value. A garbage seed that sorts lexically
before every real timestamp (e.g. "1") is never displaced by
comparePersistedDates, so the corruption -- and the wrong totalDays it drives
-- persists across every later run. Treat an unparseable seed as absent so
the first valid session date replaces it.
* fix(stats): accept the ISO spellings the ingestion path persists
parsePersistedDateMs required seconds and a colon in the numeric offset, so it
returned NaN for valid instants that processSessionFiles stores verbatim --
`2026-07-13T12:00Z` and `...+0000`. The session was still counted in
dailyActivity but the span came out 0, so /stats reported zero total days for
real multi-day activity. Make the seconds group and the offset colon optional
while keeping the zone requirement and range/calendar validation. Also use the
explicit leap rule instead of Date.UTC(year, ...), which maps years 0-99 to
1900-1999 and judged year 0000 inconsistently with the Date.parse result.