[Review] computeCyclePhase / isoDate anchors on UTC midnight — drifts one calendar day for negative-UTC users #177

Closed
opened 2026-09-14 14:07:04 +00:00 by Mark · 0 comments
Owner

Finding

src/lib/intimate-state-engine.ts:19-30:

function dateOnly(d: Date): Date {
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function isoDate(d: Date): string {
  return dateOnly(d).toISOString().slice(0,10);
}

dateOnly produces UTC midnight, then toISOString() displays in UTC. For a user in UTC-7 (PDT) entering lastPeriodStart = "2026-03-15" as a local date, the engine stores 2026-03-15T00:00:00Z and isoDate displays "2026-03-15" — fine.

But if the user enters "2026-03-15T23:30 local PDT" via any UI control that produces local ISO strings, the storage is 2026-03-16T06:30:00Z, dateOnly becomes 2026-03-16T00:00:00Z, and the user sees their cycle phase anchored on March 16 (UTC date) even though they entered March 15 (local).

The reverse also holds: late-evening local-time entries shift forward by one UTC day.

Internally, diffDays = floor((ref.getTime() - last.getTime()) / DAY_MS) is consistent because it uses absolute timestamps. The bug is purely in the display of phaseStart/phaseEnd and the user's mental model of which day their cycle started.

Repro

In a timezone west of UTC (PDT, PST, AKST, HST):

  1. Set person.cycleTracking = { enabled: true, lastPeriodStart: "2026-03-15T23:30", cycleLength: 28, lutealPhaseLength: 14 } (a string captured late in the local evening).
  2. Compute buildCycleInsight(person, new Date("2026-03-22T20:00 local PDT")).
  3. phaseStart reports "2026-03-16". The user entered March 15.

Suggested fix

  • Construct dateOnly using local-midnight components (new Date(d.getFullYear(), d.getMonth(), d.getDate())) instead of Date.UTC(...).
  • Or display phaseStart/phaseEnd as local-date strings the same way the user input was captured.
  • Add unit tests that exercise the cycle math in non-UTC timezones (use vi.setSystemTime with a non-UTC zone, or parameterize the helper).

Severity

Medium. UX-visible bug for users west of UTC (i.e. North America). The internal day-of-cycle math is correct, but the displayed phase boundaries drift.

Discovered by

Manual code review pass over #168, 2026-09-14.

## Finding `src/lib/intimate-state-engine.ts:19-30`: ```ts function dateOnly(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } function isoDate(d: Date): string { return dateOnly(d).toISOString().slice(0,10); } ``` `dateOnly` produces UTC midnight, then `toISOString()` displays in UTC. For a user in UTC-7 (PDT) entering `lastPeriodStart = "2026-03-15"` as a local date, the engine stores `2026-03-15T00:00:00Z` and `isoDate` displays `"2026-03-15"` — fine. But if the user enters `"2026-03-15T23:30 local PDT"` via any UI control that produces local ISO strings, the storage is `2026-03-16T06:30:00Z`, `dateOnly` becomes `2026-03-16T00:00:00Z`, and the user sees their cycle phase anchored on March 16 (UTC date) even though they entered March 15 (local). The reverse also holds: late-evening local-time entries shift forward by one UTC day. Internally, `diffDays = floor((ref.getTime() - last.getTime()) / DAY_MS)` is consistent because it uses absolute timestamps. The bug is purely in the **display** of `phaseStart`/`phaseEnd` and the user's mental model of which day their cycle started. ## Repro In a timezone west of UTC (PDT, PST, AKST, HST): 1. Set `person.cycleTracking = { enabled: true, lastPeriodStart: "2026-03-15T23:30", cycleLength: 28, lutealPhaseLength: 14 }` (a string captured late in the local evening). 2. Compute `buildCycleInsight(person, new Date("2026-03-22T20:00 local PDT"))`. 3. `phaseStart` reports `"2026-03-16"`. The user entered March 15. ## Suggested fix - Construct `dateOnly` using local-midnight components (`new Date(d.getFullYear(), d.getMonth(), d.getDate())`) instead of `Date.UTC(...)`. - Or display phaseStart/phaseEnd as local-date strings the same way the user input was captured. - Add unit tests that exercise the cycle math in non-UTC timezones (use `vi.setSystemTime` with a non-UTC zone, or parameterize the helper). ## Severity Medium. UX-visible bug for users west of UTC (i.e. North America). The internal day-of-cycle math is correct, but the displayed phase boundaries drift. ## Discovered by Manual code review pass over #168, 2026-09-14.
Mark 2026-09-14 16:02:58 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Mark/kimi_agent_kin_app_sync#177
No description provided.