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.
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-10-16 16:20:25 +02:00
1 parent cb17091d2d
commit 5b8f504ce3
4 files changed
+231 -22

No files matched your search

@@ -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')
})
})
@@ -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)
}
@@ -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(
<PITRForm
earliestAvailableBackupUnix={earliest}
latestAvailableBackupUnix={dayjs('2026-10-26T09:15:30Z').unix()}
onSubmit={onSubmit}
/>
)
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(
<PITRForm
earliestAvailableBackupUnix={dayjs('2026-10-23T00:00:00Z').unix()}
latestAvailableBackupUnix={dayjs('2026-10-26T09:15:30Z').unix()}
onSubmit={onSubmit}
/>
)
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(
<PITRForm
earliestAvailableBackupUnix={dayjs('2026-10-24T22:30:00Z').unix()}
latestAvailableBackupUnix={dayjs('2026-10-25T01:30:30Z').unix()}
onSubmit={onSubmit}
/>
)
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',
})
)
})
})
@@ -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<Timezone>(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<Date>(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({
<Calendar
mode="single"
required={true}
selected={selectedDateRaw}
selected={selectedCalendarDate}
onSelect={onUpdateDate}
defaultMonth={latestAvailableBackupAsDate}
startMonth={earliestAvailableBackupAsDate}
@@ -206,12 +230,14 @@ export function PITRForm({
}
maximumTime={isSelectedOnLatestDay ? latestAvailableBackupTime : undefined}
onChange={({ h, m, s }) => {
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()
)
}}
/>
</div>