Skip to content

fix(home): give the calendar widget and the header one day boundary - #1953

Merged
h4yfans merged 3 commits into
mainfrom
home-calendar-day-boundary
Sep 2, 2026
Merged

fix(home): give the calendar widget and the header one day boundary#1953
h4yfans merged 3 commits into
mainfrom
home-calendar-day-boundary

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

The Home board built "today" twice and the two answers disagreed. todayCalendarRange took the local date components and pinned them to T00:00:00.000Z; the header's todayRangeIso used a local setHours(0, 0, 0, 0) with an inclusive 23:59:59.999 end. At any non-zero UTC offset a band of hours fell inside one window and outside the other, so the header advertised a count the widget below it had no row for. At UTC-7 the gap is the local evening, at UTC+3 it is local 00:00 to 03:00.

Both sides now call one helper, localDayRange(date) in apps/desktop/src/renderer/src/lib/local-day-range.ts, over the YYYY-MM-DD string useToday already hands them. The two old implementations are deleted rather than adjusted to agree, which is the point of the ticket: two agreeing copies drift again. The header also moves from new Date() to useToday(), so its count rolls over at midnight alongside the widget instead of waiting for the next re-render.

Local midnight is the correct boundary, and not only because "today" is the user's wall clock. The main process already treats the window as local: toLocalInstant and toLocalAllDayEnd (apps/desktop/src/main/calendar/projection.ts:34-43) project every date-only source at local midnight, and getLocalDueDateRange (:45-59) collapses the requested instants back to local calendar dates to match tasks.due_date. Under the UTC-pinned window that collapse spanned two local days: at UTC-7 today's widget asked for yesterday's and today's task due dates, at UTC+9 for today's and tomorrow's. Local midnight makes it exact. The end is built as new Date(y, m, d + 1) rather than start + 24h, so a 23- or 25-hour DST day resolves through the platform; that is the same construction use-today.ts and toLocalAllDayEnd already use.

The ranges being identical makes the react-query key identical, so the board now fetches the day once instead of twice, and one useCalendarChangeEvents invalidation covers both. The stale comment claiming a shared key is gone with the function that carried it; the E2E spec's "pinned to UTC hours" note is corrected too.

Two behaviour changes worth a reviewer's eye, both in negative-offset zones, both concerning all-day rows stored at UTC midnight:

  • A Google all-day event with a NULL end_at was included before and is dropped now, because the events predicate falls back to coalesce(end_at, start_at) (projection.ts:139, :441) and 00:00Z sorts before a local-midnight window start. Google's API effectively always sends end.date, whose exclusive next-day value keeps normal all-day events in, so this is a narrow tail. Filed separately rather than widening the projection SQL here.
  • Neighbour-day leakage for those same UTC-pinned rows flips from yesterday to tomorrow, and a Google-synced all-day reminder moves from "today" to "yesterday". The latter now agrees with where the calendar grid already draws it via toLocalDateKey.

No schema, IPC contract, settings shape or vault file format changed. packages/contracts is untouched, so no ipc:generate / ipc:check run was needed. Nothing on disk is read or written differently and existing installs need no migration; the change is which instants the renderer asks for.

Closes #1920

Release note

The Home board's event count and its calendar widget now agree on which day is today, so a late-evening or just-after-midnight event is no longer counted in the header while missing from the widget.

Test plan

The bug reproduces first. Against origin/main's four source files, with the new tests in place:

× the header and the widget request the same window
  - "endAt": "2026-09-02T20:59:59.999Z"      (header)
  - "startAt": "2026-09-01T21:00:00.000Z"
  + "endAt": "2026-09-03T00:00:00.000Z"      (widget)
  + "startAt": "2026-09-02T00:00:00.000Z"
× shares one query key, so the board fetches the day once
  → expected "vi.fn()" to be called 1 times, but got 2 times
× a local just-past-midnight event the header counts is one the widget shows
  → Unable to find an element with the text: Investor sync
Tests  3 failed | 1 passed (4)

