Skip to content

fix: group expenses by their stored calendar day - #635

Merged
scastiel merged 7 commits into
spliit-app:mainfrom
SomSamantray:fix/expense-date-grouping-timezone
Sep 13, 2026
Merged

scastiel merged 7 commits into
spliit-app:mainfrom
SomSamantray:fix/expense-date-grouping-timezone

Conversation

@SomSamantray

Copy link
Copy Markdown
Contributor

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.expenseDate is a DATE column, which arrives as a UTC-midnight instant. The expense list parsed it with dayjs(...) in local time, so anywhere west of UTC it landed on the previous day. formatDateOnly already 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 into src/lib/date-groups.ts so 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 runs npm run test:tz at TZ=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.ts is 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

SomSamantray and others added 7 commits September 11, 2026 23:50
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
@scastiel
scastiel merged commit b695816 into spliit-app:main Sep 13, 2026
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.

"Last Month" wrongly put.

2 participants