Skip to content

Commit 78a69b6

Browse files
authored
[Bug] Fix timezone drift, respect locale + user date-time-locale across pickers (#3646)
1 parent 25254c8 commit 78a69b6

753 files changed

Lines changed: 30772 additions & 48 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/js/src/core/components/date-picker/date-picker.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { useStyles } from './date-picker.styles'
2424
import cn from 'classnames'
2525
import { useFieldWidthOptional } from '@Pimcore/modules/element/dynamic-types/definitions/objects/data-related/providers/field-width/use-field-width'
2626
import { formatDate, formatDateTime } from '@Pimcore/utils/date-time'
27+
import { useSettings } from '@Pimcore/modules/app/settings/hooks/use-settings'
28+
import { isNonEmptyString } from '@Pimcore/utils/type-utils'
2729

2830
export type DatePickerProps = PickerProps & {
2931
value?: DatePickerValueType
@@ -33,10 +35,18 @@ export type DatePickerProps = PickerProps & {
3335
disabled?: boolean
3436
inherited?: boolean
3537
showSuffixIcon?: boolean
38+
/**
39+
* When explicitly `false`, the value is treated as a server-timezone wall-clock so the displayed
40+
* value does not drift with the browser timezone (see `toDayJs`). Defaults to the previous
41+
* absolute-instant behaviour when omitted.
42+
*/
43+
respectTimezone?: boolean
3644
}
3745

3846
const DatePickerComponent = (props: DatePickerProps): React.JSX.Element => {
39-
const value = toDayJs(props.value)
47+
const { timezone } = useSettings()
48+
const serverTimezone = isNonEmptyString(timezone) ? timezone : undefined
49+
const value = toDayJs(props.value, undefined, { respectTimezone: props.respectTimezone, timezone: serverTimezone })
4050
const fieldWidths = useFieldWidthOptional()
4151

4252
const { styles } = useStyles()

assets/js/src/core/components/date-picker/date-range-picker.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useStyles } from '@Pimcore/components/date-picker/date-picker.styles'
2222
import cn from 'classnames'
2323
import { Icon } from '@Pimcore/components/icon/icon'
2424
import { useFieldWidthOptional } from '@Pimcore/modules/element/dynamic-types/definitions/objects/data-related/providers/field-width/use-field-width'
25+
import { formatDate, formatDateTime } from '@Pimcore/utils/date-time'
2526

2627
export type DateRange = [start: Dayjs | null, end: Dayjs | null]
2728
export type DateRangeTargetValue = [start: DatePickerValueType, end: DatePickerValueType]
@@ -72,9 +73,17 @@ export const DateRangePicker = (props: DateRangePickerProps): React.JSX.Element
7273
props.onChange?.(valueFromDayJs(dates, props.outputType, props.outputFormat))
7374
}
7475

76+
const formatDisplayValue = (date: Dayjs): string => {
77+
const timestamp = date.unix()
78+
return props.showTime !== undefined && props.showTime !== false
79+
? formatDateTime({ timestamp, dateStyle: 'short', timeStyle: 'short' })
80+
: formatDate(timestamp)
81+
}
82+
7583
return (
7684
<OriginalDatePicker.RangePicker
7785
{ ...props }
86+
format={ props.format ?? formatDisplayValue }
7887
onChange={ handleChange }
7988
popupClassName={ styles.datePickerDropdown }
8089
rootClassName={ cn(styles.datePicker, props.className, { [styles.inherited]: props.inherited }) }
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* This source file is available under the terms of the
3+
* Pimcore Open Core License (POCL)
4+
* Full copyright and license information is available in
5+
* LICENSE.md which is distributed with this source code.
6+
*
7+
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
8+
* @license Pimcore Open Core License (POCL)
9+
*/
10+
11+
import dayjs from 'dayjs'
12+
import utc from 'dayjs/plugin/utc'
13+
import timezone from 'dayjs/plugin/timezone'
14+
import { toDayJs, fromDayJs, toServerWallClock, formatFilterDate, parseFilterDate } from './date-picker-utils'
15+
16+
dayjs.extend(utc)
17+
dayjs.extend(timezone)
18+
19+
// Pimcore date/datetime timezone semantics: respectTimezone=true means the value is an absolute
20+
// instant rendered in the browser timezone; respectTimezone=false means the value is a wall-clock
21+
// anchored to the server timezone (so the displayed wall-clock does not drift between browsers).
22+
//
23+
// All assertions are independent of the host (browser) timezone the test process runs under,
24+
// except where they intentionally compare against the host-local `dayjs.unix()` rendering (the
25+
// respectTimezone=true case), which is the no-regression property.
26+
27+
// Two reference instants: winter (Vienna = UTC+1) and summer/DST (Vienna = UTC+2).
28+
const WINTER_UNIX = dayjs.utc('2024-01-15T12:00:00Z').unix()
29+
const SUMMER_UNIX = dayjs.utc('2024-07-15T12:00:00Z').unix()
30+
31+
describe('toServerWallClock', () => {
32+
it('renders the instant as the wall-clock of the given server timezone (winter)', () => {
33+
expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'Europe/Vienna').format('YYYY-MM-DD HH:mm'))
34+
.toBe('2024-01-15 13:00') // UTC+1
35+
expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'America/New_York').format('YYYY-MM-DD HH:mm'))
36+
.toBe('2024-01-15 07:00') // UTC-5
37+
expect(toServerWallClock(dayjs.unix(WINTER_UNIX), 'UTC').format('YYYY-MM-DD HH:mm'))
38+
.toBe('2024-01-15 12:00')
39+
})
40+
41+
it('honours DST for the server timezone (summer)', () => {
42+
expect(toServerWallClock(dayjs.unix(SUMMER_UNIX), 'Europe/Vienna').format('YYYY-MM-DD HH:mm'))
43+
.toBe('2024-07-15 14:00') // UTC+2 in summer
44+
})
45+
})
46+
47+
describe('toDayJs — respectTimezone === false (server-timezone wall-clock, no browser drift)', () => {
48+
it('anchors numeric values to the server timezone wall-clock', () => {
49+
const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: 'Europe/Vienna' })
50+
expect(result?.format('YYYY-MM-DD HH:mm')).toBe('2024-01-15 13:00')
51+
})
52+
53+
it('round-trips stably: stored -> display -> naive string -> server-tz parse -> stored', () => {
54+
const serverTz = 'Europe/Vienna'
55+
// Read for display (what the picker shows / edits).
56+
const display = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: serverTz })
57+
// Save: non-respect-timezone fields emit a naive wall-clock string.
58+
const naive = fromDayJs(display, 'dateString', 'YYYY-MM-DD HH:mm')
59+
expect(naive).toBe('2024-01-15 13:00')
60+
// Backend Carbon::parse interprets the naive string in the server timezone.
61+
const recovered = dayjs.tz(naive as string, serverTz).unix()
62+
expect(recovered).toBe(WINTER_UNIX)
63+
})
64+
65+
it('falls back to the absolute instant when no server timezone is configured', () => {
66+
const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: false, timezone: '' })
67+
expect(result?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf())
68+
})
69+
})
70+
71+
describe('toDayJs — respectTimezone !== false (absolute instant in browser timezone)', () => {
72+
it('returns the same instant as dayjs.unix (no regression) when respectTimezone is true', () => {
73+
const result = toDayJs(WINTER_UNIX, undefined, { respectTimezone: true, timezone: 'Europe/Vienna' })
74+
expect(result?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf())
75+
})
76+
77+
it('does not apply the server timezone when respectTimezone is omitted', () => {
78+
expect(toDayJs(WINTER_UNIX)?.valueOf()).toBe(dayjs.unix(WINTER_UNIX).valueOf())
79+
expect(toDayJs(WINTER_UNIX, undefined, { timezone: 'Europe/Vienna' })?.valueOf())
80+
.toBe(dayjs.unix(WINTER_UNIX).valueOf())
81+
})
82+
})
83+
84+
describe('toDayJs — non-numeric inputs are unchanged', () => {
85+
it('passes through dayjs values', () => {
86+
const d = dayjs.unix(WINTER_UNIX)
87+
expect(toDayJs(d, undefined, { respectTimezone: false, timezone: 'Europe/Vienna' })).toBe(d)
88+
})
89+
90+
it('parses strings with the given format and returns null for nullish', () => {
91+
expect(toDayJs('2024-01-15', 'YYYY-MM-DD')?.format('YYYY-MM-DD')).toBe('2024-01-15')
92+
expect(toDayJs(null)).toBeNull()
93+
expect(toDayJs()).toBeNull()
94+
})
95+
})
96+
97+
describe('formatFilterDate', () => {
98+
// The DatePicker filter component emits a browser-local timestamp (seconds) — i.e. browser-local
99+
// midnight of the day the user clicked, as produced by fromDayJs's 'timestamp' branch. Mock that
100+
// shape directly so the test is independent of the host tz the jest runtime uses.
101+
const tsBrowserLocalMidnight = new Date(2026, 2, 15).getTime() / 1000
102+
103+
it('returns null for null', () => {
104+
expect(formatFilterDate(null, true)).toBeNull()
105+
expect(formatFilterDate(null, false)).toBeNull()
106+
})
107+
108+
it('respectTimezone=false emits the picked calendar day as UTC ISO 8601', () => {
109+
expect(formatFilterDate(tsBrowserLocalMidnight, false)).toBe('2026-03-15T00:00:00Z')
110+
})
111+
112+
it('respectTimezone=true emits an ISO 8601 string with browser offset (instant)', () => {
113+
const out = formatFilterDate(tsBrowserLocalMidnight, true)!
114+
// ISO includes the date, "T", time, and a numeric offset or Z.
115+
expect(out).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-]\d{2}:\d{2}|Z)$/)
116+
// Round-trips back to the same instant when parsed.
117+
expect(dayjs(out).unix()).toBe(tsBrowserLocalMidnight)
118+
})
119+
})
120+
121+
describe('parseFilterDate (read-back round-trip)', () => {
122+
// Pins the regression: stored UTC ISO must not shift the picker day in browsers west of UTC.
123+
const tsBrowserLocalMidnight = new Date(2026, 2, 15).getTime() / 1000
124+
125+
it('returns null for null', () => {
126+
expect(parseFilterDate(null)).toBeNull()
127+
})
128+
129+
it('round-trips a respect=false stored value back to the originally picked day', () => {
130+
const stored = formatFilterDate(tsBrowserLocalMidnight, false)!
131+
expect(stored).toBe('2026-03-15T00:00:00Z')
132+
const recovered = parseFilterDate(stored)!
133+
expect(dayjs.unix(recovered).format('YYYY-MM-DD')).toBe('2026-03-15')
134+
})
135+
136+
it('round-trips a respect=true stored value back to the originally picked day', () => {
137+
const stored = formatFilterDate(tsBrowserLocalMidnight, true)!
138+
const recovered = parseFilterDate(stored)!
139+
expect(dayjs.unix(recovered).format('YYYY-MM-DD')).toBe('2026-03-15')
140+
})
141+
142+
it('ignores any offset or Z marker on the stored value (calendar day only)', () => {
143+
expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00Z')!).format('YYYY-MM-DD')).toBe('2026-03-15')
144+
expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00-04:00')!).format('YYYY-MM-DD')).toBe('2026-03-15')
145+
expect(dayjs.unix(parseFilterDate('2026-03-15T00:00:00+09:00')!).format('YYYY-MM-DD')).toBe('2026-03-15')
146+
})
147+
})
148+
149+
describe('fromDayJs — unchanged save behaviour', () => {
150+
it('formats a dateString with the supplied output format', () => {
151+
expect(fromDayJs(dayjs('2024-01-15 13:30'), 'dateString', 'YYYY-MM-DD HH:mm')).toBe('2024-01-15 13:30')
152+
})
153+
154+
it('returns null for null', () => {
155+
expect(fromDayJs(null, 'dateString', 'YYYY-MM-DD')).toBeNull()
156+
})
157+
158+
it('emits unix seconds (start of day) for the timestamp output type', () => {
159+
const value = dayjs('2024-01-15 13:30')
160+
const ts = fromDayJs(value, 'timestamp') as number
161+
expect(ts).toBe(new Date(2024, 0, 15).getTime() / 1000)
162+
})
163+
})

