diff --git a/frontend/src/apps/calendar/components/EventDetailSidebar.vue b/frontend/src/apps/calendar/components/EventDetailSidebar.vue index 1d8c48c6fc..ae9d8c60db 100644 --- a/frontend/src/apps/calendar/components/EventDetailSidebar.vue +++ b/frontend/src/apps/calendar/components/EventDetailSidebar.vue @@ -22,6 +22,7 @@ import DOMPurify from 'dompurify' import meetLogo from '@/assets/app-logos/meet.png' import { getMeetUrl, getReorderedParticipants, isUrl } from '@/apps/calendar/utils' +import { fromEventZone, inUserTimeZone } from '@/apps/calendar/utils/datetime' import { getRepeatMessage } from '@/apps/calendar/utils/format' import { userStore } from '@/apps/calendar/stores/user' import EventParticipantList from '@/apps/calendar/components/EventParticipantList.vue' @@ -64,10 +65,13 @@ const handleSetResponse = (response: string) => { // --- Date / time label --- const dateLabel = computed(() => { - const start = dayjs(calendarEvent.start) + // Full-day detection reads the stored wall clock (midnight in the event's own zone); + // timed events are then shown in the viewer's zone. + const rawStart = dayjs(calendarEvent.start) const duration = dayjs.duration(calendarEvent.duration) + const isFullDay = duration.asHours() % 24 === 0 && rawStart.isSame(rawStart.startOf('day')) + const start = isFullDay ? rawStart : fromEventZone(calendarEvent.start, calendarEvent.time_zone) const end = start.add(duration) - const isFullDay = duration.asHours() % 24 === 0 && start.isSame(start.startOf('day')) const isSameDay = start.isSame(end, 'day') || (isFullDay && start.isSame(end.subtract(1, 'ms'), 'day')) @@ -140,7 +144,7 @@ const mailParticipantsUrl = computed(() => { // --- Alerts --- const formatAlert = (a: any) => { - if (a.type === 'AbsoluteTrigger') return dayjs.utc(a.when).format('D MMM, h:mm a') + if (a.type === 'AbsoluteTrigger') return inUserTimeZone(a.when).format('D MMM, h:mm a') const d = dayjs.duration(a.offset).$d const units = { diff --git a/frontend/src/apps/calendar/components/Modals/EventModal.vue b/frontend/src/apps/calendar/components/Modals/EventModal.vue index 1c89cdbc9b..1bc341e916 100644 --- a/frontend/src/apps/calendar/components/Modals/EventModal.vue +++ b/frontend/src/apps/calendar/components/Modals/EventModal.vue @@ -5,6 +5,7 @@ import { Button, Dialog, Dropdown, FormControl, Switch, createResource, toast } import meetLogo from '@/assets/app-logos/meet.png' import { getMeetUrl, getReorderedParticipants } from '@/apps/calendar/utils' +import { fromEventZone, fromWallClock, inUserTimeZone } from '@/apps/calendar/utils/datetime' import { getRepeatMessage } from '@/apps/calendar/utils/format' import { userStore } from '@/apps/calendar/stores/user' import EventAlertList from '@/apps/calendar/components/EventAlertList.vue' @@ -28,7 +29,9 @@ const getEventData = () => { if (isNew.value) return getDefaultEventData() const { calendarEvent: ev } = selectedEvent - const start = dayjs(ev.start) + // Timed events edit in the viewer's zone (saving re-zones them to it, see eventParams); + // all-day events keep their calendar date. + const start = ev.isAllDay ? dayjs(ev.start) : fromEventZone(ev.start, ev.time_zone) const end = start.add(dayjs.duration(ev.duration)) const displayEnd = ev.isAllDay ? end.subtract(1, 'day') : end @@ -136,12 +139,15 @@ const eventParams = computed(() => { duration: duration.value, } + if (event.title) params.title = event.title + if (dayjs?.tz) params.time_zone = dayjs.tz.guess() + if (selectedEvent.calendarEvent?.recurrence_id && !isUpdateInstance.value) { + // The master's start passes through unchanged, so it must keep the master's zone — + // pairing it with the browser's would silently shift the whole series. params.start = selectedEvent.calendarEvent.master_start + params.time_zone = selectedEvent.calendarEvent.time_zone || params.time_zone } - - if (event.title) params.title = event.title - if (dayjs?.tz) params.time_zone = dayjs.tz.guess() if (event.recurrence_rule && Object.keys(event.recurrence_rule).length) params.recurrence_rule = event.recurrence_rule if (event.privacy) params.privacy = event.privacy @@ -159,7 +165,8 @@ const eventParams = computed(() => { if (a.type === 'AbsoluteTrigger') return { ...base, - when: dayjs(`${a.date}T${a.time}`).format('YYYY-MM-DD[T]HH:mm:ss'), + // The date/time inputs are a wall clock in the user's zone; the API takes UTC. + when: fromWallClock(`${a.date}T${a.time}`), } return { @@ -173,13 +180,17 @@ const eventParams = computed(() => { return params }) -const patch = computed(() => - Object.fromEntries( +const patch = computed(() => { + const changed = Object.fromEntries( [...new Set([...Object.keys(eventParams.value), ...Object.keys(originalParams)])] .filter((k) => JSON.stringify(eventParams.value[k]) !== JSON.stringify(originalParams[k])) .map((k) => [k, eventParams.value[k]]), - ), -) + ) + // A changed start is a wall clock in the viewer's zone; without the zone alongside it the + // server would reinterpret those numbers in the event's stored zone. + if ('start' in changed && !('time_zone' in changed)) changed.time_zone = eventParams.value.time_zone + return changed +}) // --- Helpers --- @@ -188,8 +199,8 @@ const parseAlert = (a: any) => { return { type: a.type, action: a.action, - date: dayjs.utc(a.when).format('YYYY-MM-DD'), - time: dayjs.utc(a.when).format('HH:mm'), + date: inUserTimeZone(a.when).format('YYYY-MM-DD'), + time: inUserTimeZone(a.when).format('HH:mm'), } const d = dayjs.duration(a.offset).$d diff --git a/frontend/src/apps/calendar/components/Modals/EventRepeatSettingsModal.vue b/frontend/src/apps/calendar/components/Modals/EventRepeatSettingsModal.vue index 2bfa60a271..2c2be407a2 100644 --- a/frontend/src/apps/calendar/components/Modals/EventRepeatSettingsModal.vue +++ b/frontend/src/apps/calendar/components/Modals/EventRepeatSettingsModal.vue @@ -38,7 +38,9 @@ const parseRRule = () => { if (rRule.until) { end = 'On Date' - until = dayjs(rRule.until).format('YYYY-MM-DD') + // `until` is a local date-time; strip the `Z` older events carry so the picked + // date survives instead of shifting a day in zones east of UTC. + until = dayjs(rRule.until.replace(/Z$/, '')).format('YYYY-MM-DD') } else if (rRule.count) { end = 'After Occurrences' count = rRule.count @@ -134,7 +136,8 @@ const recurrenceRule = computed(() => { else rule.byMonthDay = monthlyByMonthDay[repeat.repeatOn] } - if (repeat.end === 'On Date') rule.until = `${repeat.until}T23:59:59Z` + // JSCalendar `until` is a LocalDateTime in the event's zone (RFC 8984) — no `Z`. + if (repeat.end === 'On Date') rule.until = `${repeat.until}T23:59:59` else if (repeat.end === 'After Occurrences') rule.count = repeat.count return rule diff --git a/frontend/src/apps/calendar/pages/CalendarView.vue b/frontend/src/apps/calendar/pages/CalendarView.vue index 884ab60ac9..79aa5ce0ba 100644 --- a/frontend/src/apps/calendar/pages/CalendarView.vue +++ b/frontend/src/apps/calendar/pages/CalendarView.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { Button, Calendar, Dialog, createResource, usePageMeta } from 'frappe-ui' import { raiseToast } from '@/apps/calendar/utils' +import { fromEventZone } from '@/apps/calendar/utils/datetime' import { userStore } from '@/apps/calendar/stores/user' import AppSidebar from '@/apps/calendar/components/AppSidebar.vue' import EventDetailSidebar from '@/apps/calendar/components/EventDetailSidebar.vue' @@ -81,18 +82,22 @@ onMounted(() => { }) const transformEvent = (event) => { - const start = dayjs(event.start) + // The all-day heuristic reads the stored wall clock (midnight in the event's own zone). + const rawStart = dayjs(event.start) const dur = dayjs.duration(event.duration || 'PT0S') - const end = start.add(dur) const isAllDay = - start.hour() === 0 && - start.minute() === 0 && - start.second() === 0 && + rawStart.hour() === 0 && + rawStart.minute() === 0 && + rawStart.second() === 0 && dur.days() > 0 && dur.hours() === 0 && dur.minutes() === 0 && dur.seconds() === 0 + // Timed events are placed in the viewer's zone; all-day events keep their calendar date. + const start = isAllDay ? rawStart : fromEventZone(event.start, event.time_zone) + const end = start.add(dur) + return { ...event, fromDate: start.format('YYYY-MM-DD'), @@ -134,8 +139,8 @@ const events = createResource({ .month(calendarRef.value?.currentMonth) return { account: store.accountId, - from_date: date.startOf('month').subtract(37, 'day').toDate(), - to_date: date.endOf('month').add(37, 'day').toDate(), + from_date: date.startOf('month').subtract(37, 'day').utc().format('YYYY-MM-DD[T]HH:mm:ss[Z]'), + to_date: date.endOf('month').add(37, 'day').utc().format('YYYY-MM-DD[T]HH:mm:ss[Z]'), time_zone: dayjs.tz.guess(), } }, @@ -226,6 +231,9 @@ const submitEvent = (sendEmail: boolean) => { eventToBeUpdated.start = dayjs(eventToBeUpdated.fromDateTime).format('YYYY-MM-DDTHH:mm:ss') if (!eventToBeUpdated.isAllDay) { + // The dragged wall clock is in the viewer's zone; re-zone the event to match, or the + // same numbers would be reinterpreted in the event's original zone. + eventToBeUpdated.time_zone = dayjs.tz.guess() const start = dayjs(eventToBeUpdated.fromDateTime) const end = dayjs(eventToBeUpdated.toDateTime) const diff = dayjs.duration(end.diff(start)) diff --git a/frontend/src/apps/calendar/utils/datetime.test.ts b/frontend/src/apps/calendar/utils/datetime.test.ts new file mode 100644 index 0000000000..bd5f4be79e --- /dev/null +++ b/frontend/src/apps/calendar/utils/datetime.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' + +import dayjs from '@/apps/calendar/utils/dayjs' + +vi.mock('@/apps/calendar/stores/user', () => ({ + userStore: () => ({ userResource: { data: { time_zone: 'Asia/Karachi' } } }), +})) + +// The browser zone wins over the User doc's zone; the tests below assert against it. +vi.spyOn(dayjs.tz, 'guess').mockReturnValue('Asia/Kolkata') + +const load = async () => await import('@/apps/calendar/utils/datetime') + +describe('calendar datetime helpers', () => { + it('prefers the browser zone, falling back to the user zone', async () => { + const { userTimeZone } = await load() + expect(userTimeZone()).toBe('Asia/Kolkata') + vi.mocked(dayjs.tz.guess).mockReturnValueOnce(undefined as unknown as string) + expect(userTimeZone()).toBe('Asia/Karachi') + }) + + it('renders a UTC timestamp in the user zone', async () => { + const { formatDateTime } = await load() + expect(formatDateTime('2026-07-28T09:02:30Z')).toBe('Jul 28 2026, 2:32 PM') + }) + + it('reads an alert wall clock back as UTC', async () => { + const { fromWallClock } = await load() + expect(fromWallClock('2026-07-28T14:32')).toBe('2026-07-28T09:02:00Z') + }) + + it('round-trips an alert through the user zone', async () => { + const { fromWallClock, inUserTimeZone } = await load() + const when = fromWallClock('2026-07-28T14:32') + expect(inUserTimeZone(when).format('YYYY-MM-DDTHH:mm')).toBe('2026-07-28T14:32') + }) + + it('moves an event wall clock into the viewer zone', async () => { + const { fromEventZone } = await load() + // 2 PM Asia/Kolkata is 10:30 AM in Vienna; the viewer here is Asia/Kolkata, so a + // Vienna-stored 10:30 AM renders back at 2 PM. + expect(fromEventZone('2026-07-29T10:30:00', 'Europe/Vienna').format('HH:mm')).toBe('14:00') + expect(fromEventZone('2026-07-29T14:00:00', 'Asia/Kolkata').format('HH:mm')).toBe('14:00') + }) + + it('leaves a floating event where the viewer is', async () => { + const { fromEventZone } = await load() + expect(fromEventZone('2026-07-29T14:00:00', null).format('HH:mm')).toBe('14:00') + expect(fromEventZone('2026-07-29T14:00:00', '').format('HH:mm')).toBe('14:00') + }) + + it('leaves blanks blank', async () => { + const { formatDateTime, fromWallClock } = await load() + expect(formatDateTime(null)).toBe('') + expect(fromWallClock('')).toBe('') + }) +}) diff --git a/frontend/src/apps/calendar/utils/datetime.ts b/frontend/src/apps/calendar/utils/datetime.ts new file mode 100644 index 0000000000..2d58cc546a --- /dev/null +++ b/frontend/src/apps/calendar/utils/datetime.ts @@ -0,0 +1,45 @@ +import dayjs from '@/apps/calendar/utils/dayjs' +import { userStore } from '@/apps/calendar/stores/user' + +/** + * Timestamps on the wire are always UTC, spelled the way Stalwart spells them: + * "2026-07-28T09:02:30Z". This is the calendar counterpart of + * `@/apps/mail/utils/datetime` — the single place that moves between that wire format and the + * zone the user reads and types in. JSCalendar `start`/`duration`/`timeZone` values are local + * time plus an IANA zone and never pass through here. + */ + +const UTC_FORMAT = 'YYYY-MM-DDTHH:mm:ss[Z]' + +/** + * The zone timestamps are displayed and typed in: the browser's, falling back to `time_zone` on + * the User doc for the rare environment where the browser cannot say (`get_user_info` resolves + * that to the site's zone when unset, completing the browser → user → system fallback chain). + */ +export const userTimeZone = (): string => { + const { userResource } = userStore() + return dayjs.tz.guess() || userResource.data?.time_zone +} + +/** Reads a UTC timestamp from an API into the user's zone. */ +export const inUserTimeZone = (value: string) => dayjs.utc(value).tz(userTimeZone()) + +/** Formats a UTC timestamp from an API for display in the user's zone. */ +export const formatDateTime = (value?: string | null, format = 'MMM D YYYY, h:mm A'): string => + value ? inUserTimeZone(value).format(format) : '' + +/** + * Turns a wall-clock reading in the user's zone (e.g. an alert's date + time inputs, carrying + * no offset) into the UTC timestamp the APIs take. Blank stays blank so callers can tell + * "unset" from a time. + */ +export const fromWallClock = (value?: string | null): string => + value ? dayjs.tz(value, userTimeZone()).utc().format(UTC_FORMAT) : '' + +/** + * Reads a JSCalendar `start` — a wall clock in the event's own IANA zone — into the user's + * zone, so a 2 PM Asia/Kolkata event renders at 10:30 AM in Vienna. An event without a zone + * is floating and stays wherever the viewer is. + */ +export const fromEventZone = (start: string, eventTimeZone?: string | null) => + eventTimeZone ? dayjs.tz(start, eventTimeZone).tz(userTimeZone()) : dayjs(start) diff --git a/frontend/src/apps/calendar/utils/format.ts b/frontend/src/apps/calendar/utils/format.ts index 7ba0b962f9..8fb40f7380 100644 --- a/frontend/src/apps/calendar/utils/format.ts +++ b/frontend/src/apps/calendar/utils/format.ts @@ -80,9 +80,11 @@ export const getRepeatMessage = (recurrenceRule: RecurrenceRule) => { const fullMessage = `${message}${suffix}` if (recurrenceRule?.until) + // `until` is a local date-time; strip the `Z` older events carry so the picked + // date survives instead of shifting a day in zones east of UTC. return __('{0} until {1}', [ fullMessage, - dayjs(recurrenceRule.until).format('MMM DD, YYYY'), + dayjs(recurrenceRule.until.replace(/Z$/, '')).format('MMM DD, YYYY'), ]) if (recurrenceRule?.count) return __('{0}, {1} times', [fullMessage, recurrenceRule.count]) diff --git a/frontend/src/apps/mail/components/Modals/EditInviteModal.vue b/frontend/src/apps/mail/components/Modals/EditInviteModal.vue index 9b58f81d12..8a7f57bc52 100644 --- a/frontend/src/apps/mail/components/Modals/EditInviteModal.vue +++ b/frontend/src/apps/mail/components/Modals/EditInviteModal.vue @@ -59,7 +59,7 @@ disabled /> () @@ -153,10 +155,23 @@ const isEditableInvite = computed(() => { return !doc.is_verified }) +// `expires_at` is a naive system-zone DB field saved straight through the doc resource; the +// input edits it as a wall clock in the user's zone, converting on both sides. This also feeds +// the `datetime-local` input the `YYYY-MM-DDTHH:mm` shape it demands — the raw DB string +// (space-separated) would render an empty box. +const inviteExpiresAt = computed({ + get: () => formatSystemDateTime(accountRequest.value?.doc?.expires_at, 'YYYY-MM-DDTHH:mm'), + set: (value) => { + if (!accountRequest.value?.doc) return + accountRequest.value.doc.expires_at = toSystemDateTime(value) + }, +}) + // Expiry is read off the local doc so extending it lights the send action up only once saved. +// The stored value is system-zone; compare instants, not the browser's reading of the string. const isExpired = computed(() => { const expiresAt = accountRequest.value?.doc?.expires_at - return Boolean(expiresAt) && new Date(expiresAt as string) < new Date() + return Boolean(expiresAt) && dayjs.tz(expiresAt as string, systemTimeZone()).isBefore(dayjs()) }) // The server sends the link with whatever is stored, so pending edits have to be saved first. diff --git a/frontend/src/apps/mail/components/Settings/CalendarExportSettings.vue b/frontend/src/apps/mail/components/Settings/CalendarExportSettings.vue index af89f173fd..90477f7683 100644 --- a/frontend/src/apps/mail/components/Settings/CalendarExportSettings.vue +++ b/frontend/src/apps/mail/components/Settings/CalendarExportSettings.vue @@ -82,6 +82,7 @@ import { computed, inject, onMounted, reactive, ref } from 'vue' import { Button, ErrorMessage, FormControl, SettingsRow, Switch, createResource } from 'frappe-ui' +import { utcDayEnd, utcDayStart } from '@/apps/mail/utils/datetime' import { userStore } from '@/apps/mail/stores/user' const { accountId } = userStore() @@ -133,6 +134,10 @@ const createCalendarExport = createResource({ .map(([k, v]) => [k, typeof v === 'string' ? v.trim() : v]) .filter(([, v]) => Boolean(v)), ) + // The date pickers give local days; the API listens UTC, so send the day's bounds + // in the user's zone or the first/last hours of the range get cut off. + if (cleanedFilter.after) cleanedFilter.after = utcDayStart(cleanedFilter.after as string) + if (cleanedFilter.before) cleanedFilter.before = utcDayEnd(cleanedFilter.before as string) return { account: accountId, ...calendarExport, filter: cleanedFilter } }, onSuccess: () => ongoingExport.reload(), diff --git a/frontend/src/apps/mail/components/Settings/MailExportSettings.vue b/frontend/src/apps/mail/components/Settings/MailExportSettings.vue index c61143cc91..97be51733e 100644 --- a/frontend/src/apps/mail/components/Settings/MailExportSettings.vue +++ b/frontend/src/apps/mail/components/Settings/MailExportSettings.vue @@ -92,6 +92,7 @@ import { computed, inject, onMounted, reactive, ref } from 'vue' import { Button, ErrorMessage, FormControl, SettingsRow, Switch, createResource } from 'frappe-ui' import { getAttachmentOptions, getReadStatusOptions } from '@/apps/mail/constants' +import { utcDayEnd, utcDayStart } from '@/apps/mail/utils/datetime' import { userStore } from '@/apps/mail/stores/user' const { accountId, mailboxes } = userStore() @@ -138,6 +139,10 @@ const createMailExport = createResource({ .map(([k, v]) => [k, typeof v === 'string' ? v.trim() : v]) .filter(([, v]) => Boolean(v)), ) + // The date pickers give local days; the API listens UTC, so send the day's bounds + // in the user's zone or the first/last hours of the range get cut off. + if (cleanedFilter.after) cleanedFilter.after = utcDayStart(cleanedFilter.after as string) + if (cleanedFilter.before) cleanedFilter.before = utcDayEnd(cleanedFilter.before as string) return { account: accountId, ...mailExport, filter: cleanedFilter } }, onSuccess: () => ongoingExport.reload(), diff --git a/frontend/src/apps/mail/components/Settings/ScreenedEmailAddressSettings.vue b/frontend/src/apps/mail/components/Settings/ScreenedEmailAddressSettings.vue index 9b4d6e901f..38cebd93b1 100644 --- a/frontend/src/apps/mail/components/Settings/ScreenedEmailAddressSettings.vue +++ b/frontend/src/apps/mail/components/Settings/ScreenedEmailAddressSettings.vue @@ -136,6 +136,7 @@ import AppSettingsHeader from '@/components/settings/AppSettingsHeader.vue' import AdaptiveDropdown from '@/apps/mail/components/AdaptiveDropdown.vue' import { getFormattedDate, raiseToast } from '@/apps/mail/utils' +import { formatSystemDateTime } from '@/apps/mail/utils/datetime' import { userStore } from '@/apps/mail/stores/user' import AddScreenedSenderModal from '@/apps/mail/components/Modals/AddScreenedSenderModal.vue' @@ -259,7 +260,9 @@ const rows = computed(() => { .map((a: ScreenedAddress) => ({ email: a.email, action: ACTION_LABELS[a.action] ?? a.action, - modified: getFormattedDate(a.modified), + // `modified` is a naive system-zone DB timestamp; move it into the user's zone + // before the Today/Yesterday labels are applied. Blank (optimistic rows) stays blank. + modified: a.modified ? getFormattedDate(formatSystemDateTime(a.modified, 'YYYY-MM-DDTHH:mm:ss')) : '', })) }) diff --git a/frontend/src/apps/mail/components/Settings/VacationResponseSettings.vue b/frontend/src/apps/mail/components/Settings/VacationResponseSettings.vue index 16daa6bdc1..ce636b1c14 100644 --- a/frontend/src/apps/mail/components/Settings/VacationResponseSettings.vue +++ b/frontend/src/apps/mail/components/Settings/VacationResponseSettings.vue @@ -61,7 +61,7 @@