From 64e43752ffc924f6bd5a3830c193b6d795675064 Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Thu, 15 Jan 2026 11:38:38 +0000 Subject: [PATCH 1/9] fixed so that when a user books leave in a different timezone and the day is different, the holiday is till for day the server is running on --- src/components/Holidays/RequestLeave.tsx | 25 +- tests/RequestLeave.test.tsx | 358 +++++++++++++++++++++++ 2 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 tests/RequestLeave.test.tsx diff --git a/src/components/Holidays/RequestLeave.tsx b/src/components/Holidays/RequestLeave.tsx index 64ba47af..0cdfdabd 100644 --- a/src/components/Holidays/RequestLeave.tsx +++ b/src/components/Holidays/RequestLeave.tsx @@ -44,8 +44,14 @@ const ButtonGroup: React.FC = ({ options, value, onChange, dis } export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeaveProps) { - const [startDate, setStartDate] = useState(new Date()) - const [endDate, setEndDate] = useState(new Date()) + // Helper to create a UTC midnight date from the user's local date (year, month, day) + // This preserves the user's selected day regardless of their timezone + const setToMidnightUTC = (date: Date) => { + return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)) + } + + const [startDate, setStartDate] = useState(() => setToMidnightUTC(new Date())) + const [endDate, setEndDate] = useState(() => setToMidnightUTC(new Date())) const [leaveType, setLeaveType] = useState('Full Day') const [isMultipleDays, setIsMultipleDays] = useState(false) const [totalDays, setTotalDays] = useState(1) @@ -137,9 +143,12 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave mode="single" selected={startDate} onSelect={(date) => { - setStartDate(date) - if (date && (!endDate || date > endDate)) { - setEndDate(date) + if (date) { + const dateMidnight = setToMidnightUTC(date) + setStartDate(dateMidnight) + if (!endDate || date > endDate) { + setEndDate(dateMidnight) + } } }} initialFocus @@ -166,7 +175,11 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave { + if (date) { + setEndDate(setToMidnightUTC(date)) + } + }} disabled={(date) => (startDate ? date < startDate : false)} initialFocus /> diff --git a/tests/RequestLeave.test.tsx b/tests/RequestLeave.test.tsx new file mode 100644 index 00000000..9d26c441 --- /dev/null +++ b/tests/RequestLeave.test.tsx @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import React from 'react' +import { RequestLeave } from '../src/components/Holidays/RequestLeave' + +// Mock toast - use vi.hoisted to ensure mocks are available before vi.mock runs +const { mockToast, routerMock } = vi.hoisted(() => ({ + mockToast: { + error: vi.fn(), + success: vi.fn(), + }, + routerMock: { + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }, +})) + +vi.mock('sonner', () => ({ + toast: mockToast, + Toaster: () => null, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => routerMock, +})) + +describe('RequestLeave', () => { + const mockSubmit = vi.fn() + const remainingDays = 10 + + beforeEach(() => { + mockSubmit.mockReset() + // Default mock response - tests can override this + mockSubmit.mockResolvedValue({ success: true, message: 'Leave submitted' }) + mockToast.error.mockReset() + mockToast.success.mockReset() + Object.values(routerMock).forEach(fn => { + if (typeof fn === 'function' && 'mockReset' in fn) { + fn.mockReset() + } + }) + vi.useRealTimers() + }) + + // Helper to select a date in the calendar popover + const selectDate = async (labelText: string, dayNumber: number) => { + // Find the label and its associated button (next sibling in the flex container) + const label = screen.getByText(labelText) + const container = label.closest('.flex.flex-col')! + const button = within(container as HTMLElement).getByRole('button') + + fireEvent.click(button) + + // Wait for popover to open and find the day button + const calendar = await screen.findByRole('grid') + const dayButtons = within(calendar).getAllByRole('gridcell') + + // Find the day button that matches the day number AND is not from adjacent months + // The 'day-outside' class is used by react-day-picker for adjacent month days + const dayButton = dayButtons.find(btn => { + if (btn.textContent !== String(dayNumber)) return false + // The button inside the cell has the day-outside class + const innerButton = btn.querySelector('button') + if (innerButton && innerButton.classList.contains('day-outside')) return false + // Also check the cell itself + if (btn.classList.contains('day-outside')) return false + return true + }) + + if (dayButton) { + fireEvent.click(dayButton) + } + } + + it('submits correct UTC dates and verifies startDate and endDate are set correctly', async () => { + vi.setSystemTime(new Date(Date.UTC(2024, 5, 15, 12, 0, 0))) // June 15, 2024 + + render() + + // Select start date: June 10 + await selectDate('Start Date', 10) + // Select end date: June 12 + await selectDate('End Date', 12) + + // Submit the form + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + + const formData = mockSubmit.mock.calls[0][0] as FormData + const startDateStr = formData.get('startDate') as string + const endDateStr = formData.get('endDate') as string + + // Verify the dates are UTC midnight + expect(startDateStr).toBe('2024-06-10T00:00:00.000Z') + expect(endDateStr).toBe('2024-06-12T00:00:00.000Z') + + // Verify parsed dates + const startDate = new Date(startDateStr) + const endDate = new Date(endDateStr) + + expect(startDate.getUTCFullYear()).toBe(2024) + expect(startDate.getUTCMonth()).toBe(5) // June (0-indexed) + expect(startDate.getUTCDate()).toBe(10) + expect(startDate.getUTCHours()).toBe(0) + expect(startDate.getUTCMinutes()).toBe(0) + expect(startDate.getUTCSeconds()).toBe(0) + + expect(endDate.getUTCFullYear()).toBe(2024) + expect(endDate.getUTCMonth()).toBe(5) + expect(endDate.getUTCDate()).toBe(12) + expect(endDate.getUTCHours()).toBe(0) + }) + + it('submits correct UTC dates regardless of local timezone offset', async () => { + // Simulate a time that could cause date shift in certain timezones + // e.g., UTC+12 at 23:00 UTC would be next day locally + vi.setSystemTime(new Date(Date.UTC(2024, 5, 15, 23, 0, 0))) + + render() + + await selectDate('Start Date', 20) + await selectDate('End Date', 22) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + + const formData = mockSubmit.mock.calls[0][0] as FormData + const startDateStr = formData.get('startDate') as string + const endDateStr = formData.get('endDate') as string + + // Dates should always be UTC midnight regardless of when submitted + expect(startDateStr).toBe('2024-06-20T00:00:00.000Z') + expect(endDateStr).toBe('2024-06-22T00:00:00.000Z') + }) + + it('resets fields after successful submit', async () => { + vi.setSystemTime(new Date(Date.UTC(2024, 5, 15, 12, 0, 0))) + mockSubmit.mockResolvedValue({ success: true, message: 'Leave approved' }) + + render() + + await selectDate('Start Date', 5) + await selectDate('End Date', 6) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + await waitFor(() => expect(mockToast.success).toHaveBeenCalledWith('Leave approved')) + + // After successful submit, dates should be reset to "Pick a date" + await waitFor(() => { + expect(screen.getAllByText('Pick a date').length).toBeGreaterThanOrEqual(2) + }) + expect(routerMock.refresh).toHaveBeenCalled() + }) + + it('shows error if total days exceed remaining days in current year', async () => { + vi.setSystemTime(new Date(Date.UTC(2024, 5, 15, 12, 0, 0))) + + render() + + // Select 3 days which exceeds remaining 1 day + await selectDate('Start Date', 10) + await selectDate('End Date', 12) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalledWith('You do not have enough leave days remaining.') + }) + expect(mockSubmit).not.toHaveBeenCalled() + }) + + it('submits correct leaveType and duration for half-day leave', async () => { + // June 18, 2024 is a Tuesday (weekday) - using 20:00 UTC so it's still June 18 in UTC-8 + vi.setSystemTime(new Date(Date.UTC(2024, 5, 18, 20, 0, 0))) + + render() + + // The component initializes with today's date for both start and end (single day) + // So we can directly select the leave type without changing dates + + // Select Morning leave type + const morningButton = screen.getByRole('button', { name: /^morning$/i }) + fireEvent.click(morningButton) + + // Wait for the total days to update to 0.5 + await waitFor(() => { + expect(screen.getByText(/total days:/i).textContent).toContain('0.5') + }) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + + const formData = mockSubmit.mock.calls[0][0] as FormData + expect(formData.get('leaveType')).toBe('Morning') + expect(formData.get('duration')).toBe('0.5') + }) + + it('displays correct total days calculation', async () => { + // June 18, 2024 is a Tuesday (weekday) - using 20:00 UTC so it's still June 18 in UTC-8 + vi.setSystemTime(new Date(Date.UTC(2024, 5, 18, 20, 0, 0))) + + render() + + // Initially shows 1 day (today to today) + await waitFor(() => { + expect(screen.getByText(/total days:/i).textContent).toContain('1') + }) + + // Select a 3-day range (Tue 18 to Thu 20 = 3 working days) + await selectDate('Start Date', 18) + await selectDate('End Date', 20) + + // Should show 3 days + await waitFor(() => { + expect(screen.getByText(/total days:/i).textContent).toContain('3') + }) + }) + + it('submits correct UTC dates when user timezone is ahead of system (user: 2026-01-11 01:00, system: 2026-01-10 19:00 UTC)', async () => { + // To properly test timezone differences, we need to set the TZ environment variable + // This test documents the expected behavior when a user in UTC+6 selects a date + // + // Scenario: + // - System UTC time: 2026-01-10 19:00 UTC + // - User in UTC+6 sees: 2026-01-11 01:00 local time + // - User selects January 30, 2026 in the calendar + // - Expected: The submitted date should be 2026-01-30T00:00:00.000Z + // + // Note: vi.setSystemTime() only changes the clock, not the timezone. + // To run this test with a real timezone offset, use: + // TZ='Asia/Dhaka' pnpm vitest run tests/RequestLeave.test.tsx + + // Store original timezone + const originalTZ = process.env.TZ + + // Set timezone to UTC+6 (e.g., Bangladesh/Dhaka) + process.env.TZ = 'Asia/Dhaka' + + // Set system time to 2026-01-10 19:00 UTC (which is 2026-01-11 01:00 in UTC+6) + vi.setSystemTime(new Date(Date.UTC(2026, 0, 10, 19, 0, 0))) + + render() + + // User selects January 30, 2026 for both start and end (single day) + await selectDate('Start Date', 30) + await selectDate('End Date', 30) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + + const formData = mockSubmit.mock.calls[0][0] as FormData + const startDateStr = formData.get('startDate') as string + const endDateStr = formData.get('endDate') as string + + // Both dates should be 2026-01-30 at UTC midnight + expect(startDateStr).toBe('2026-01-30T00:00:00.000Z') + expect(endDateStr).toBe('2026-01-30T00:00:00.000Z') + + // Verify the dates are correct + const startDate = new Date(startDateStr) + const endDate = new Date(endDateStr) + + expect(startDate.getUTCFullYear()).toBe(2026) + expect(startDate.getUTCMonth()).toBe(0) // January (0-indexed) + expect(startDate.getUTCDate()).toBe(30) + expect(startDate.getUTCHours()).toBe(0) + expect(startDate.getUTCMinutes()).toBe(0) + expect(startDate.getUTCSeconds()).toBe(0) + + expect(endDate.getUTCFullYear()).toBe(2026) + expect(endDate.getUTCMonth()).toBe(0) + expect(endDate.getUTCDate()).toBe(30) + expect(endDate.getUTCHours()).toBe(0) + + // Restore original timezone + process.env.TZ = originalTZ + }) + + it('submits correct UTC dates when user timezone is behind system (user: 2026-01-13 16:00, system: 2026-01-14 00:00 UTC)', async () => { + // Scenario: + // - System UTC time: 2026-01-14 00:00 UTC (midnight, start of new day - Wednesday) + // - User in UTC-8 (US Pacific) sees: 2026-01-13 16:00 local time (still Tuesday) + // - User selects January 22, 2026 (Thursday) in the calendar + // - Expected: The submitted date should be 2026-01-22T00:00:00.000Z + // + // This tests that when the user's local day is BEHIND UTC, the correct date is submitted. + // To run this test with a real timezone offset, use: + // TZ='America/Los_Angeles' pnpm vitest run tests/RequestLeave.test.tsx + + // Store original timezone + const originalTZ = process.env.TZ + + // Set timezone to UTC-8 (e.g., US Pacific / Los Angeles) + process.env.TZ = 'America/Los_Angeles' + + // Set system time to 2026-01-14 00:00 UTC (which is 2026-01-13 16:00 in UTC-8, a Tuesday) + vi.setSystemTime(new Date(Date.UTC(2026, 0, 14, 0, 0, 0))) + + render() + + // User selects January 22, 2026 (Thursday) for both start and end (single day) + await selectDate('Start Date', 22) + await selectDate('End Date', 22) + + // Wait for calendar to close and totalDays to update + await waitFor(() => { + expect(screen.getByText(/total days:/i).textContent).toContain('1') + }) + + const submitButton = screen.getByRole('button', { name: /submit request/i }) + fireEvent.click(submitButton) + + await waitFor(() => expect(mockSubmit).toHaveBeenCalled()) + + const formData = mockSubmit.mock.calls[0][0] as FormData + const startDateStr = formData.get('startDate') as string + const endDateStr = formData.get('endDate') as string + + // Both dates should be 2026-01-22 at UTC midnight + expect(startDateStr).toBe('2026-01-22T00:00:00.000Z') + expect(endDateStr).toBe('2026-01-22T00:00:00.000Z') + + // Verify the dates are correct + const startDate = new Date(startDateStr) + const endDate = new Date(endDateStr) + + expect(startDate.getUTCFullYear()).toBe(2026) + expect(startDate.getUTCMonth()).toBe(0) // January (0-indexed) + expect(startDate.getUTCDate()).toBe(22) + expect(startDate.getUTCHours()).toBe(0) + expect(startDate.getUTCMinutes()).toBe(0) + expect(startDate.getUTCSeconds()).toBe(0) + + expect(endDate.getUTCFullYear()).toBe(2026) + expect(endDate.getUTCMonth()).toBe(0) + expect(endDate.getUTCDate()).toBe(22) + expect(endDate.getUTCHours()).toBe(0) + + // Restore original timezone + process.env.TZ = originalTZ + }) +}) \ No newline at end of file From 0da1ce8555bfc7beda38771f0305e3d24aa9a96c Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Tue, 20 Jan 2026 08:14:57 +0000 Subject: [PATCH 2/9] fix: ensure consistency with different timezones --- tests/RequestLeave.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/RequestLeave.test.tsx b/tests/RequestLeave.test.tsx index 9d26c441..fdd15cf6 100644 --- a/tests/RequestLeave.test.tsx +++ b/tests/RequestLeave.test.tsx @@ -28,6 +28,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => routerMock, })) + describe('RequestLeave', () => { const mockSubmit = vi.fn() const remainingDays = 10 From db03fe0668ac4292be95660c9c4475f58ae1643c Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Tue, 20 Jan 2026 08:45:18 +0000 Subject: [PATCH 3/9] fix issue with server/client rendering clash --- src/components/Holidays/RequestLeave.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Holidays/RequestLeave.tsx b/src/components/Holidays/RequestLeave.tsx index 0cdfdabd..51136220 100644 --- a/src/components/Holidays/RequestLeave.tsx +++ b/src/components/Holidays/RequestLeave.tsx @@ -50,8 +50,15 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)) } - const [startDate, setStartDate] = useState(() => setToMidnightUTC(new Date())) - const [endDate, setEndDate] = useState(() => setToMidnightUTC(new Date())) + const [startDate, setStartDate] = useState(undefined) + const [endDate, setEndDate] = useState(undefined) + + // Set initial dates on client only to avoid hydration mismatch + useEffect(() => { + if (startDate === undefined) setStartDate(setToMidnightUTC(new Date())) + if (endDate === undefined) setEndDate(setToMidnightUTC(new Date())) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const [leaveType, setLeaveType] = useState('Full Day') const [isMultipleDays, setIsMultipleDays] = useState(false) const [totalDays, setTotalDays] = useState(1) From d15f6dffe1719486536d18ccce5fcd4fa983d2cb Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Tue, 20 Jan 2026 08:51:34 +0000 Subject: [PATCH 4/9] fix: issue with rendering mismatch server/client --- tests/RequestLeave.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/RequestLeave.test.tsx b/tests/RequestLeave.test.tsx index fdd15cf6..b704ed51 100644 --- a/tests/RequestLeave.test.tsx +++ b/tests/RequestLeave.test.tsx @@ -341,6 +341,7 @@ describe('RequestLeave', () => { const startDate = new Date(startDateStr) const endDate = new Date(endDateStr) + expect(startDate.getUTCFullYear()).toBe(2026) expect(startDate.getUTCMonth()).toBe(0) // January (0-indexed) expect(startDate.getUTCDate()).toBe(22) From 379edd8ed1e572a7a84e9e0e0c2842162f47f716 Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Wed, 21 Jan 2026 08:13:26 +0000 Subject: [PATCH 5/9] fix: further fix for timezone issue --- src/components/Holidays/RequestLeave.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Holidays/RequestLeave.tsx b/src/components/Holidays/RequestLeave.tsx index 51136220..f45cd4cf 100644 --- a/src/components/Holidays/RequestLeave.tsx +++ b/src/components/Holidays/RequestLeave.tsx @@ -50,6 +50,16 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)) } + // Format date using UTC values to avoid hydration mismatch between server/client timezones + const formatDateUTC = (date: Date) => { + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const day = date.getUTCDate() + const month = months[date.getUTCMonth()] + const year = date.getUTCFullYear() + const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th' + return `${month} ${day}${suffix}, ${year}` + } + const [startDate, setStartDate] = useState(undefined) const [endDate, setEndDate] = useState(undefined) @@ -142,7 +152,7 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave )} > - {startDate ? format(startDate, 'PPP') : Pick a date} + {startDate ? formatDateUTC(startDate) : Pick a date} @@ -175,7 +185,7 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave )} > - {endDate ? format(endDate, 'PPP') : Pick a date} + {endDate ? formatDateUTC(endDate) : Pick a date} From 64fd60b1d855b81d29a212a95854ef23fcf1a8f6 Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Wed, 21 Jan 2026 08:40:51 +0000 Subject: [PATCH 6/9] further fixes for hydration issues --- .../Holidays/CalendarView.stories.tsx | 2 + src/components/Holidays/CalendarView.tsx | 40 ++++++++++++------- .../Holidays/HolidayTracker.stories.tsx | 1 + src/components/Holidays/HolidayTracker.tsx | 6 ++- src/components/Holidays/RequestLeave.tsx | 2 +- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/components/Holidays/CalendarView.stories.tsx b/src/components/Holidays/CalendarView.stories.tsx index 5176b912..a414c05f 100644 --- a/src/components/Holidays/CalendarView.stories.tsx +++ b/src/components/Holidays/CalendarView.stories.tsx @@ -91,6 +91,7 @@ const mockHolidays: Holiday[] = [ export const Default: Story = { render: () => { const [currentDate, setCurrentDate] = useState(new Date(2025, 0, 1)) // January 2025 + const today = TimeUtil.toUtcMidnight(new Date(2025, 0, 1)) // Server's "today" const correctedCurrentDate = TimeUtil.toUtcMidnight(currentDate) return ( @@ -98,6 +99,7 @@ export const Default: Story = { currentDate={correctedCurrentDate} setCurrentDate={setCurrentDate} holidays={mockHolidays} + today={today} /> ) }, diff --git a/src/components/Holidays/CalendarView.tsx b/src/components/Holidays/CalendarView.tsx index bb4cc9b7..e32da089 100644 --- a/src/components/Holidays/CalendarView.tsx +++ b/src/components/Holidays/CalendarView.tsx @@ -11,6 +11,7 @@ interface CalendarViewProps { currentDate: Date setCurrentDate: (date: Date) => void holidays: Holiday[] + today: Date // Server-determined "today" to avoid hydration mismatch } interface Holiday { @@ -24,18 +25,25 @@ interface Holiday { leaveType: 'Full Day' | 'Morning' | 'Afternoon' } -export function CalendarView({ currentDate, setCurrentDate, holidays }: CalendarViewProps) { +export function CalendarView({ currentDate, setCurrentDate, holidays, today }: CalendarViewProps) { const [selectedDay, setSelectedDay] = useState(null) + // Helper to compare dates by their UTC year/month/day values + const isSameUtcDay = (a: Date, b: Date) => + a.getUTCFullYear() === b.getUTCFullYear() && + a.getUTCMonth() === b.getUTCMonth() && + a.getUTCDate() === b.getUTCDate() + const firstDayOfMonth = - (new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getDay() + 6) % 7 + (new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth(), 1)).getUTCDay() + 6) % 7 const days = Array.from({ length: 42 }, (_, i) => { const day = new Date( - currentDate.getFullYear(), - currentDate.getMonth(), - i - firstDayOfMonth + 1, - currentDate.getHours(), + Date.UTC( + currentDate.getUTCFullYear(), + currentDate.getUTCMonth(), + i - firstDayOfMonth + 1, + ), ) const filteredHolidays = holidays.filter( (h) => @@ -44,28 +52,32 @@ export function CalendarView({ currentDate, setCurrentDate, holidays }: Calendar ) return { date: day, - isCurrentMonth: day.getMonth() === currentDate.getMonth(), - isToday: day.toDateString() === new Date().toDateString(), + isCurrentMonth: day.getUTCMonth() === currentDate.getUTCMonth(), + isToday: isSameUtcDay(day, today), holidays: filteredHolidays, } }) const prevMonth = () => { - setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)) + setCurrentDate(new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() - 1, 1))) } const nextMonth = () => { - setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)) + setCurrentDate(new Date(Date.UTC(currentDate.getUTCFullYear(), currentDate.getUTCMonth() + 1, 1))) } const setToday = () => { - setCurrentDate(new Date()) + setCurrentDate(TimeUtil.toUtcMidnight(new Date())) } + // Format month/year using UTC values to avoid hydration mismatch + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const monthYearDisplay = `${months[currentDate.getUTCMonth()]} ${currentDate.getUTCFullYear()}` + return (

@@ -139,7 +151,7 @@ export function CalendarView({ currentDate, setCurrentDate, holidays }: Calendar day.isToday && 'bg-indigo-600 font-semibold text-white', )} > - {day.date.getDate()} + {day.date.getUTCDate()} {day.holidays.length > 0 && (
    @@ -189,7 +201,7 @@ export function CalendarView({ currentDate, setCurrentDate, holidays }: Calendar 'flex h-6 w-6 items-center justify-center rounded-full bg-gray-900', )} > - {day.date.getDate()} + {day.date.getUTCDate()} {day.holidays.length} holidays {day.holidays.length > 0 && ( diff --git a/src/components/Holidays/HolidayTracker.stories.tsx b/src/components/Holidays/HolidayTracker.stories.tsx index 5bd5dbda..19bae146 100644 --- a/src/components/Holidays/HolidayTracker.stories.tsx +++ b/src/components/Holidays/HolidayTracker.stories.tsx @@ -29,6 +29,7 @@ export const Default: Story = { args: { // Mock holidays data currentDate: format(new Date(2025, 0, 2), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"), // January 2025 + today: format(new Date(2025, 0, 2), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"), // Today's date for highlighting holidays: [ { id: '1', diff --git a/src/components/Holidays/HolidayTracker.tsx b/src/components/Holidays/HolidayTracker.tsx index 4fb63791..f61c90c1 100644 --- a/src/components/Holidays/HolidayTracker.tsx +++ b/src/components/Holidays/HolidayTracker.tsx @@ -19,7 +19,8 @@ interface HolidayTrackerProps { holidays: Holiday[] leaveApprovals: LeaveRequest[] employees: Employee[] - currentDate: string // ISO 8601 string + currentDate: string // ISO 8601 string - the date being viewed + today: string // ISO 8601 string - server's current date for "today" highlighting currentUser: { grade: string; remainingLeaveDays: number } submitLeaveRequest?: (formData: FormData) => Promise<{ success: boolean; message: string }> approveLeave: (ids: string[]) => Promise<{ success: boolean; message: string }> @@ -34,6 +35,7 @@ export function HolidayTracker({ holidays, currentUser, currentDate, + today, leaveApprovals, employees, submitLeaveRequest, @@ -46,6 +48,7 @@ export function HolidayTracker({ const isLoading = false const parsedCurrentDate = TimeUtil.toUtcMidnight(parseISO(currentDate)) + const parsedToday = TimeUtil.toUtcMidnight(parseISO(today)) const setCurrentDate = async (date: Date) => { const formattedDate = format(date, 'dd-MM-yyyy') @@ -147,6 +150,7 @@ export function HolidayTracker({ currentDate={parsedCurrentDate} setCurrentDate={setCurrentDate} holidays={holidays} + today={parsedToday} /> )} {currentTab === 'Request Leave' && ( diff --git a/src/components/Holidays/RequestLeave.tsx b/src/components/Holidays/RequestLeave.tsx index f45cd4cf..e70d9adf 100644 --- a/src/components/Holidays/RequestLeave.tsx +++ b/src/components/Holidays/RequestLeave.tsx @@ -71,7 +71,7 @@ export function RequestLeave({ remainingDays, submitLeaveRequest }: RequestLeave }, []) const [leaveType, setLeaveType] = useState('Full Day') const [isMultipleDays, setIsMultipleDays] = useState(false) - const [totalDays, setTotalDays] = useState(1) + const [totalDays, setTotalDays] = useState(0) const router = useRouter() useEffect(() => { From bc76d012bd730e242290333f9be54c01470c161c Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Wed, 21 Jan 2026 08:41:44 +0000 Subject: [PATCH 7/9] fix: trigger release --- src/components/Holidays/CalendarView.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Holidays/CalendarView.stories.tsx b/src/components/Holidays/CalendarView.stories.tsx index a414c05f..5e0e1d1e 100644 --- a/src/components/Holidays/CalendarView.stories.tsx +++ b/src/components/Holidays/CalendarView.stories.tsx @@ -88,6 +88,7 @@ const mockHolidays: Holiday[] = [ }, ] + export const Default: Story = { render: () => { const [currentDate, setCurrentDate] = useState(new Date(2025, 0, 1)) // January 2025 From c8ebe202d394ead8419d1f4f676451a5150d048f Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Mon, 26 Jan 2026 08:36:43 +0000 Subject: [PATCH 8/9] further timezone fix --- src/utils/DaysUtil.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/utils/DaysUtil.ts b/src/utils/DaysUtil.ts index fe2bd290..bf95139a 100644 --- a/src/utils/DaysUtil.ts +++ b/src/utils/DaysUtil.ts @@ -1,16 +1,22 @@ import { bankHolidays } from './BankHolidays' export const isDayOff = (date: Date) => { - const isSunday = date.getDay() === 0 - const isSaturday = date.getDay() === 6 - const isBankHoliday = bankHolidays.some((h) => h.toISOString() === date.toISOString()) + const isSunday = date.getUTCDay() === 0 + const isSaturday = date.getUTCDay() === 6 + const isBankHoliday = bankHolidays.some( + (h) => + h.getFullYear() === date.getUTCFullYear() && + h.getMonth() === date.getUTCMonth() && + h.getDate() === date.getUTCDate() + ) return isSunday || isSaturday || isBankHoliday } export const getTotalDaysBetween = (startDate: Date, endDate: Date, isHalfDay = false) => { - const start = new Date(startDate) - const end = new Date(endDate) + // Work with UTC day values to avoid timezone issues + let start = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())) + const end = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())) let count = 0 @@ -23,7 +29,7 @@ export const getTotalDaysBetween = (startDate: Date, endDate: Date, isHalfDay = count++ } - start.setDate(start.getDate() + 1) + start = new Date(start.getTime() + 24 * 60 * 60 * 1000) // Add 1 day in milliseconds } return count @@ -34,6 +40,10 @@ export const getIsMultipleDays = (startDate?: Date, endDate?: Date) => { return false } - const isMultipleDays = startDate.toDateString() !== endDate.toDateString() + // Compare UTC dates to avoid timezone issues + const isMultipleDays = + startDate.getUTCFullYear() !== endDate.getUTCFullYear() || + startDate.getUTCMonth() !== endDate.getUTCMonth() || + startDate.getUTCDate() !== endDate.getUTCDate() return isMultipleDays } From f5fc5b885422744f3ff6f045f54274fa8e7a635a Mon Sep 17 00:00:00 2001 From: Mark Pedley Date: Wed, 4 Feb 2026 09:09:44 +0000 Subject: [PATCH 9/9] fix: handle different timezones --- tests/RequestLeave.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/RequestLeave.test.tsx b/tests/RequestLeave.test.tsx index b704ed51..ce864759 100644 --- a/tests/RequestLeave.test.tsx +++ b/tests/RequestLeave.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea import React from 'react' import { RequestLeave } from '../src/components/Holidays/RequestLeave' -// Mock toast - use vi.hoisted to ensure mocks are available before vi.mock runs +// Mock toast - use vi.hoisted to ensure mocks are available, before vi.mock runs const { mockToast, routerMock } = vi.hoisted(() => ({ mockToast: { error: vi.fn(),