Skip to content

Commit 3b31202

Browse files
Merge pull request #61714 from nextcloud/backport/61586/stable33
[stable33] fix(settings): display birthday correctly regardless of browsers timezone
2 parents ba0702d + 31fcb53 commit 3b31202

4 files changed

Lines changed: 145 additions & 18 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { mount } from '@vue/test-utils'
7+
import { afterEach, describe, expect, it, vi } from 'vitest'
8+
9+
let personalInfoParameters
10+
vi.mock('@nextcloud/initial-state', () => ({
11+
loadState(app, key, fallback) {
12+
if (app === 'settings' && key === 'personalInfoParameters' && personalInfoParameters !== undefined) {
13+
return personalInfoParameters
14+
}
15+
if (fallback !== undefined) {
16+
return fallback
17+
}
18+
19+
console.error('Unexpected loadState call without fallback', { app, key })
20+
throw new Error()
21+
},
22+
}))
23+
24+
const savePrimaryAccountProperty = vi.hoisted(() => vi.fn())
25+
vi.mock('../../service/PersonalInfo/PersonalInfoService.js', () => ({
26+
savePrimaryAccountProperty,
27+
}))
28+
29+
async function mountBirthdaySection() {
30+
const BirthdaySection = await import('./BirthdaySection.vue')
31+
return mount(BirthdaySection.default, {
32+
mocks: {
33+
t: (_app, text) => text,
34+
},
35+
})
36+
}
37+
38+
afterEach(() => {
39+
vi.unstubAllEnvs()
40+
personalInfoParameters = undefined
41+
vi.resetModules()
42+
})
43+
44+
describe('BirthdaySection', () => {
45+
it('saves value', async () => {
46+
personalInfoParameters = {
47+
birthdate: {
48+
name: 'birthdate',
49+
value: null,
50+
},
51+
}
52+
savePrimaryAccountProperty.mockReturnValue(Promise.resolve({
53+
ocs: { meta: { status: 'ok' } },
54+
}))
55+
const wrapper = await mountBirthdaySection()
56+
57+
const input = wrapper.find('input')
58+
await input.setValue('1987-12-01')
59+
60+
await expect.poll(() => savePrimaryAccountProperty.mock.calls.length).toBe(1)
61+
expect(savePrimaryAccountProperty).toHaveBeenCalledWith(
62+
'birthdate',
63+
'1987-12-01T00:00:00.000Z',
64+
)
65+
expect(input.element.value).toBe('1987-12-01')
66+
})
67+
68+
it('displays value when browser timezone is set', async () => {
69+
vi.stubEnv('TZ', 'US/Pacific')
70+
personalInfoParameters = {
71+
birthdate: {
72+
name: 'birthdate',
73+
value: '1987-12-15T00:00:00.000Z',
74+
},
75+
}
76+
77+
const wrapper = await mountBirthdaySection()
78+
79+
expect(wrapper.find('input').element.value).toBe('1987-12-15')
80+
})
81+
82+
it('saves value when browser timezone is set', async () => {
83+
vi.stubEnv('TZ', 'US/Pacific')
84+
personalInfoParameters = {
85+
birthdate: {
86+
name: 'birthdate',
87+
value: null,
88+
},
89+
}
90+
savePrimaryAccountProperty.mockReturnValue(Promise.resolve({
91+
ocs: { meta: { status: 'ok' } },
92+
}))
93+
const wrapper = await mountBirthdaySection()
94+
95+
const input = wrapper.find('input')
96+
await input.setValue('1987-12-01')
97+
98+
await expect.poll(() => savePrimaryAccountProperty.mock.calls.length).toBe(1)
99+
expect(savePrimaryAccountProperty).toHaveBeenCalledWith(
100+
'birthdate',
101+
'1987-12-01T00:00:00.000Z',
102+
)
103+
expect(input.element.value).toBe('1987-12-01')
104+
})
105+
})

apps/settings/src/components/PersonalInfo/BirthdaySection.vue

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
:id="inputId"
1414
type="date"
1515
label=""
16-
:model-value="value"
16+
:model-value="timezoneAdjustedValue"
1717
@input="onInput" />
1818

1919
<p class="property__helper-text-message">
@@ -33,6 +33,16 @@ import { handleError } from '../../utils/handlers.js'
3333
3434
const { birthdate } = loadState('settings', 'personalInfoParameters', {})
3535
36+
/**
37+
* Convert a birthdate string value into a Date.
38+
*
39+
* @param {string } birthdateValue - e.g. "1987-12-01" or "1987-12-01T00:00:00.000Z"
40+
* @return {Date}
41+
*/
42+
function birthdateValueToDate(birthdateValue) {
43+
return new Date(birthdateValue)
44+
}
45+
3646
export default {
3747
name: 'BirthdaySection',
3848
@@ -44,7 +54,7 @@ export default {
4454
data() {
4555
let initialValue = null
4656
if (birthdate.value) {
47-
initialValue = new Date(birthdate.value)
57+
initialValue = birthdateValueToDate(birthdate.value)
4858
}
4959
5060
return {
@@ -62,24 +72,36 @@ export default {
6272
return `account-property-${birthdate.name}`
6373
},
6474
65-
value: {
66-
get() {
67-
return new Date(this.birthdate.value)
68-
},
75+
value() {
76+
return birthdateValueToDate(this.birthdate.value)
77+
},
6978
70-
/** @param {Date} value The date to set */
71-
set(value) {
72-
const day = value.getDate().toString().padStart(2, '0')
73-
const month = (value.getMonth() + 1).toString().padStart(2, '0')
74-
const year = value.getFullYear()
75-
this.birthdate.value = `${year}-${month}-${day}`
76-
},
79+
/**
80+
* Adjusted value for usage with `NcDateTimePickerNative` (internally `<input="date">`)
81+
* The saved value is is UTC and we want to show it the same regardless of the browsers/OSs timezone.
82+
* When the adjusted value is displayed and the users timezone is applied, this adjusted value then looks like the UTC value.
83+
*/
84+
timezoneAdjustedValue() {
85+
// example: this.birthdate.value === '1987-12-01T00:00:00.000Z' or '1987-12-01'
86+
87+
// example: Mon Nov 30 1987 16:00:00 GMT-0800 (Pacific Standard Time)
88+
// `NcDateTimePickerNative` would show this as 11/30/1987
89+
const date = this.value
90+
const timezoneOffsetMilliseconds = date.getTimezoneOffset() * 60 * 1000
91+
const adjustedDate = new Date(date.getTime() + timezoneOffsetMilliseconds)
92+
93+
// example: Tue Dec 01 1987 00:00:00 GMT-0800 (Pacific Standard Time)
94+
// `NcDateTimePickerNative` will show this as 12/01/1987
95+
return adjustedDate
7796
},
7897
},
7998
8099
methods: {
81100
onInput(e) {
82-
this.value = e
101+
const day = e.getDate().toString().padStart(2, '0')
102+
const month = (e.getMonth() + 1).toString().padStart(2, '0')
103+
const year = e.getFullYear()
104+
this.birthdate.value = `${year}-${month}-${day}`
83105
this.debouncePropertyChange(this.value)
84106
},
85107
@@ -91,7 +113,7 @@ export default {
91113
try {
92114
const responseData = await savePrimaryAccountProperty(
93115
this.birthdate.name,
94-
value,
116+
value.toISOString(),
95117
)
96118
this.handleResponse({
97119
value,

dist/settings-vue-settings-personal-info.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/settings-vue-settings-personal-info.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)