Skip to content

Commit 34d3264

Browse files
committed
[Bug] #1055 — align date/datetime field timezone handling with Pimcore semantics
Pimcore date/datetime fields have two timezone modes: - respectTimezone=true → absolute instant, displayed in the browser timezone - respectTimezone=false → wall-clock anchored to the configured server timezone Studio was reading respect=false fields with dayjs.unix() in browser-local time, so the displayed wall-clock drifted by the browser↔server offset on reload. The respect=true path already behaved correctly. This wires useSettings().timezone + the field's respect-timezone semantics through every surface where these fields are rendered or edited: - DatePicker.tsx: new respectTimezone prop; toDayJs(value, _, {respectTimezone, timezone}) anchors the input to the server wall-clock via dayjs.tz() - date-picker-utils.ts: toServerWallClock helper; formatFilterDate(timestamp, respectTimezone) emits ISO+offset for respect=true, UTC ISO of the picked calendar day for respect=false (aligns with the generic-data-index pipeline, which indexes naive date strings as UTC) - abstract/date/datetime object-data types: propagate respectTimezone; new DateGridCellPreview / DatetimeGridCellPreview render the grid cell with the server timezone when respect=false; fix datetime columnType union - date-time.ts: optional timeZone passed through to Intl - field-filter date component: reads field config via useDynamicFilter and uses formatFilterDate - batch-edit datetime component: reads field config from batchEdit and forwards respectTimezone + outputFormat + showTime to the shared DatePicker; same component handles both date and datetime since the backend FrontendType enum has a single DATETIME entry Tests: 20 new Jest cases in date-picker-utils.test.ts (toServerWallClock / toDayJs / fromDayJs / formatFilterDate) and 5 in date-time.test.ts (timeZone forwarding to Intl). All pure-function tests, fast and host-tz-independent. Companion PR in studio-backend-bundle: pimcore/studio-backend-bundle#1864.
1 parent f4d1d7f commit 34d3264

10 files changed

Lines changed: 356 additions & 18 deletions

