diff --git a/src/fullcalendar/rendering/eventDidMount.js b/src/fullcalendar/rendering/eventDidMount.js
index 8e68a7567b..f74c8687d0 100644
--- a/src/fullcalendar/rendering/eventDidMount.js
+++ b/src/fullcalendar/rendering/eventDidMount.js
@@ -2,8 +2,125 @@
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCanonicalLocale, translate as t } from '@nextcloud/l10n'
+import { formatDateWithTimezone, isMultiDayAllDayEvent } from '../../utils/date.js'
import { errorCatch } from '../utils/errors.js'
+/**
+ * Build time description for all-day events
+ *
+ * @param {EventApi} event The event
+ * @param {string|undefined} locale Locale for event time formatting
+ * @return {string} Time description
+ */
+function buildAllDayTimeDescription(event, locale) {
+ if (!event.start) {
+ return ''
+ }
+
+ const dateOptions = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }
+ const startStr = formatDateWithTimezone(event.start, locale, dateOptions)
+
+ if (!event.end || !isMultiDayAllDayEvent(event.start, event.end)) {
+ return startStr
+ }
+
+ // Multi-day event: calculate end date (exclusive, so subtract 1 day)
+ const adjustedEnd = new Date(event.end)
+ adjustedEnd.setDate(adjustedEnd.getDate() - 1)
+ const endStr = formatDateWithTimezone(adjustedEnd, locale, dateOptions)
+
+ return t('calendar', '{startDate} to {endDate}', {
+ startDate: startStr,
+ endDate: endStr,
+ })
+}
+
+/**
+ * Build time description for timed events
+ *
+ * @param {EventApi} event The event
+ * @param {string|undefined} locale The locale to use
+ * @return {string} Time description
+ */
+function buildTimedEventDescription(event, locale) {
+ if (!event.start) {
+ return ''
+ }
+
+ const dateTimeOptions = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ }
+ const timeOptions = {
+ hour: 'numeric',
+ minute: 'numeric',
+ }
+
+ const startStr = formatDateWithTimezone(event.start, locale, dateTimeOptions, true)
+
+ if (!event.end) {
+ return startStr
+ }
+
+ // Check if same day - only show time for end
+ const sameDay = event.start.toDateString() === event.end.toDateString()
+ if (sameDay) {
+ const endTimeStr = formatDateWithTimezone(event.end, locale, timeOptions, true)
+ return t('calendar', '{startDateTime} to {endTime}', {
+ startDateTime: startStr,
+ endTime: endTimeStr,
+ })
+ }
+
+ // Multi-day timed event
+ const endStr = formatDateWithTimezone(event.end, locale, dateTimeOptions, true)
+ return t('calendar', '{startDateTime} to {endDateTime}', {
+ startDateTime: startStr,
+ endDateTime: endStr,
+ })
+}
+
+/**
+ * Builds an accessible label for a calendar event including its title and time.
+ *
+ * @param {EventApi} event The fullcalendar event object
+ * @return {string} A human-readable label for screen readers
+ */
+function buildAriaLabel(event) {
+ const locale = getCanonicalLocale() || undefined
+ const title = event.title || t('calendar', 'Untitled event')
+
+ if (event.allDay) {
+ const timeDescription = buildAllDayTimeDescription(event, locale)
+ if (timeDescription) {
+ return t('calendar', '{title}, All day: {timeDescription}', {
+ title,
+ timeDescription,
+ })
+ }
+ return t('calendar', '{title}, All day', { title })
+ }
+
+ const timeDescription = buildTimedEventDescription(event, locale)
+ if (timeDescription) {
+ return t('calendar', '{title}, {timeDescription}', {
+ title,
+ timeDescription,
+ })
+ }
+ return title
+}
+
/**
* Adds data to the html element representing the event in the fullcalendar grid.
* This is used to later on position the popover
@@ -13,9 +130,12 @@ import { errorCatch } from '../utils/errors.js'
* @param {Node} data.el The HTML element
*/
export default errorCatch(function({ event, el }) {
+ // Set aria-label for screen reader accessibility
+ el.setAttribute('aria-label', buildAriaLabel(event))
if (el.classList.contains('fc-event-nc-alarms')) {
const notificationIcon = document.createElement('span')
notificationIcon.classList.add('icon-event-reminder')
+ notificationIcon.setAttribute('aria-hidden', 'true')
if (event.extendedProps.darkText) {
notificationIcon.classList.add('icon-event-reminder--dark')
} else {
@@ -137,7 +257,7 @@ export default errorCatch(function({ event, el }) {
const titleElement = el.querySelector('.fc-event-title')
if (titleElement) {
- const svgString = ''
+ const svgString = ''
titleElement.innerHTML = svgString + titleElement.innerHTML
const svgElement = titleElement.querySelector('svg')
diff --git a/src/utils/date.js b/src/utils/date.js
index 922873b7ae..baa06c48ab 100644
--- a/src/utils/date.js
+++ b/src/utils/date.js
@@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { DateTimeValue } from '@nextcloud/calendar-js'
+import getTimezoneManager from '../services/timezoneDataProviderService.js'
+import useSettingsStore from '../store/settings.js'
import logger from './logger.js'
/**
@@ -115,3 +118,55 @@ export function modifyDate(date, { day = 0, week = 0, month = 0, year = 0 }) {
return date
}
+
+/**
+ * Convert a date from UTC to user's timezone
+ *
+ * @param {Date} date The date to convert (in UTC)
+ * @return {Date} Converted date in user's timezone
+ */
+export function convertDateToUserTimezone(date) {
+ const settingsStore = useSettingsStore()
+ const userTimezoneId = settingsStore.getResolvedTimezone
+
+ const tzManager = getTimezoneManager()
+ const utcTimezone = tzManager.getTimezoneForId('UTC')
+ const userTimezone = tzManager.getTimezoneForId(userTimezoneId)
+
+ const dateTimeValue = DateTimeValue.fromJSDate(date, true)
+ dateTimeValue.replaceTimezone(utcTimezone)
+ return getDateFromDateTimeValue(dateTimeValue.getInTimezone(userTimezone))
+}
+
+/**
+ * Format a date with specified options
+ *
+ * @param {Date} date The date to format (in UTC for timed events, local for all-day)
+ * @param {string|undefined} locale The locale to use
+ * @param {object} options Formatting options
+ * @param {boolean} convertTimezone Whether to convert from UTC to user timezone
+ * @return {string} Formatted date string
+ */
+export function formatDateWithTimezone(date, locale, options, convertTimezone = false) {
+ const dateToFormat = convertTimezone ? convertDateToUserTimezone(date) : date
+ return dateToFormat.toLocaleString(locale ?? 'en_001', options)
+}
+
+/**
+ * Check if an all-day event spans multiple days
+ *
+ * @param {Date} start Start date
+ * @param {Date} end End date (exclusive in FullCalendar)
+ * @return {boolean} True if multi-day
+ */
+export function isMultiDayAllDayEvent(start, end) {
+ // FullCalendar all-day end dates are exclusive, so subtract one day
+ const adjustedEnd = new Date(end)
+ adjustedEnd.setDate(adjustedEnd.getDate() - 1)
+ adjustedEnd.setHours(0, 0, 0, 0)
+
+ const startMidnight = new Date(start)
+ startMidnight.setHours(0, 0, 0, 0)
+
+ return adjustedEnd.getTime() > startMidnight.getTime()
+}
diff --git a/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js b/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js
index 9f631777ef..7da52c7761 100644
--- a/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js
+++ b/tests/javascript/unit/fullcalendar/rendering/eventDidMount.test.js
@@ -2,8 +2,13 @@
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { translate, getCanonicalLocale } from '@nextcloud/l10n'
+import { formatDateWithTimezone, isMultiDayAllDayEvent } from '../../../../../src/utils/date.js'
import eventRender from "../../../../../src/fullcalendar/rendering/eventDidMount.js";
+vi.mock('@nextcloud/l10n')
+vi.mock('../../../../../src/utils/date.js')
+
describe('fullcalendar/eventDidMount test suite', () => {
it('should add extended properties from the event to the dataset of the dom element - existing event', () => {
@@ -66,7 +71,7 @@ describe('fullcalendar/eventDidMount test suite', () => {
eventRender({ event, el })
- expect(el.outerHTML).toEqual('
2pmTitle 123
')
+ expect(el.outerHTML).toEqual('
2pmTitle 123
')
})
it('should add an alarm bell icon if event has an alarm - light', () => {
@@ -98,7 +103,7 @@ describe('fullcalendar/eventDidMount test suite', () => {
eventRender({ event, el })
- expect(el.outerHTML).toEqual('
2pmTitle 123
')
+ expect(el.outerHTML).toEqual('
2pmTitle 123
')
})
// TODO: fix me later
@@ -167,3 +172,287 @@ describe('fullcalendar/eventDidMount test suite', () => {
// })
})
+
+describe('fullcalendar/eventDidMount aria-label test suite', () => {
+
+ beforeEach(() => {
+ translate.mockClear()
+ getCanonicalLocale.mockClear()
+ formatDateWithTimezone.mockClear()
+ isMultiDayAllDayEvent.mockClear()
+
+ getCanonicalLocale.mockReturnValue('en')
+
+ // Mock translate to perform placeholder substitution like the real function
+ translate.mockImplementation((app, str, params) => {
+ if (!params) return str
+ return Object.entries(params).reduce(
+ (result, [key, value]) => result.replace(`{${key}}`, value),
+ str,
+ )
+ })
+ })
+
+ /**
+ * Helper to create a minimal event object
+ *
+ * @param {object} overrides Properties to override on the event
+ * @return {object} A minimal fullcalendar-like event object
+ */
+ function createEvent(overrides = {}) {
+ return {
+ title: 'Team Meeting',
+ allDay: false,
+ start: new Date('2026-03-11T10:00:00'),
+ end: new Date('2026-03-11T11:00:00'),
+ source: {},
+ extendedProps: {
+ objectId: 'obj1',
+ recurrenceId: 'rec1',
+ },
+ ...overrides,
+ }
+ }
+
+ /**
+ * Helper to create a DOM element and run eventRender, returning the aria-label
+ *
+ * @param {object} event The event object
+ * @return {string} The aria-label attribute value
+ */
+ function getAriaLabel(event) {
+ const el = document.createElement('div')
+ eventRender({ event, el })
+ return el.getAttribute('aria-label')
+ }
+
+ describe('all-day events', () => {
+
+ it('should build an aria-label for a single-day all-day event', () => {
+ const start = new Date('2026-03-11T00:00:00')
+ const end = new Date('2026-03-12T00:00:00')
+
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026')
+ isMultiDayAllDayEvent.mockReturnValue(false)
+
+ const label = getAriaLabel(createEvent({ allDay: true, start, end }))
+
+ expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026')
+ expect(formatDateWithTimezone).toHaveBeenCalledWith(start, 'en', expect.objectContaining({
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }))
+ })
+
+ it('should build an aria-label for a multi-day all-day event', () => {
+ const start = new Date('2026-03-11T00:00:00')
+ const end = new Date('2026-03-14T00:00:00')
+
+ formatDateWithTimezone
+ .mockReturnValueOnce('Wednesday, March 11, 2026')
+ .mockReturnValueOnce('Friday, March 13, 2026')
+ isMultiDayAllDayEvent.mockReturnValue(true)
+
+ const label = getAriaLabel(createEvent({ allDay: true, start, end }))
+
+ expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026 to Friday, March 13, 2026')
+ // Should have been called twice: once for start, once for adjusted end
+ expect(formatDateWithTimezone).toHaveBeenCalledTimes(2)
+ })
+
+ it('should handle an all-day event with no end date', () => {
+ const start = new Date('2026-03-11T00:00:00')
+
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026')
+
+ const label = getAriaLabel(createEvent({ allDay: true, start, end: null }))
+
+ expect(label).toBe('Team Meeting, All day: Wednesday, March 11, 2026')
+ })
+
+ it('should handle an all-day event with no start date', () => {
+ const label = getAriaLabel(createEvent({ allDay: true, start: null, end: null }))
+
+ expect(label).toBe('Team Meeting, All day')
+ expect(formatDateWithTimezone).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('timed events', () => {
+
+ it('should build an aria-label for a same-day timed event', () => {
+ const start = new Date('2026-03-11T10:00:00Z')
+ const end = new Date('2026-03-11T11:00:00Z')
+ // Make toDateString() match for same-day detection
+ start.toDateString = () => 'Wed Mar 11 2026'
+ end.toDateString = () => 'Wed Mar 11 2026'
+
+ formatDateWithTimezone
+ .mockReturnValueOnce('Wednesday, March 11, 2026, 10:00 AM')
+ .mockReturnValueOnce('11:00 AM')
+
+ const label = getAriaLabel(createEvent({ allDay: false, start, end }))
+
+ expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM to 11:00 AM')
+ expect(formatDateWithTimezone).toHaveBeenCalledTimes(2)
+ // First call: full datetime for start
+ expect(formatDateWithTimezone).toHaveBeenCalledWith(start, 'en', expect.objectContaining({
+ weekday: 'long',
+ hour: 'numeric',
+ minute: 'numeric',
+ }), true)
+ // Second call: time-only for end
+ expect(formatDateWithTimezone).toHaveBeenCalledWith(end, 'en', expect.objectContaining({
+ hour: 'numeric',
+ minute: 'numeric',
+ }), true)
+ })
+
+ it('should build an aria-label for a multi-day timed event', () => {
+ const start = new Date('2026-03-11T10:00:00Z')
+ const end = new Date('2026-03-12T14:00:00Z')
+ start.toDateString = () => 'Wed Mar 11 2026'
+ end.toDateString = () => 'Thu Mar 12 2026'
+
+ formatDateWithTimezone
+ .mockReturnValueOnce('Wednesday, March 11, 2026, 10:00 AM')
+ .mockReturnValueOnce('Thursday, March 12, 2026, 2:00 PM')
+
+ const label = getAriaLabel(createEvent({ allDay: false, start, end }))
+
+ expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM to Thursday, March 12, 2026, 2:00 PM')
+ expect(formatDateWithTimezone).toHaveBeenCalledTimes(2)
+ // Both calls use full dateTime options
+ expect(formatDateWithTimezone).toHaveBeenNthCalledWith(1, start, 'en', expect.objectContaining({
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ }), true)
+ expect(formatDateWithTimezone).toHaveBeenNthCalledWith(2, end, 'en', expect.objectContaining({
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ }), true)
+ })
+
+ it('should handle a timed event with no end date', () => {
+ const start = new Date('2026-03-11T10:00:00Z')
+
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026, 10:00 AM')
+
+ const label = getAriaLabel(createEvent({ allDay: false, start, end: null }))
+
+ expect(label).toBe('Team Meeting, Wednesday, March 11, 2026, 10:00 AM')
+ expect(formatDateWithTimezone).toHaveBeenCalledTimes(1)
+ })
+
+ it('should handle a timed event with no start date', () => {
+ const label = getAriaLabel(createEvent({ allDay: false, start: null, end: null }))
+
+ expect(label).toBe('Team Meeting')
+ expect(formatDateWithTimezone).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('edge cases', () => {
+
+ it('should use "Untitled event" for events with no title', () => {
+ const start = new Date('2026-03-11T00:00:00')
+
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026')
+ isMultiDayAllDayEvent.mockReturnValue(false)
+
+ const label = getAriaLabel(createEvent({ title: '', allDay: true, start, end: null }))
+
+ expect(label).toBe('Untitled event, All day: Wednesday, March 11, 2026')
+ })
+
+ it('should use "Untitled event" for events with undefined title', () => {
+ const label = getAriaLabel(createEvent({ title: undefined, allDay: false, start: null, end: null }))
+
+ expect(label).toBe('Untitled event')
+ })
+
+ it('should set the aria-label attribute on the DOM element', () => {
+ const start = new Date('2026-03-11T00:00:00')
+
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026')
+ isMultiDayAllDayEvent.mockReturnValue(false)
+
+ const el = document.createElement('div')
+ const event = createEvent({ allDay: true, start, end: null })
+
+ eventRender({ event, el })
+
+ expect(el.hasAttribute('aria-label')).toBe(true)
+ expect(el.getAttribute('aria-label')).toBeTruthy()
+ expect(el.getAttribute('aria-label')).toContain('Team Meeting')
+ })
+
+ it('should use all localized strings via t() and not hardcode separators', () => {
+ const start = new Date('2026-03-11T10:00:00Z')
+ const end = new Date('2026-03-11T11:00:00Z')
+ start.toDateString = () => 'Wed Mar 11 2026'
+ end.toDateString = () => 'Wed Mar 11 2026'
+
+ formatDateWithTimezone
+ .mockReturnValueOnce('formatted-start')
+ .mockReturnValueOnce('formatted-end')
+
+ getAriaLabel(createEvent({ allDay: false, start, end }))
+
+ // Verify that translate was called for combining title + time description
+ // (not just raw string concatenation)
+ const translateCalls = translate.mock.calls
+ const ariaLabelCall = translateCalls.find(
+ call => call[1] === '{title}, {timeDescription}',
+ )
+ expect(ariaLabelCall).toBeTruthy()
+ expect(ariaLabelCall[2]).toEqual({
+ title: 'Team Meeting',
+ timeDescription: 'formatted-start to formatted-end',
+ })
+ })
+
+ it('should pass through getCanonicalLocale to formatDateWithTimezone', () => {
+ getCanonicalLocale.mockReturnValue('ar')
+
+ const start = new Date('2026-03-11T00:00:00')
+ formatDateWithTimezone.mockReturnValue('الأربعاء، ١١ مارس ٢٠٢٦')
+ isMultiDayAllDayEvent.mockReturnValue(false)
+
+ getAriaLabel(createEvent({ allDay: true, start, end: null }))
+
+ expect(formatDateWithTimezone).toHaveBeenCalledWith(
+ start,
+ 'ar',
+ expect.any(Object),
+ )
+ })
+
+ it('should handle undefined locale from getCanonicalLocale', () => {
+ getCanonicalLocale.mockReturnValue('')
+
+ const start = new Date('2026-03-11T00:00:00')
+ formatDateWithTimezone.mockReturnValue('Wednesday, March 11, 2026')
+ isMultiDayAllDayEvent.mockReturnValue(false)
+
+ getAriaLabel(createEvent({ allDay: true, start, end: null }))
+
+ // Empty string is falsy, so locale should be undefined
+ expect(formatDateWithTimezone).toHaveBeenCalledWith(
+ start,
+ undefined,
+ expect.any(Object),
+ )
+ })
+ })
+})