assets/js/src/core/components/date-picker/utils/date-picker-utils.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,76 @@
99
*/
1010

1111
import dayjs, { type Dayjs } from 'dayjs'
12+
import utc from 'dayjs/plugin/utc'
13+
import timezone from 'dayjs/plugin/timezone'
14+
import { isNonEmptyString } from '@Pimcore/utils/type-utils'
15+
16+
// Extend dayjs with timezone support (idempotent; mirrors use-date-converter.ts)
17+
dayjs.extend(utc)
18+
dayjs.extend(timezone)
1219

1320
export type DatePickerValueType = string | number | Dayjs | null
1421
export type OutputType = 'dateString' | 'timestamp' | 'dayjs'
15-
export const toDayJs = (value?: DatePickerValueType | unknown, format?: string): Dayjs | null => {
22+
23+
export interface ToDayJsOptions {
24+
/**
25+
* When explicitly `false` the value is treated as a timezone-agnostic wall-clock anchored to the
26+
* server timezone (Pimcore semantics for date `columnType:'date'` / datetime `respectTimezone:false`).
27+
* The stored instant is rendered in `timezone` and handed to the picker as a browser-local dayjs
28+
* carrying those wall-clock fields, so the displayed wall-clock no longer drifts with the browser
29+
* timezone. Any other value keeps the previous behaviour (display the absolute instant locally).
30+
*/
31+
respectTimezone?: boolean | null
32+
/** Server timezone, e.g. from `useSettings().timezone`. */
33+
timezone?: string
34+
}
35+
36+
/**
37+
* Returns a browser-local dayjs whose wall-clock fields equal the given instant rendered in
38+
* `timezone`. Used to anchor non-respect-timezone date fields to the server timezone.
39+
*/
40+
export const toServerWallClock = (instant: Dayjs, timezone: string): Dayjs =>
41+
dayjs(instant.tz(timezone).format('YYYY-MM-DDTHH:mm:ss'))
42+
43+
/**
44+
* Serialise a date-picker timestamp (browser-local seconds) for a grid filter request:
45+
* - respectTimezone=true → ISO 8601 with the browser offset (absolute-instant semantics).
46+
* - respectTimezone=false → the picked calendar day pinned to UTC midnight as ISO 8601. The
47+
* generic-data-index pipeline indexes naive `date` / `datetime`
48+
* values into OpenSearch without an offset (which OS interprets as
49+
* UTC), so anchoring the filter to UTC keeps the query window aligned
50+
* with the indexed instants regardless of the server timezone.
51+
*/
52+
export const formatFilterDate = (timestamp: number | null, respectTimezone: boolean): string | null => {
53+
if (timestamp === null) {
54+
return null
55+
}
56+
const dj = dayjs.unix(timestamp)
57+
if (respectTimezone) {
58+
return dj.format()
59+
}
60+
return `${dj.format('YYYY-MM-DD')}T00:00:00Z`
61+
}
62+
63+
/**
64+
* Inverse of `formatFilterDate`. Slices the calendar prefix so the read-back picker shows the
65+
* picked day regardless of any offset / `Z` marker on the stored value.
66+
*/
67+
export const parseFilterDate = (dateStr: string | null): number | null => {
68+
if (dateStr === null) {
69+
return null
70+
}
71+
return dayjs(dateStr.slice(0, 10), 'YYYY-MM-DD').unix()
72+
}
73+
74+
export const toDayJs = (value?: unknown, format?: string, options?: ToDayJsOptions): Dayjs | null => {
1675
if (dayjs.isDayjs(value)) {
1776
return value
1877
}
1978
if (typeof value === 'number') {
79+
if (options?.respectTimezone === false && isNonEmptyString(options.timezone)) {
80+
return toServerWallClock(dayjs.unix(value), options.timezone)
81+
}
2082
return dayjs.unix(value)
2183
}
2284
if (typeof value === 'string') {

assets/js/src/core/modules/element/dynamic-types/definitions/field-filters/components/dynamic-type-field-filter-date-component.tsx

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
*/
1010

1111
import React from 'react'
12-
import dayjs from 'dayjs'
1312
import { Select } from '@Pimcore/components/select/select'
1413
import { Flex } from '@Pimcore/components/flex/flex'
1514
import { DatePicker } from '@Pimcore/components/date-picker/date-picker'
1615
import { DateRangePicker } from '@Pimcore/components/date-picker/date-range-picker'
1716
import { useDynamicFilter } from '@Pimcore/components/dynamic-filter/provider/use-dynamic-filter'
1817
import { t } from 'i18next'
1918
import { type AbstractFieldFilterDefinition } from '../dynamic-type-field-filter-abstract'
19+
import { formatFilterDate, parseFilterDate } from '@Pimcore/components/date-picker/utils/date-picker-utils'
20+
import { isFieldRespectTimezone } from '../../objects/data-related/types/abstract/dynamic-type-object-data-abstract-date'
2021

2122
export enum DatePickerSettingValue {
2223
ON = 'on',
@@ -25,8 +26,6 @@ export enum DatePickerSettingValue {
2526
AFTER = 'after'
2627
}
2728

28-
const DATE_FORMAT = 'YYYY-MM-DD'
29-
3029
export interface DateValue {
3130
setting: DatePickerSettingValue
3231
from: string | null
@@ -37,7 +36,8 @@ export interface DateValue {
3736
export interface DynamicTypeFieldFilterDateProps extends AbstractFieldFilterDefinition {}
3837

3938
export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilterDateProps): React.JSX.Element => {
40-
const { data: rawData, setData } = useDynamicFilter()
39+
const { data: rawData, setData, type: filterType, config: filterConfig } = useDynamicFilter()
40+
const respectTimezone = isFieldRespectTimezone(filterType, (filterConfig as { fieldDefinition?: { columnType?: string, respectTimezone?: boolean | null } } | undefined)?.fieldDefinition)
4141

4242
const data: DateValue = rawData ?? {
4343
setting: DatePickerSettingValue.ON,
@@ -92,15 +92,11 @@ export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilte
9292
}
9393

9494
const convertValueToISOFormat = (timestamp: number | null): string | null => {
95-
if (timestamp === null) return null
96-
97-
return dayjs.unix(timestamp).format()
95+
return formatFilterDate(timestamp, respectTimezone)
9896
}
9997

10098
const convertISOToTimestamp = (dateStr: string | null): number | null => {
101-
if (dateStr === null) return null
102-
103-
return dayjs(dateStr).startOf('day').unix()
99+
return parseFilterDate(dateStr)
104100
}
105101

106102
const handleDateChange = (field: 'on' | 'from' | 'to', value: string | null): void => {
@@ -146,7 +142,6 @@ export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilte
146142
{currentSetting === DatePickerSettingValue.BETWEEN && (
147143
<DateRangePicker
148144
allowEmpty={ [true, true] }
149-
format={ DATE_FORMAT }
150145
onChange={ (value: unknown) => {
151146
const [newFrom, newTo] = value as [number | null, number | null]
152147

@@ -159,7 +154,6 @@ export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilte
159154
}
160155
{currentSetting !== DatePickerSettingValue.BETWEEN && (
161156
<DatePicker
162-
format={ DATE_FORMAT }
163157
onChange={ (value: unknown) => {
164158
const newValue = typeof value === 'number' ? value : null
165159
const convertedValue = convertValueToISOFormat(newValue)

0 commit comments

Comments
 (0)