The three-hour split is this machine at UTC+3. The local-evening case passes here and fails west of UTC, which is the asymmetry the issue describes from the other side. After the fix all four pass.

Timezone coverage. process.env.TZ is inert in the renderer vitest project: it runs on the threads pool, where process.env is a worker copy that never reaches tzset. A probe across four zones read back the same -180 offset every time, and use-today.test.tsx documents the same wall. Because a CI runner sits at UTC, where the two old windows differ only in the final millisecond, an ambient-zone test alone would go green on main in CI. So local-day-range.test.ts runs the helper in a child node with a real TZ (Node strips the types on import, which is why the module is deliberately import-free, with a test guarding that). That covers UTC, America/Los_Angeles (-7), Pacific/Marquesas (-9:30), Asia/Kolkata (+5:30), Australia/Eucla (+8:45) and Pacific/Kiritimati (+14), plus three DST transition days asserted by day length: 23h and 25h in Los Angeles, 24.5h in Australia/Lord_Howe. Host-zone property tests sit alongside it so the file still says something on a machine the matrix cannot enumerate.

Commands run from the worktree on Node v24.16.0 (process.versions.modules 137):

  • pnpm lint — exit 0.
  • pnpm --filter @memry/desktop typecheck:web / typecheck:node / typecheck:test — all exit 0. No file was added to any exclude list; both new test files compile.
  • pnpm check:architecture — passed. pnpm check:contracts — passed.
  • vitest --project renderer over components/home, components/calendar, components/journal, lib, hooks, pages — 313 files, 4854 passed, 0 failed.
  • git diff --check — clean.
  • npx react-doctor@latest . — the one hit in the new file is anti-slop/no-known-value-widening on the declared return type, a repo-wide pattern with 640 existing instances and the same signature the deleted function had. Net neutral.
  • pnpm docs:impact --base origin/main --strictdocs changed on this branch. pnpm docs:build — build complete.
  • pnpm --filter @memry/desktop i18n:check — not run; no user-facing string changed, only a docs paragraph and comments.

Tests added. apps/desktop/src/renderer/src/lib/local-day-range.test.ts (17 cases, the zone matrix and the host-zone properties) and apps/desktop/src/renderer/src/components/home/home-board-day-range.test.tsx (4 cases, mounting the real HomeHeader and the real CalendarWidget: the two request an identical window, one client fetches the day once, and a local-evening and a local-00:30 event each appear in the widget and in the header's "1 event"). calendar-widget.test.tsx had three cases pinning the old UTC literals; they now assert through localDayRange, which keeps their real subject, the midnight rollover, and makes them zone-independent.

E2E, not run here. apps/desktop/tests/e2e/home-calendar-widget-refresh.e2e.ts is edited but not executed; E2E needs an Electron native rebuild, which runs serially under the coordinator. Its seed helper was todayUtcAt, a UTC hour on the local date, which only landed inside the widget's window because the window was UTC-pinned too. It is now todayLocalAt, a local wall-clock hour, which is inside the local day at every offset. All three cases should pass unchanged: the rows are the same, only the seeded instants moved.

The Home board built "today" twice. `todayCalendarRange` took the local date
components and pinned them to `T00:00:00.000Z`; the header's `todayRangeIso`
used a local `setHours(0, 0, 0, 0)`. At every non-zero UTC offset a band of
hours landed in one window and not the other, so the header counted events the
widget had no row for.

Both now call `localDayRange`, one helper over the `YYYY-MM-DD` string
`useToday` already hands them. Local midnight is the right boundary: the main
process projects every date-only source at local midnight and reads the window
back as local dates, so a UTC-pinned window made `getLocalDueDateRange` span
two local days.

The ranges being identical makes the query key identical, so the board fetches
the day once instead of twice.
@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation test labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 1963bf6.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@h4yfans
h4yfans marked this pull request as ready for review September 2, 2026 16:30
@h4yfans
h4yfans merged commit 7091476 into main Sep 2, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Home calendar widget builds its day range from UTC midnight while the header uses local midnight

1 participant