File tree

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()
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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 } 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('fromDayJs — unchanged save behaviour', () => {
122+
it('formats a dateString with the supplied output format', () => {
123+
expect(fromDayJs(dayjs('2024-01-15 13:30'), 'dateString', 'YYYY-MM-DD HH:mm')).toBe('2024-01-15 13:30')
124+
})
125+
126+
it('returns null for null', () => {
127+
expect(fromDayJs(null, 'dateString', 'YYYY-MM-DD')).toBeNull()
128+
})
129+
130+
it('emits unix seconds (start of day) for the timestamp output type', () => {
131+
const value = dayjs('2024-01-15 13:30')
132+
const ts = fromDayJs(value, 'timestamp') as number
133+
expect(ts).toBe(new Date(2024, 0, 15).getTime() / 1000)
134+
})
135+
})

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,65 @@
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+
export const toDayJs = (value?: unknown, format?: string, options?: ToDayJsOptions): Dayjs | null => {
1664
if (dayjs.isDayjs(value)) {
1765
return value
1866
}
1967
if (typeof value === 'number') {
68+
if (options?.respectTimezone === false && isNonEmptyString(options.timezone)) {
69+
return toServerWallClock(dayjs.unix(value), options.timezone)
70+
}
2071
return dayjs.unix(value)
2172
}
2273
if (typeof value === 'string') {

assets/js/src/core/modules/element/dynamic-types/definitions/batch-edits/components/datetime/dynamic-type-batch-edit-datetime-component.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,35 @@ import { type AbstractBatchEditDefinition } from '@Pimcore/modules/element/dynam
1414
import { Form } from '@Pimcore/components/form/form'
1515
export interface DynamicTypeBatchEditDatetimeProps extends AbstractBatchEditDefinition {}
1616

17+
/**
18+
* Batch-edit picker for `date` and `datetime` fields. The backend's FrontendType only has DATETIME
19+
* (no separate DATE), so this single component covers both — distinguished at runtime via
20+
* `batchEdit.type` and the field-level config on `batchEdit.config.fieldDefinition`.
21+
*
22+
* Timezone semantics mirror the editor exactly:
23+
* - respectTimezone=true → absolute instant; emit ISO with browser offset on save.
24+
* - respectTimezone=false → server-tz wall-clock; emit a naive string the backend's Carbon::parse
25+
* interprets in the server timezone.
26+
*/
1727
export const DynamicTypeBatchEditDatetimeComponent = ({ batchEdit }: DynamicTypeBatchEditDatetimeProps): React.JSX.Element => {
18-
const { key } = batchEdit
28+
const { key, type } = batchEdit
29+
const fieldDefinition = (batchEdit as { config?: { fieldDefinition?: Record<string, any> } }).config?.fieldDefinition
30+
const isDateOnly = type === 'date'
31+
const respectTimezone = isDateOnly
32+
? fieldDefinition?.columnType !== 'date' // date field: 'date' columnType = wall-clock
33+
: fieldDefinition?.respectTimezone !== false // datetime field: explicit false flips
34+
const outputFormat = respectTimezone
35+
? undefined
36+
: (isDateOnly ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm')
1937

2038
return (
2139
<Form.Item name={ key }>
22-
<DatePicker outputType='timestamp' />
40+
<DatePicker
41+
outputFormat={ outputFormat }
42+
outputType='dateString'
43+
respectTimezone={ respectTimezone }
44+
showTime={ isDateOnly ? undefined : { format: 'HH:mm' } }
45+
/>
2346
</Form.Item>
2447
)
2548
}

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ import { DateRangePicker } from '@Pimcore/components/date-picker/date-range-pick
1717
import { useDynamicFilter } from '@Pimcore/components/dynamic-filter/provider/use-dynamic-filter'
1818
import { t } from 'i18next'
1919
import { type AbstractFieldFilterDefinition } from '../dynamic-type-field-filter-abstract'
20+
import { formatFilterDate } from '@Pimcore/components/date-picker/utils/date-picker-utils'
21+
22+
/**
23+
* - `date` field with `columnType:'date'` → wall-clock, anchored to the server timezone.
24+
* - `date` field with `columnType:'bigint(20)'` (default) → absolute instant.
25+
* - `datetime` field with `respectTimezone:false` → wall-clock, anchored to the server timezone.
26+
* - `datetime` field with `respectTimezone:true` (default) → absolute instant.
27+
*/
28+
const isRespectTimezone = (filterType: string | undefined, fieldDefinition: Record<string, any> | undefined): boolean => {
29+
if (filterType === 'date') {
30+
return fieldDefinition?.columnType !== 'date'
31+
}
32+
return fieldDefinition?.respectTimezone !== false
33+
}
2034

2135
export enum DatePickerSettingValue {
2236
ON = 'on',
@@ -37,7 +51,9 @@ export interface DateValue {
3751
export interface DynamicTypeFieldFilterDateProps extends AbstractFieldFilterDefinition {}
3852

3953
export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilterDateProps): React.JSX.Element => {
40-
const { data: rawData, setData } = useDynamicFilter()
54+
const { data: rawData, setData, type: filterType, config: filterConfig } = useDynamicFilter()
55+
const fieldDefinition: Record<string, any> | undefined = (filterConfig as { fieldDefinition?: Record<string, any> } | undefined)?.fieldDefinition
56+
const respectTimezone = isRespectTimezone(filterType, fieldDefinition)
4157

4258
const data: DateValue = rawData ?? {
4359
setting: DatePickerSettingValue.ON,
@@ -92,9 +108,7 @@ export const DynamicTypeFieldFilterDateComponent = (props: DynamicTypeFieldFilte
92108
}
93109

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

100114
const convertISOToTimestamp = (dateStr: string | null): number | null => {

assets/js/src/core/modules/element/dynamic-types/definitions/objects/data-related/types/abstract/dynamic-type-object-data-abstract-date.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export abstract class DynamicTypeObjectDataAbstractDate extends DynamicTypeObjec
4949
inherited={ props.inherited }
5050
outputFormat={ props.respectTimezone !== false || outputType !== 'dateString' ? undefined : props.outputFormat }
5151
outputType={ outputType }
52+
respectTimezone={ props.respectTimezone ?? undefined }
5253
showTime={ props.showTime }
5354
style={ { maxWidth: toCssDimension(props.defaultFieldWidth.small) } }
5455
value={ props.value }

assets/js/src/core/modules/element/dynamic-types/definitions/objects/data-related/types/dynamic-type-object-data-date.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,23 @@ import { isNumber } from 'lodash'
2424
import { type DynamicTypeFieldFilterAbstract } from '../../../field-filters/dynamic-type-field-filter-abstract'
2525
import { container } from '@Pimcore/app/depency-injection'
2626
import { serviceIds } from '@Pimcore/app/config/services/service-ids'
27+
import { useSettings } from '@Pimcore/modules/app/settings/hooks/use-settings'
28+
import { isNonEmptyString } from '@Pimcore/utils/type-utils'
2729

2830
export type DateObjectDataDefinition = AbstractDateObjectDataDefinition & {
2931
columnType: 'date' | 'bigint(20)'
3032
}
3133

34+
const DateGridCellPreview = ({ value, respectTimezone }: { value: unknown, respectTimezone: boolean }): React.ReactElement => {
35+
const { timezone } = useSettings()
36+
const serverTimezone = isNonEmptyString(timezone) ? timezone : undefined
37+
const formatted = (isNumber(value) || typeof value === 'string')
38+
? formatDate(value, respectTimezone ? undefined : serverTimezone)
39+
: ''
40+
41+
return <GridCellPreviewWrapper>{formatted}</GridCellPreviewWrapper>
42+
}
43+
3244
export class DynamicTypeObjectDataDate extends DynamicTypeObjectDataAbstractDate {
3345
id: string = 'date'
3446
gridCellEditMode: EditMode = 'edit-modal'
@@ -46,8 +58,13 @@ export class DynamicTypeObjectDataDate extends DynamicTypeObjectDataAbstractDate
4658
}
4759

4860
getGridCellPreviewComponent (props: GetGridCellDefinitionProps): React.ReactElement {
49-
const value = props.cellProps.getValue()
61+
const columnType = (props.objectProps as DateObjectDataDefinition).columnType
5062

51-
return <GridCellPreviewWrapper>{(isNumber(value) || typeof value === 'string') ? formatDate(value) : ''}</GridCellPreviewWrapper>
63+
return (
64+
<DateGridCellPreview
65+
respectTimezone={ columnType === 'bigint(20)' }
66+
value={ props.cellProps.getValue() }
67+
/>
68+
)
5269
}
5370
}

0 commit comments

Comments
 (0)