From 5b8f504ce35fdcc6c55323301fd82f5f936035e2 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:20:25 +0200 Subject: [PATCH] fix(studio): preserve restore times across date edits FE-4129 (#51613) ## Problem Changing the restore date resets the entered time, and the repeated hour at the end of daylight saving time can resolve to the wrong occurrence. Both can change the submitted recovery timestamp. ## Fix Store the recovery point as a Unix timestamp and preserve its occurrence when editing an ambiguous local time. Calendar edits carry the entered hours, minutes, and seconds onto the selected day, applying that day's timezone offset and retaining the existing backup-range clamping. This focuses on date edits and repeated-hour handling from #48903. It changes only the PITR form, its utilities, and regression tests. Related to [FE-4129](https://linear.app/supabase/issue/FE-4129/r2np-datetime-widget). ## How to test - In PITR or restore-to-new-project, enter a time and select another available calendar day. Expected result: the entered hours, minutes, and seconds remain selected, and the submitted timestamp matches the new date. - Run the PITRForm and PITR.utils Vitest suites. Their fixtures cover both occurrences of the repeated Madrid/New York hour, edits across DST, spring-forward normalization, fractional offsets, and backup boundaries. - Twelve focused regression cases passed under a Madrid browser timezone. Those cases plus the existing selector component test passed under Hong Kong, thirteen tests total. All CI checks passed, including Studio tests, all four Next.js/TanStack E2E shards, typecheck, lint ratchet, formatting, spelling, unused-code checks, and Docker build. --- .../Database/Backups/PITR/PITR.utils.test.ts | 54 +++++++++++ .../Database/Backups/PITR/PITR.utils.ts | 32 ++++++ .../Database/Backups/PITR/PITRForm.test.tsx | 97 +++++++++++++++++++ .../Database/Backups/PITR/PITRForm.tsx | 70 ++++++++----- 4 files changed, 231 insertions(+), 22 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.test.ts create mode 100644 apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.test.tsx diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.test.ts b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.test.ts new file mode 100644 index 00000000000..52692f6abfc --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.test.ts @@ -0,0 +1,54 @@ +import dayjs from 'dayjs' +import { describe, expect, test } from 'vitest' + +import { getRestoreDate, updateRestoreDate } from './PITR.utils' + +describe('restore date editing', () => { + test('normalizes a nonexistent spring-forward time using the existing timezone behavior', () => { + const current = getRestoreDate(dayjs('2026-03-28T01:30:00Z').unix(), 'Europe/Madrid') + const updated = updateRestoreDate(current, '2026-03-29', { h: 2, m: 30, s: 0 }, 'Europe/Madrid') + expect(updated.toISOString()).toBe('2026-03-29T01:30:00.000Z') + expect(updated.format('HH:mm')).toBe('03:30') + }) + + test.each([ + ['Europe/Madrid', '2026-10-25T00:30:00Z', '2026-10-25', 2, '2026-10-25T00:35:17Z'], + ['Europe/Madrid', '2026-10-25T01:30:00Z', '2026-10-25', 2, '2026-10-25T01:35:17Z'], + ['America/New_York', '2026-11-01T05:30:00Z', '2026-11-01', 1, '2026-11-01T05:35:17Z'], + ['America/New_York', '2026-11-01T06:30:00Z', '2026-11-01', 1, '2026-11-01T06:35:17Z'], + ])('preserves repeated hour occurrence in %s at %s', (timezone, instant, date, h, expected) => { + const current = getRestoreDate(dayjs(instant).unix(), timezone) + const updated = updateRestoreDate(current, date, { h, m: 35, s: 17 }, timezone) + expect(updated.toISOString()).toBe(dayjs(expected).toISOString()) + }) + + test.each([ + ['2026-10-26T09:15:30Z', '2026-10-24', '2026-10-24T08:15:30Z'], + ['2026-10-24T08:15:30Z', '2026-10-26', '2026-10-26T09:15:30Z'], + ])('retains the entered time across daylight saving at %s', (instant, date, expected) => { + const current = getRestoreDate(dayjs(instant).unix(), 'Europe/Madrid') + expect( + updateRestoreDate(current, date, { h: 10, m: 15, s: 30 }, 'Europe/Madrid').toISOString() + ).toBe(dayjs(expected).toISOString()) + }) + + test('leaves the repeated hour using the offset for the new time', () => { + const current = getRestoreDate(dayjs('2026-10-25T00:30:00Z').unix(), 'Europe/Madrid') + expect( + updateRestoreDate(current, '2026-10-25', { h: 3, m: 30, s: 0 }, 'Europe/Madrid').toISOString() + ).toBe('2026-10-25T02:30:00.000Z') + }) + + test('retains fractional timezone offsets', () => { + const current = getRestoreDate(dayjs('2026-10-24T08:30:00Z').unix(), 'Asia/Kathmandu') + expect(current.format('HH:mm')).toBe('14:15') + expect( + updateRestoreDate( + current, + '2026-10-23', + { h: 14, m: 15, s: 0 }, + 'Asia/Kathmandu' + ).toISOString() + ).toBe('2026-10-23T08:30:00.000Z') + }) +}) diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.ts b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.ts index 436630b98bf..c299cc45bdd 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.ts +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITR.utils.ts @@ -46,3 +46,35 @@ export function constrainDateToRange(current: dayjs.Dayjs, lower: dayjs.Dayjs, u } return current } + +export function getRestoreDate(unix: number, timezone: string) { + const instant = dayjs.unix(unix).utc() + const offset = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'longOffset', + }) + .formatToParts(instant.toDate()) + .find((part) => part.type === 'timeZoneName')?.value + const match = offset?.match(/^GMT([+-])(\d{2}):(\d{2})$/) + if (!match) return instant + + const minutes = Number(match[2]) * 60 + Number(match[3]) + return instant.utcOffset(match[1] === '-' ? -minutes : minutes) +} + +export function updateRestoreDate( + current: dayjs.Dayjs, + date: string, + time: Time, + timezone: string +) { + const wallTime = dayjs.utc(date).hour(time.h).minute(time.m).second(time.s) + const candidate = getRestoreDate( + wallTime.subtract(current.utcOffset(), 'minute').unix(), + timezone + ) + const format = 'YYYY-MM-DD HH:mm:ss' + if (candidate.format(format) === wallTime.format(format)) return candidate + + return getRestoreDate(dayjs.tz(wallTime.format(format), timezone).unix(), timezone) +} diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.test.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.test.tsx new file mode 100644 index 00000000000..017efe136f9 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { PITRForm } from './PITRForm' +import { customRender } from '@/tests/lib/custom-render' + +mockAnimationsApi() + +beforeEach(() => { + vi.spyOn(dayjs.tz, 'guess').mockReturnValue('Europe/Madrid') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('PITRForm', () => { + test('clamps retained time to the earliest available backup on a selected boundary day', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + const earliest = dayjs('2026-10-23T12:00:00Z').unix() + customRender( + + ) + await user.click(screen.getByRole('button', { name: /October 23rd, 2026/ })) + expect(screen.getByRole('textbox', { name: 'Hours' })).toHaveValue('14') + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ recoveryTimeTargetUnix: earliest }) + ) + }) + + test('preserves entered time when changing the date across daylight saving time', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + customRender( + + ) + + await user.clear(screen.getByRole('textbox', { name: 'Minutes' })) + await user.type(screen.getByRole('textbox', { name: 'Minutes' }), '12') + await user.tab() + await user.click(screen.getByRole('button', { name: /October 24th, 2026/ })) + + expect(screen.getByRole('textbox', { name: 'Hours' })).toHaveValue('10') + expect(screen.getByRole('textbox', { name: 'Minutes' })).toHaveValue('12') + expect(screen.getByRole('textbox', { name: 'Seconds' })).toHaveValue('30') + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + recoveryTimeTargetUnix: dayjs('2026-10-24T08:12:30Z').unix(), + recoveryTimeString: '24 Oct 2026 10:12:30', + }) + ) + }) + + test('submits the second occurrence of a repeated hour and retains it when editing seconds', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + customRender( + + ) + + expect(screen.getByText('Earliest backup available for this date')).toBeVisible() + expect(screen.getByRole('textbox', { name: 'Hours' })).toHaveValue('02') + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ recoveryTimeTargetUnix: dayjs('2026-10-25T01:30:30Z').unix() }) + ) + + await user.clear(screen.getByRole('textbox', { name: 'Seconds' })) + await user.type(screen.getByRole('textbox', { name: 'Seconds' }), '17') + await user.tab() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ + recoveryTimeTargetUnix: dayjs('2026-10-25T01:30:17Z').unix(), + recoveryTimeString: '25 Oct 2026 02:30:17', + }) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.tsx index 2a1dedc48a2..3d8a00fe4a9 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITRForm.tsx @@ -8,6 +8,8 @@ import { formatNumberToTwoDigits, getClientTimezone, getDatesBetweenRange, + getRestoreDate, + updateRestoreDate, } from './PITR.utils' import TimeInput from './TimeInput' import { TimezoneSelection } from './TimezoneSelection' @@ -35,20 +37,37 @@ export function PITRForm({ disabled = false, }: Props) { const [selectedTimezone, setSelectedTimezone] = useState(getClientTimezone()) - const earliestAvailableBackup = dayjs - .unix(earliestAvailableBackupUnix ?? 0) - .tz(selectedTimezone.utc[0]) - const latestAvailableBackup = dayjs - .unix(latestAvailableBackupUnix ?? 0) - .tz(selectedTimezone.utc[0]) - const earliestAvailableBackupAsDate = earliestAvailableBackup.toDate() - const latestAvailableBackupAsDate = latestAvailableBackup.toDate() + const earliestAvailableBackup = getRestoreDate( + earliestAvailableBackupUnix ?? 0, + selectedTimezone.utc[0] + ) + const latestAvailableBackup = getRestoreDate( + latestAvailableBackupUnix ?? 0, + selectedTimezone.utc[0] + ) + const earliestAvailableBackupAsDate = new Date( + earliestAvailableBackup.year(), + earliestAvailableBackup.month(), + earliestAvailableBackup.date() + ) + const latestAvailableBackupAsDate = new Date( + latestAvailableBackup.year(), + latestAvailableBackup.month(), + latestAvailableBackup.date() + ) - const [selectedDateRaw, setSelectedDateRaw] = useState(latestAvailableBackup.toDate()) + const [selectedUnix, setSelectedUnix] = useState(latestAvailableBackupUnix) - const selectedDate = dayjs(selectedDateRaw).tz(selectedTimezone.utc[0], true) - const isSelectedOnEarliestDay = selectedDate.isSame(earliestAvailableBackup, 'day') - const isSelectedOnLatestDay = selectedDate.isSame(latestAvailableBackup, 'day') + const selectedDate = getRestoreDate(selectedUnix, selectedTimezone.utc[0]) + const selectedCalendarDate = new Date( + selectedDate.year(), + selectedDate.month(), + selectedDate.date() + ) + const isSelectedOnEarliestDay = + selectedDate.format('YYYY-MM-DD') === earliestAvailableBackup.format('YYYY-MM-DD') + const isSelectedOnLatestDay = + selectedDate.format('YYYY-MM-DD') === latestAvailableBackup.format('YYYY-MM-DD') const availableDates = getDatesBetweenRange(earliestAvailableBackup, latestAvailableBackup) const selectedTime = { @@ -83,12 +102,17 @@ export function PITRForm({ ) const onUpdateDate = (date: Date) => { - setSelectedDateRaw( + setSelectedUnix( constrainDateToRange( - dayjs(date).tz(selectedTimezone.utc[0], true), + updateRestoreDate( + selectedDate, + dayjs(date).format('YYYY-MM-DD'), + selectedTime, + selectedTimezone.utc[0] + ), earliestAvailableBackup, latestAvailableBackup - ).toDate() + ).unix() ) } @@ -131,7 +155,7 @@ export function PITRForm({ { - const newDate = dayjs(selectedDateRaw) - .set('hour', h) - .set('minute', m) - .set('second', s) - - setSelectedDateRaw(newDate.toDate()) + setSelectedUnix( + updateRestoreDate( + selectedDate, + selectedDate.format('YYYY-MM-DD'), + { h, m, s }, + selectedTimezone.utc[0] + ).unix() + ) }} />