fix(SDK-810): emit local calendar dates in DatePickerField string mode#1630
fix(SDK-810): emit local calendar dates in DatePickerField string mode#1630
Conversation
`formatDateToStringDate` and `normalizeDateToLocal` both extracted year / month / day via `toISOString()`, which returns the UTC interpretation of the Date. For users in UTC+ timezones the local calendar date sits before UTC midnight on the previous day, so each helper shifted the result back by one day. `DatePickerField` chains both helpers in string mode, so the form value (and the date sent to the API) ended up two days behind what the user picked in calendars like UTC+5:30 or UTC+14. Switch both helpers to local-time getters (`getFullYear`, `getMonth`, `getDate`) so they describe the date the user actually sees. This matches the existing `normalizeToISOString` helper and the inline `dateToCalendarDate` in `DatePicker.tsx`, both of which already deliberately avoid `toISOString()` for the same reason. Add a parameterized timezone test suite that pins behavior across UTC, UTC-8 (PST), UTC+5:30 (IST), and UTC+14 (Kiritimati) for both helpers and the DatePickerField round-trip. Made-with: Cursor
There was a problem hiding this comment.
Pull request overview
Fixes DatePickerField string mode producing off-by-one/two-day calendar dates in UTC+ timezones by making date normalization/formatting read local year/month/day rather than routing through toISOString().
Changes:
- Update
formatDateToStringDateto buildYYYY-MM-DDfrom local date parts. - Update
normalizeDateToLocalto return a new local-midnightDateusing local date parts. - Add parameterized tests intended to pin helper behavior across multiple timezones and a string-mode round trip.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/helpers/dateFormatting.ts |
Switches string-date formatting and local-midnight normalization to use local calendar parts; adds documentation. |
src/helpers/dateFormatting.test.ts |
Updates existing normalization tests and adds timezone-parameterized coverage plus a round-trip assertion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Formats a Date as a `YYYY-MM-DD` string using the date's *local* calendar | ||
| * date (year/month/day), so the output reflects the calendar date the user | ||
| * actually picked or stored regardless of the runtime timezone. | ||
| * | ||
| * Reading via `toISOString()` would shift the date by a day for users in | ||
| * UTC+ timezones, where local midnight falls before UTC midnight on the | ||
| * previous calendar day. | ||
| */ |
There was a problem hiding this comment.
formatDateToStringDate now explicitly uses local date parts, but the nearby normalizeToISOString docstring later in this file still claims it is "Unlike formatDateToStringDate (which reads UTC)". Please update that docstring to reflect the new local-time behavior so consumers aren’t misled about which helper is UTC vs local.
There was a problem hiding this comment.
This is a good catch, this is what i was noodling on yesterday. trying to figure out if this formatDateTOStringDate function should be preserved as is for a distinct use case. If we are updating this as seen in this PR, it should likely be reconciled with normalizeToISOString
| }) | ||
|
|
||
| afterAll(() => { | ||
| process.env.TZ = originalTZ |
There was a problem hiding this comment.
afterAll restores process.env.TZ by assignment, but if originalTZ was undefined this will set the env var to the literal string "undefined" and can leak a bogus timezone into later tests. Prefer restoring with delete process.env.TZ when originalTZ is not set, otherwise reassign the saved value.
| process.env.TZ = originalTZ | |
| if (originalTZ === undefined) { | |
| delete process.env.TZ | |
| } else { | |
| process.env.TZ = originalTZ | |
| } |
| { tz: 'America/Los_Angeles', label: 'UTC-8 (PST)' }, | ||
| { tz: 'Asia/Kolkata', label: 'UTC+5:30 (IST)' }, | ||
| { tz: 'Pacific/Kiritimati', label: 'UTC+14' }, |
There was a problem hiding this comment.
The label "UTC-8 (PST)" for America/Los_Angeles is not always correct due to DST (it can be UTC-7). Consider renaming the label to something timezone-name-based (e.g., "America/Los_Angeles" or "Pacific Time") to avoid misleading test output.
serikjensen
left a comment
There was a problem hiding this comment.
Left a question in the channel around this one, i think we need to get a little more clarity around our expected date behavior https://gustohq.slack.com/archives/C071WFJUNF5/p1777474103283499f
| /** | ||
| * Formats a Date as a `YYYY-MM-DD` string using the date's *local* calendar | ||
| * date (year/month/day), so the output reflects the calendar date the user | ||
| * actually picked or stored regardless of the runtime timezone. | ||
| * | ||
| * Reading via `toISOString()` would shift the date by a day for users in | ||
| * UTC+ timezones, where local midnight falls before UTC midnight on the | ||
| * previous calendar day. | ||
| */ |
There was a problem hiding this comment.
This is a good catch, this is what i was noodling on yesterday. trying to figure out if this formatDateTOStringDate function should be preserved as is for a distinct use case. If we are updating this as seen in this PR, it should likely be reconciled with normalizeToISOString
Summary
formatDateToStringDateandnormalizeDateToLocalto read local-time year / month / day instead of routing throughtoISOString(). In UTC+ timezones the UTC representation of a local-midnight Date sits on the previous calendar day, so the old implementations shifted the result back by a day each.DatePickerFieldinisStringModechains both helpers, so the form value (and the date sent to the API) was two days behind what the user picked in calendars like UTC+5:30 (IST) or UTC+14 (Kiritimati).DatePickerFielddoes in string mode.Why
The codebase already knows about this pitfall in two places — they just hadn't been generalized:
src/helpers/dateFormatting.tsalready hadnormalizeToISOString, with this docstring:src/components/Common/UI/DatePicker/DatePicker.tsxhas a privatedateToCalendarDatehelper (lines 29-42) with this comment:This PR brings the two general-purpose helpers in line with that already-documented pattern.
Bug trace (UTC+5:30, our react-aria adapter)
react-ariaemitsDateValue { year: 2024, month: 4, day: 16 }.calendarDateValueToDateconverts tonew Date(2024, 3, 16)= April 16 local midnight (= April 15 18:30 UTC).DatePickerField.handleChange:normalizeDateToLocal(value)callsvalue.toISOString()→"2024-04-15T18:30:00.000Z"→ buildsnew Date(2024, 3, 15)= April 15 local midnight (one day off).formatDateToStringDate(...)calls.toISOString()again on April 15 local →"2024-04-14T18:30:00.000Z"→ returns"2024-04-14"(two days off).The form ends up with
"2024-04-14". After this PR, the chain returns"2024-04-16"in every timezone tested.What changed
src/helpers/dateFormatting.tsformatDateToStringDatenow builds theYYYY-MM-DDstring fromgetFullYear/getMonth/getDate(with zero-padding) instead oftoISOString().split('T')[0].normalizeDateToLocalnow returnsnew Date(date.getFullYear(), date.getMonth(), date.getDate())instead of round-tripping throughtoISOString().src/helpers/dateFormatting.test.tsnormalizeDateToLocaltests that asserted UTC-input behavior with locale-deterministic equivalents (the old assertions were only correct in UTC and would fail elsewhere).describe.eachblock running in UTC, PST, IST, and UTC+14, exercising:formatDateToStringDateagainst local-midnight, late-evening, and early-morning Dates.normalizeDateToLocalno-op behavior on local-midnight Dates and time-zeroing on Dates with a time component.normalizeDateToLocal→formatDateToStringDate) preserving the user-picked calendar date.Behavior change for partner adapters
The new
normalizeDateToLocalinterprets its input in local time. For our defaultreact-ariaadapter (which already passes local-midnight Dates), this is a no-op and the user-visible bug is fixed. For a hypothetical partner adapter that passes UTC-midnight Dates, the result in UTC- timezones now shifts to the previous local calendar day (since UTC midnight there isprevious-day 16:00local). This is consistent with the rest of the date pipeline now reading local — and the prior behavior was already broken for the much more common local-midnight adapter case in UTC+ timezones, so optimizing for the actually-shipped adapter is the right trade-off.Test plan
Automated:
npm run test -- --runpasses (2,340 / 2,340).npm run tscclean.Manual:
TZ="Asia/Kolkata" npm run storybookand pick a future date in anyDatePickerFieldstory (e.g.Forms/DatePickerField). Confirm the field's stored value matches what was clicked.DatePickerField(e.g. employee start date) and verify the date the API receives matches the picked date.Made with Cursor