Skip to content

fix: count the day in local time, not UTC (eod-gather + session-learner) - #34

Open
juanparisma wants to merge 2 commits into
Luispitik:mainfrom
juanparisma:fix/count-days-in-local-time
Open

fix: count the day in local time, not UTC (eod-gather + session-learner)#34
juanparisma wants to merge 2 commits into
Luispitik:mainfrom
juanparisma:fix/count-days-in-local-time

Conversation

@juanparisma

Copy link
Copy Markdown
Contributor

The bug

Both _eod-gather.sh and _session-learner.sh derive "today" with
new Date().toISOString().slice(0, 10), which is the UTC date. West of
Greenwich the UTC date and the operator's date disagree for part of every day:
in UTC-5 the UTC bucket for a given date runs from 19:00 the previous local
day to 19:00 that local day
.

That happens to be exactly when closes run. On my machine the /eod safety net
fires at 19:00, 21:00 and 23:30 local — all three land on the wrong side of
UTC midnight.

On 2026-07-30 the same day was reported two different wrong ways:

Source Reported
gather at 21:43 local date: 2026-07-31, 27 observations
headless close at 21:08 local ~522 observations
truth (local day) 382

The low number looks like a quiet day. The high one is worse: it silently
absorbs the previous evening, and nobody audits a total that looks plausible.

_session-learner.sh shows it as Última sesión: <tomorrow> in every project's
context.md — which the gather then injects into the agent's context at session
start.

Why not just make the date local

That was the first thing I tried, and it makes the gather worse: the
startsWith prefix then matches the whole UTC bucket and always over-counts
(521 instead of 382 in the case above). The defect is comparing a local date
against a UTC instant, not how the date is computed.

So the gather now builds an explicit local [midnight, next midnight) window
and compares instants with Date.parse(). dayEnd comes from the calendar
rather than +24h, so it stays correct across a DST transition where a local
day is 23 or 25 hours long.

The learner had its own version of the same trap: priorPropDates built its day
set from p.proposed_at.slice(0, 10), a UTC date. Changing only today to local
would mix UTC-derived and local days in one Set — and that Set feeds the
"3+ distinct days" threshold that promotes a repetition proposal. Two
proposals from the same local evening (10:00 and 22:00 in UTC-5) would have
counted as two days and promoted early. Both sides now go through
localDayOf(), which converts the instant instead of slicing it.

proposed_at stays UTC on purpose: it is an instant, not a day.

Also fixed: the test shared the bug

tests/test-eod-gather.sh built fixtures as "<UTC date>T10:00:00Z", which fall
outside the local day every evening west of Greenwich — the suite would have gone
red on a correct implementation, depending on the hour it ran. Fixtures now
anchor to local noon, far from either midnight in any timezone.

One note on that helper, because it is a nice illustration of the failure mode
this PR is about: it first passed its day offset through argv, where node
parsed a leading -1 as one of its own options and printed nothing.
YESTERDAY came out empty — and Test 5 still passed, because an empty
timestamp is excluded exactly like a stale one. The offset goes through the
environment now.

Verification

  • tests/test-eod-gather.sh: 8 passed, 0 failed
  • All 16 suites in .github/workflows/tests.yml green locally on a clean
    clone at a038b50
  • Live on my machine: the gather reports the local date and its count matches a
    hand-measured [05:00Z, 05:00Z) window exactly; context.md now reads the
    local date; the instincts index is untouched

Scope

Three files, +81/−20. No workflow or packaging changes.

`today` came from toISOString() and was matched with startsWith() against UTC
observation timestamps. The reported day is therefore the UTC one, not the
operator's. West of Greenwich the two disagree every evening: in UTC-5 the UTC
bucket for a date runs from 19:00 the previous local day to 19:00 that local
day.

That is exactly when closes run. On one machine the safety net fires at 19:00,
21:00 and 23:30 local, so all three land on the wrong side of UTC midnight. The
same day was reported as 27 observations by a gather at 21:43 and as ~522 by a
close written at 21:08; the truth was 382.

Changing only the date computation to local makes it worse: the prefix would
then match the whole UTC bucket and always over-count (521 in the case above).
The defect is comparing a local date against a UTC instant, so the fix is an
explicit local [midnight, next midnight) window compared with Date.parse().
dayEnd comes from the calendar rather than +24h so it stays correct across a DST
transition, where a local day is 23 or 25 hours long.

The early-exit branch printed `date -u` too, and is now local for consistency.

Tests: fixtures were "<UTC date>T10:00:00Z", which fall outside the local day
every evening west of Greenwich; they now anchor to local noon. The new helper
first passed its day offset through argv, where node parsed a leading "-1" as
one of its own options and left YESTERDAY empty -- Test 5 kept passing because
an empty timestamp is excluded exactly like a stale one. It goes through the
environment instead.
Same defect as the gather. `today` came from toISOString(), so from 19:00 local
onward in UTC-5 every date written here belonged to tomorrow. It surfaces as
"Última sesión: <tomorrow>" in each project's context.md, which the gather
injects into the agent's context at session start.

The harm is not only cosmetic. The proposals file keys on session_date, so
evening work started a new day instead of continuing the current one -- and the
file is rewritten wholesale on each run, so that day's earlier proposals were
dropped sooner than intended.

Changing only that line would introduce a subtler bug: priorPropDates built its
day set from `p.proposed_at.slice(0, 10)`, a UTC date. Mixing UTC-derived and
local days in one Set makes a single local day count twice, and that Set feeds
the "3+ distinct days" threshold that promotes a repetition proposal. Two
proposals from the same local evening (say 10:00 and 22:00 in UTC-5) would have
counted as two days. Both sides now go through localDayOf(), which converts the
instant instead of slicing it.

proposed_at stays UTC on purpose: it is an instant, not a day.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant