Repository navigation
fix: group expenses by their stored calendar day - #635
Merged
scastiel merged 7 commits intoSep 13, 2026
Merged
Conversation
A DATE column value is carried at UTC midnight, so its calendar day has to be read from the UTC components. Pulling that conversion out of formatDateOnly gives the expense list one shared primitive instead of a second copy of the same off-by-one guard. Refs spliit-app#202
The expense list parsed expenseDate with dayjs(), which reads a DATE column's UTC-midnight value in the local timezone. West of UTC that moves it to the previous day, so an expense dated the 1st was filed under "Last month" while its card still showed the 1st. formatDateOnly already guarded the display path against this; the grouping path was missed. - add dateOnlyToLocalDate and reuse it in formatDateOnly - move the expense grouping into date-groups.ts, which is where the list grouping helpers live, and take `today` as a parameter - convert expenseDate with dateOnlyToLocalDate before classifying The bug is one-directional, so a UTC test runner takes the passing side and cannot observe it. Added npm run test:tz to rerun the suite at UTC-7 and wired it into CI; both new cases were confirmed failing before the conversion. Fixes spliit-app#202
…oup types Simplification pass over the timezone fix: - delete expense-list-fetch-action.ts, whose only importer was removed when the grouping moved out of the component; getGroupExpenses in lib/api is still used by the tRPC list/balances/stats procedures - make EXPENSE_GROUPS `as const` now that it is exported and shared - replace a duplicated dateOnlyToLocalDate case with a January boundary case Refs spliit-app#202
Review found the moved classifier's THIS_WEEK branch had no test, and that getGroupedExpensesByDate was only ever exercised with a single expense, so its bucket-accumulation path was unverified. Both were required scenarios in the plan's test list. date-groups.ts now reports 100% line coverage. Refs spliit-app#202
The CSV export still read the UTC-midnight DATE value through local getters, so on a server west of UTC the exported date was one day earlier than the one shown in the UI. Route it through dateOnlyToLocalDate like the card and the grouping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLTAXMbWBHQwWoZu46cBCx
`expenses` was a fresh `flatMap` array on every render, so the `useMemo` around the date bucketing never hit and the whole list was re-bucketed on each intersection or search-text change. Derive it from `data` with `useMemo` so the bucketing only reruns when a page arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLTAXMbWBHQwWoZu46cBCx
Guarding the DATE-column tests by rerunning the whole suite under `TZ=America/Los_Angeles` in CI left `npm test` vacuous for them on any host at or east of UTC, relied on a shell-only `TZ=` prefix, paid for a second full pass, and never exercised the east-of-UTC failure the fix also addresses (a UTC-midnight value parsed locally lands at midday, filing today's expense under "Upcoming" until noon). A test file only sees a copy of `process.env`, so the timezone cannot be switched per test. Instead a thin jsdom environment pins the worker's real `process.env.TZ` from `testEnvironmentOptions.tz`, and the config declares two projects, America/Los_Angeles and Pacific/Auckland, so one `npm test` is deterministic on every machine and covers both directions. The suite runs in under a second, so nothing is scoped to date tests. Adds the missing east-of-UTC regression test (a `today` with a time of day) and drops the `test:tz` script and the extra CI step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLTAXMbWBHQwWoZu46cBCx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An expense dated the 1st of a month could appear under Last month while its own row still showed the 1st. Expenses are now grouped by the calendar day stored on them, so the section header and the date on the row agree.
Expense.expenseDateis aDATEcolumn, which arrives as a UTC-midnight instant. The expense list parsed it withdayjs(...)in local time, so anywhere west of UTC it landed on the previous day.formatDateOnlyalready guarded the display path against this; grouping was missed, which is why one expense could show two different dates on the same screen.The fix extracts the conversion into
dateOnlyToLocalDate, routes display and grouping through it, and moves the bucketing out of the client component intosrc/lib/date-groups.tsso it is unit-testable.Testing
The defect is one-directional: a UTC-midnight value only ever shifts backwards. A UTC runner therefore takes the passing side, and the Playwright suite pins
timezoneId: 'UTC'. CI now also runsnpm run test:tzatTZ=America/Los_Angeles. Reverting the conversion makes that run fail two assertions while the UTC run stays green, so the gate is not vacuous.date-groups.tsis at 100% line coverage, and 183 tests pass under both timezones.The same class of defect remains in the CSV export's local
formatDate; it is deliberately out of scope here.Fixes #202