Conversation
gcooper407
left a comment
There was a problem hiding this comment.
thanks for making those fixes, looks good!
walker-sean
left a comment
There was a problem hiding this comment.
Can we make this standard for how we handle dates in the frontend and backend to avoid this issue in other places
There was a problem hiding this comment.
Pull request overview
Fixes the “start date shifts to next day” timezone bug by standardizing how date-only selections are normalized and persisted (moving toward storing/transporting dates as UTC-midnight for the intended calendar day).
Changes:
- Added shared UTC-midnight date helpers and started applying them across frontend date flows.
- Removed backend “+12 hours” workaround and now parses incoming start dates directly.
- Updated frontend date formatting/Day.js usage and bumped Day.js versions/deps to support the new utilities.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Updates lockfile for Day.js range changes across workspaces. |
| src/shared/src/date-utils.ts | Adds UTC-midnight/date normalization helpers (used by frontend). |
| src/shared/package.json | Adds Day.js dependency to shared package. |
| src/frontend/src/utils/pipes.ts | Adjusts datePipe to avoid previous Date normalization approach. |
| src/frontend/src/utils/datetime.utils.ts | Switches transformDate to Day.js formatting. |
| src/frontend/src/pages/WorkPackageForm/WorkPackageFormView.tsx | Normalizes submitted work package start date. |
| src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx | Normalizes task deadline/start date during edit. |
| src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx | Normalizes task form dates and adjusts DatePicker defaults. |
| src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx | Normalizes DatePicker-selected finance filter dates. |
| src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx | Normalizes DatePicker-selected finance filter dates. |
| src/frontend/src/pages/CalendarPage/UpcomingMeetingsCard.tsx | Normalizes displayed meeting date. |
| src/frontend/src/pages/CalendarPage/CalendarPage.tsx | Normalizes event bucketing and removes prior timezone-offset hacks. |
| src/frontend/src/apis/work-packages.api.ts | Simplifies axios payload posting. |
| src/frontend/package.json | Bumps Day.js version in frontend. |
| src/backend/src/services/work-packages.services.ts | Removes “+12 hours” hack and parses start date directly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx
Show resolved
Hide resolved
| deadline: deadline ? dateToUtcMidnight(deadline) : undefined, | ||
| startDate: startDate ? dateToUtcMidnight(startDate) : undefined, |
There was a problem hiding this comment.
handleEditTask normalizes deadline/startDate with dateToUtcMidnight, but TaskFormModal already normalizes these fields before calling onSubmit. Normalizing twice is not idempotent across timezones (a UTC-midnight Date can appear as the previous day locally, so the second normalization can shift the day). Ensure the UTC-midnight conversion happens exactly once (either here or in the modal).
| deadline: deadline ? dateToUtcMidnight(deadline) : undefined, | |
| startDate: startDate ? dateToUtcMidnight(startDate) : undefined, | |
| deadline: deadline ?? undefined, | |
| startDate: startDate ?? undefined, |
| <DatePicker | ||
| label="End Date" | ||
| value={endDateState} | ||
| minDate={startDateState || undefined} | ||
| shouldDisableDate={(date) => (startDateState ? date < startDateState : false)} | ||
| slotProps={{ | ||
| textField: { | ||
| size: 'small', | ||
| sx: datePickerStyle | ||
| }, | ||
| field: { clearable: true } | ||
| }} | ||
| onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} | ||
| onChange={(newValue: Date | null) => setEndDateState(newValue ? dateToUtcMidnight(newValue) : undefined)} | ||
| /> |
There was a problem hiding this comment.
Same issue for the end date: persisting dateToUtcMidnight(newValue) into endDateState can cause the DatePicker to render an off-by-one day in non-UTC timezones. Keep local DatePicker dates in state (and normalize only when constructing API params), or re-wrap the UTC date into a local calendar-day Date for the picker.
| <DatePicker | ||
| label="Start Date" | ||
| value={startDateState} | ||
| maxDate={endDateState || undefined} | ||
| shouldDisableDate={(date) => (endDateState ? date > endDateState : false)} | ||
| slotProps={{ | ||
| textField: { | ||
| size: 'small', | ||
| sx: datePickerStyle | ||
| }, | ||
| field: { clearable: true } | ||
| }} | ||
| onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} | ||
| onChange={(newValue: Date | null) => setStartDateState(newValue ? dateToUtcMidnight(newValue) : undefined)} | ||
| /> |
There was a problem hiding this comment.
Storing dateToUtcMidnight(newValue) directly into the DatePicker value can make the picker display the wrong day for users not in UTC, since the DatePicker renders using local time. Prefer keeping the local DatePicker selection in state and converting to UTC midnight only when building the API/query params (or convert back to a local calendar-day Date before passing into value).
| field: { clearable: true } | ||
| }} | ||
| onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} | ||
| onChange={(newValue: Date | null) => setEndDateState(newValue ? dateToUtcMidnight(newValue) : undefined)} |
There was a problem hiding this comment.
Same issue for the end date: setting endDateState to dateToUtcMidnight(newValue) can cause the DatePicker to show an off-by-one day depending on timezone. Keep local DatePicker values in state and normalize at the request boundary, or re-wrap UTC dates into local calendar-day Dates for display.
| onChange={(newValue: Date | null) => setEndDateState(newValue ? dateToUtcMidnight(newValue) : undefined)} | |
| onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} |
| handleSubmit((data) => { | ||
| const transformedData: EditTaskFormInput = { | ||
| ...data, | ||
| startDate: data.startDate ? dateToUtcMidnight(data.startDate) : undefined, | ||
| deadline: data.deadline ? dateToUtcMidnight(data.deadline) : undefined | ||
| }; | ||
| onSubmit(transformedData); | ||
| })(e); |
There was a problem hiding this comment.
The modal is converting startDate/deadline to UTC-midnight before calling onSubmit. That changes the meaning of the form values for callers that expect local DatePicker dates (e.g. task creation currently formats the returned Date into a date-only string). It also causes double-normalization in the edit flow (TaskCard normalizes again), which can shift the day backwards in some timezones. Prefer keeping the form values as local Dates and performing UTC normalization at the API boundary (or ensure normalization happens exactly once).
| <DatePicker | ||
| label="Start Date" | ||
| value={startDateState} | ||
| maxDate={endDateState || undefined} | ||
| shouldDisableDate={(date) => (endDateState ? date > endDateState : false)} | ||
| slotProps={{ | ||
| textField: { | ||
| size: 'small', | ||
| sx: datePickerStyle | ||
| }, | ||
| field: { clearable: true } | ||
| }} | ||
| onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} | ||
| onChange={(newValue: Date | null) => setStartDateState(newValue ? dateToUtcMidnight(newValue) : undefined)} | ||
| /> |
There was a problem hiding this comment.
Storing dateToUtcMidnight(newValue) directly into the DatePicker value can make the picker display the previous/next day for users not in UTC, because the picker renders based on the local timezone of the Date object. Consider keeping startDateState as the local DatePicker value and only converting to UTC midnight when building request/query parameters (or convert back to a local “calendar day” Date before passing into value).
| datePipe(new Date(cardDate.getTime() + cardDate.getTimezoneOffset() * 60000)) | ||
| ) ?? [] | ||
| } | ||
| tasks={taskDict.get(datePipe(cardDate)) ?? []} |
There was a problem hiding this comment.
The tasks lookup uses the same datePipe(cardDate) key as events/dayOfWeek. If the card key should represent the user’s selected calendar day (timezone-agnostic), normalize cardDate to UTC midnight (preserving local calendar day) before calling datePipe, otherwise tasks can appear under the wrong day in some timezones.
| * @returns Date object representing today at UTC midnight | ||
| */ | ||
| export const getCurrentUtcMidnight = (): Date => { | ||
| return dayjs().startOf('day').utc().toDate(); |
There was a problem hiding this comment.
getCurrentUtcMidnight does not currently return UTC midnight: dayjs().startOf('day').utc() computes local midnight and then converts that instant to UTC, which can be a non-midnight UTC time. If the intent is “today at 00:00 UTC”, build it in UTC (e.g., start from dayjs.utc() / call .utc() before .startOf('day')).
| return dayjs().startOf('day').utc().toDate(); | |
| return dayjs.utc().startOf('day').toDate(); |
| @@ -76,8 +76,8 @@ export const emDashPipe = (str: string) => { | |||
| */ | |||
| export const datePipe = (date?: Date, includeYear = true) => { | |||
There was a problem hiding this comment.
datePipe’s parameter is typed as Date, but the implementation explicitly handles string values (typeof date === 'string'). This will either be dead code (if TS prevents string callers) or cause type errors where datePipe is called with a string. Consider updating the signature to accept Date | string (or removing the string branch) so the type matches actual usage.
| export const datePipe = (date?: Date, includeYear = true) => { | |
| export const datePipe = (date?: Date | string, includeYear = true) => { |
Changes
Fix start date off-by-one bug by normalizing client-provided dates to the user’s local midnight converted to UTC before persisting.
Notes
This change is timezone agnostic, so if someone in another timezone chooses a day (e.g. 9/24/25) for the start date, this is the date for everyone else.
To Do
Any remaining things that need to get done
Checklist
It can be helpful to check the
ChecksandFiles changedtabs.Please review the contributor guide and reach out to your Tech Lead if anything is unclear.
Please request reviewers and ping on slack only after you've gone through this whole checklist.
yarn.lockchanges (unless dependencies have changed)Closes #1192