diff --git a/src/components/Proposal/ProposalDateItem.vue b/src/components/Proposal/ProposalDateItem.vue index 1a25165d82..7d28156a16 100644 --- a/src/components/Proposal/ProposalDateItem.vue +++ b/src/components/Proposal/ProposalDateItem.vue @@ -28,6 +28,7 @@ import moment from '@nextcloud/moment' // icons import ItemIcon from 'vue-material-design-icons/Calendar' import DestroyIcon from 'vue-material-design-icons/Close' +import { getTimezoneOffset } from '@/services/timezoneOffsetService' export default { name: 'ProposalDateItem', @@ -57,15 +58,7 @@ export default { return '' } // Get the timezone offset in minutes - let timezoneOffset = 0 - try { - const now = new Date() - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })) - const targetDate = new Date(now.toLocaleString('en-US', { timeZone: this.timezoneId })) - timezoneOffset = ((utcDate.getTime() - targetDate.getTime()) / (1000 * 60)) * -1 - } catch (e) { - timezoneOffset = 0 - } + const timezoneOffset = getTimezoneOffset(this.proposalDate.date, this.timezoneId) const m = moment(this.proposalDate.date).utcOffset(timezoneOffset) // Examples: "Mon, Jul 8, 2:30 PM" (en), "Mon, 8 Jul, 14:30" (en-GB), "Mo, 8. Jul, 14:30" (de) return m.format('dddd, MMMM D, LT') diff --git a/src/components/Proposal/ProposalResponseMatrix.vue b/src/components/Proposal/ProposalResponseMatrix.vue index 3e769a0425..fff643fb4f 100644 --- a/src/components/Proposal/ProposalResponseMatrix.vue +++ b/src/components/Proposal/ProposalResponseMatrix.vue @@ -137,6 +137,7 @@ import NcAvatar from '@nextcloud/vue/components/NcAvatar' import NcButton from '@nextcloud/vue/components/NcButton' import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import { Proposal, ProposalResponse } from '@/models/proposals/proposals' +import { getTimezoneOffset } from '@/services/timezoneOffsetService' import { ProposalDateVote } from '@/types/proposals/proposalEnums' export default { @@ -182,7 +183,6 @@ export default { data() { return { ProposalDateVote, - timezoneOffset: 0, } }, @@ -201,17 +201,21 @@ export default { const groups = {} dates.forEach((d) => { // Apply timezone offset for grouping by day - const key = d.date ? moment(d.date).utcOffset(this.timezoneOffset).format('yyyy-MM-dd') : 'invalid' + const offset = getTimezoneOffset(d.date.toISOString(), this.timezoneId) + const key = moment(d.date).utcOffset(offset).format('YYYY-MM-DD') if (!groups[key]) { groups[key] = [] } groups[key].push(d) }) - return Object.entries(groups).map(([key, grp]: [string, ProposalDate[]]) => ({ - key, - label: moment(grp[0].date).utcOffset(this.timezoneOffset).format('dddd, MMMM Do'), - dates: grp, - })) + return Object.entries(groups).map(([key, grp]: [string, ProposalDate[]]) => { + const offset = getTimezoneOffset(grp[0].date.toISOString(), this.timezoneId) + return { + key, + label: moment(grp[0].date).utcOffset(offset).format('dddd, MMMM Do'), + dates: grp, + } + }) }, columnCount() { @@ -222,39 +226,13 @@ export default { }, - watch: { - timezoneId(newZone) { - if (newZone) { - this.timezoneOffset = this.calculateTimezoneOffset(newZone) - } - }, - }, - - created() { - if (this.timezoneId) { - this.timezoneOffset = this.calculateTimezoneOffset(this.timezoneId) - } - }, - methods: { t, - calculateTimezoneOffset(timezoneId) { - // Get the timezone offset in minutes - try { - const now = new Date() - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })) - const targetDate = new Date(now.toLocaleString('en-US', { timeZone: timezoneId })) - return ((utcDate.getTime() - targetDate.getTime()) / (1000 * 60)) * -1 - } catch (e) { - // Fallback to UTC if timezone is invalid - return 0 - } - }, - dateTimeSpan(date) { - const startDate = moment(date).utcOffset(this.timezoneOffset) - const endDate = moment(date).utcOffset(this.timezoneOffset).add(this.proposal.duration, 'minutes') + const offset = getTimezoneOffset(date.toISOString(), this.timezoneId) + const startDate = moment(date).utcOffset(offset) + const endDate = moment(date).utcOffset(offset).add(this.proposal.duration, 'minutes') const startTime = startDate.format('LT') const endTime = endDate.format('LT') @@ -277,7 +255,8 @@ export default { return '' } // Apply timezone offset and format very compact: "7/8 2PM" - const adjustedDate = moment(date).utcOffset(this.timezoneOffset) + const offset = getTimezoneOffset(date.toISOString(), this.timezoneId) + const adjustedDate = moment(date).utcOffset(offset) return adjustedDate.format('M/D LT').replace(':00', '').replace(' ', ' ') }, diff --git a/src/services/timezoneOffsetService.js b/src/services/timezoneOffsetService.js new file mode 100644 index 0000000000..44f8d1e64a --- /dev/null +++ b/src/services/timezoneOffsetService.js @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const dtfCache = new Map() + +export function getTimezoneOffset(proposalDate, timezoneId) { + try { + const date = new Date(proposalDate) + if (isNaN(date)) { + return null + } + + let dtf = dtfCache.get(timezoneId) + if (!dtf) { + dtf = new Intl.DateTimeFormat('en-US', { + timeZone: timezoneId, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + dtfCache.set(timezoneId, dtf) + } + + const parts = dtf.formatToParts(date) + const values = {} + + for (const { type, value } of parts) { + if (type !== 'literal') { + values[type] = value + } + } + + const asUTC = Date.UTC( + Number(values.year), + Number(values.month) - 1, + Number(values.day), + Number(values.hour), + Number(values.minute), + Number(values.second), + ) + + return Math.floor((asUTC - date.getTime()) / 60000) + } catch { + return null + } +}