Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions frontend/src/apps/calendar/components/EventDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'))

Expand Down Expand Up @@ -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 = {
Expand Down
33 changes: 22 additions & 11 deletions frontend/src/apps/calendar/components/Modals/EventModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 ---

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/apps/calendar/pages/CalendarView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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(),
}
},
Expand Down Expand Up @@ -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))
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/apps/calendar/utils/datetime.test.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
45 changes: 45 additions & 0 deletions frontend/src/apps/calendar/utils/datetime.ts
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion frontend/src/apps/calendar/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/apps/mail/components/Modals/EditInviteModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
disabled
/>
<FormControl
v-model="accountRequest.doc.expires_at"
v-model="inviteExpiresAt"
type="datetime-local"
:label="__('Expires At')"
:disabled="!isEditableInvite"
Expand Down Expand Up @@ -99,7 +99,9 @@
import { computed, ref, watch } from 'vue'
import { Dialog, FormControl, Switch, createDocumentResource, createResource } from 'frappe-ui'

import dayjs from '@/apps/mail/utils/dayjs'
import { raiseToast } from '@/apps/mail/utils'
import { formatSystemDateTime, systemTimeZone, toSystemDateTime } from '@/apps/mail/utils/datetime'

const show = defineModel<boolean>()

Expand Down Expand Up @@ -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<string>({
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading