diff --git a/package-lock.json b/package-lock.json
index 054be889ab..42fa7cbe3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,6 +35,7 @@
"@nextcloud/timezones": "^1.0.2",
"@nextcloud/vue": "^9.8.2",
"autosize": "^6.0.1",
+ "chrono-node": "^2.9.1",
"color-convert": "^3.1.3",
"color-string": "^2.1.4",
"core-js": "^3.49.0",
@@ -2730,9 +2731,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2747,9 +2745,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2764,9 +2759,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2781,9 +2773,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5320,6 +5309,15 @@
"node": ">=6.0"
}
},
+ "node_modules/chrono-node": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.9.1.tgz",
+ "integrity": "sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
"node_modules/cipher-base": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz",
diff --git a/package.json b/package.json
index 8334bf1128..7ee4c9f502 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"@nextcloud/timezones": "^1.0.2",
"@nextcloud/vue": "^9.8.2",
"autosize": "^6.0.1",
+ "chrono-node": "^2.9.1",
"color-convert": "^3.1.3",
"color-string": "^2.1.4",
"core-js": "^3.49.0",
diff --git a/src/components/Editor/Properties/TimeSuggestion.vue b/src/components/Editor/Properties/TimeSuggestion.vue
new file mode 100644
index 0000000000..4746611341
--- /dev/null
+++ b/src/components/Editor/Properties/TimeSuggestion.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+ {{ suggestionLabel }}
+
+ {{ t('calendar', 'Apply') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mixins/TimeSuggestionMixin.js b/src/mixins/TimeSuggestionMixin.js
new file mode 100644
index 0000000000..20b4f9ee9f
--- /dev/null
+++ b/src/mixins/TimeSuggestionMixin.js
@@ -0,0 +1,121 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLanguage } from '@nextcloud/l10n'
+import { parseNaturalLanguageTime } from '../services/naturalLanguageTimeParserService.js'
+
+export default {
+ data() {
+ return {
+ timeSuggestion: null,
+ }
+ },
+
+ methods: {
+ handleTitleUpdate(value) {
+ this.updateTitle(value)
+ const parsed = parseNaturalLanguageTime(
+ value,
+ this.calendarObjectInstance?.startDate ?? null,
+ getLanguage(),
+ )
+ if (parsed) {
+ const before = value.slice(0, parsed.matchedIndex)
+ const after = value.slice(parsed.matchedIndex + parsed.matchedText.length)
+ parsed.strippedTitle = (before + after).replace(/\s+/g, ' ').trim()
+ }
+ this.timeSuggestion = parsed
+ },
+
+ async applyTimeSuggestion() {
+ const s = this.timeSuggestion
+ if (!s || !this.calendarObjectInstance) {
+ return
+ }
+
+ const base = new Date(this.calendarObjectInstance.startDate)
+
+ switch (s.type) {
+ case 'all-day':
+ if (!this.isAllDay) {
+ this.toggleAllDay()
+ }
+ break
+ case 'time-range': {
+ if (this.isAllDay) {
+ this.toggleAllDay()
+ }
+ const start = new Date(base)
+ start.setHours(s.startHour, s.startMinute, 0, 0)
+ const end = new Date(base)
+ end.setHours(s.endHour, s.endMinute, 0, 0)
+ this.updateStartTime(start)
+ this.updateEndTime(end)
+ break
+ }
+ case 'time-only': {
+ if (this.isAllDay) {
+ this.toggleAllDay()
+ }
+ const start = new Date(base)
+ start.setHours(s.startHour, s.startMinute, 0, 0)
+ this.updateStartTime(start)
+ break
+ }
+ case 'date-range': {
+ if (!this.isAllDay) {
+ this.toggleAllDay()
+ }
+ this.updateStartDate(new Date(s.startYear, s.startMonth - 1, s.startDay))
+ this.updateEndDate(new Date(s.endYear, s.endMonth - 1, s.endDay))
+ break
+ }
+ case 'date': {
+ if (!this.isAllDay) {
+ this.toggleAllDay()
+ }
+ const d = new Date(s.year, s.month - 1, s.day)
+ this.updateStartDate(d)
+ this.updateEndDate(d)
+ break
+ }
+ case 'datetime': {
+ if (this.isAllDay) {
+ this.toggleAllDay()
+ }
+ const d = new Date(s.year, s.month - 1, s.day, s.startHour, s.startMinute, 0, 0)
+ this.updateStartDate(d)
+ this.updateStartTime(d)
+ break
+ }
+ }
+
+ // Clear suggestion first so Vue flushes that re-render, then update the
+ // title in a separate tick so the NcTextField double-useModel chain
+ // propagates the empty value cleanly to the DOM input.
+ const strippedTitle = s.strippedTitle ?? ''
+ this.timeSuggestion = null
+ await this.$nextTick()
+ this.updateTitle(strippedTitle)
+ },
+
+ dismissTimeSuggestion() {
+ this.timeSuggestion = null
+ },
+
+ handleTitleAreaKeydown(event) {
+ if (!this.timeSuggestion) {
+ return
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ this.applyTimeSuggestion()
+ } else if (event.key === 'Escape') {
+ event.stopPropagation()
+ this.dismissTimeSuggestion()
+ }
+ },
+ },
+}
diff --git a/src/services/naturalLanguageTimeParserService.js b/src/services/naturalLanguageTimeParserService.js
new file mode 100644
index 0000000000..fbd3a72b84
--- /dev/null
+++ b/src/services/naturalLanguageTimeParserService.js
@@ -0,0 +1,240 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import * as chrono from 'chrono-node'
+
+// Extend the casual English parser with the European DD.MM.YY(YY) date format.
+const europeanDateParser = {
+ pattern: () => /\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b/,
+ extract(_context, match) {
+ const day = parseInt(match[1], 10)
+ const month = parseInt(match[2], 10)
+ let year = parseInt(match[3], 10)
+ if (year < 100) {
+ year = year < 50 ? 2000 + year : 1900 + year
+ }
+ if (day < 1 || day > 31 || month < 1 || month > 12) {
+ return null
+ }
+ return { day, month, year }
+ },
+}
+
+/**
+ * @param {object} base chrono locale object
+ * @return {object} chrono instance with the European date parser prepended
+ */
+function buildInstance(base) {
+ const instance = base.casual.clone()
+ // Prepend so the European DD.MM.YY parser takes priority over chrono's built-in
+ // numeric date parser, which would otherwise misread e.g. "12.07.2026" as December 7.
+ instance.parsers.unshift(europeanDateParser)
+ return instance
+}
+
+// One pre-built instance per supported locale, created once at module load.
+const chronoInstances = {
+ en: buildInstance(chrono.en),
+ de: buildInstance(chrono.de),
+ fr: buildInstance(chrono.fr),
+ es: buildInstance(chrono.es),
+ it: buildInstance(chrono.it),
+ nl: buildInstance(chrono.nl),
+ pt: buildInstance(chrono.pt),
+ ru: buildInstance(chrono.ru),
+ uk: buildInstance(chrono.uk),
+ sv: buildInstance(chrono.sv),
+ fi: buildInstance(chrono.fi),
+ ja: buildInstance(chrono.ja),
+ vi: buildInstance(chrono.vi),
+ 'zh-hans': buildInstance(chrono.zh.hans),
+ 'zh-hant': buildInstance(chrono.zh.hant),
+}
+
+/**
+ * Map a Nextcloud locale code (e.g. "de_DE", "zh_CN") to a chrono instance.
+ *
+ * @param {string|null} language Nextcloud language string from getLanguage()
+ * @return {object} chrono instance
+ */
+function getChronoInstance(language) {
+ if (!language) {
+ return chronoInstances.en
+ }
+ const lang = language.toLowerCase().replace(/_/g, '-')
+ // Chinese variants: Simplified (CN/SG/default) vs Traditional (TW/HK/MO)
+ if (lang === 'zh' || lang === 'zh-cn' || lang === 'zh-sg') {
+ return chronoInstances['zh-hans']
+ }
+ if (lang === 'zh-tw' || lang === 'zh-hk' || lang === 'zh-mo') {
+ return chronoInstances['zh-hant']
+ }
+ // Try full locale, then base language, then fall back to English
+ const base = lang.split('-')[0]
+ return chronoInstances[lang] ?? chronoInstances[base] ?? chronoInstances.en
+}
+
+/**
+ * @param {number} n number to pad
+ * @return {string} zero-padded two-digit string
+ */
+function pad(n) {
+ return String(n).padStart(2, '0')
+}
+
+/**
+ * @param {number} hour hour (0-23)
+ * @param {number} minute minute (0-59)
+ * @return {string} formatted time string "HH:MM"
+ */
+function formatTimeParts(hour, minute) {
+ return `${pad(hour)}:${pad(minute)}`
+}
+
+/**
+ * Parse natural language time expressions from an event title.
+ *
+ * Recognises clock times (2pm, 14:00), time ranges (10:00–14:00, 10am to 2pm),
+ * dates (23.07.26, July 23, 07/23/2026) and the literal "all day".
+ *
+ * @param {string|null} text Event title to examine
+ * @param {Date|null} referenceDate Reference date for forward-date resolution (defaults to now)
+ * @param {string|null} language Nextcloud language code, e.g. "de" or "zh_CN" (defaults to English)
+ * @return {object|null} Parsed suggestion or null when nothing was recognised
+ */
+export function parseNaturalLanguageTime(text, referenceDate = null, language = null) {
+ if (!text || text.trim() === '') {
+ return null
+ }
+
+ // "all day" / "all-day" — chrono-node does not handle this phrase
+ const allDayMatch = text.match(/\ball[\s-]?day\b/i)
+ if (allDayMatch) {
+ return {
+ type: 'all-day',
+ displayText: 'All day',
+ matchedText: allDayMatch[0],
+ matchedIndex: allDayMatch.index,
+ }
+ }
+
+ const ref = referenceDate ?? new Date()
+ const results = getChronoInstance(language).parse(text, ref, { forwardDate: true })
+
+ if (!results.length) {
+ return null
+ }
+
+ const result = results[0]
+ const start = result.start
+ const end = result.end
+
+ const startHourCertain = start.isCertain('hour')
+ // weekday covers relative expressions like "next Monday" / "this Friday"
+ const startDateCertain = start.isCertain('day') || start.isCertain('month') || start.isCertain('weekday')
+
+ if (end && end.isCertain('hour')) {
+ // Time range: "10:00 – 14:00", "10am to 2pm"
+ const sh = start.get('hour')
+ const sm = start.get('minute') ?? 0
+ const eh = end.get('hour')
+ const em = end.get('minute') ?? 0
+ return {
+ type: 'time-range',
+ startHour: sh,
+ startMinute: sm,
+ endHour: eh,
+ endMinute: em,
+ displayText: `${formatTimeParts(sh, sm)} – ${formatTimeParts(eh, em)}`,
+ matchedText: result.text,
+ matchedIndex: result.index,
+ }
+ }
+
+ if (startHourCertain && !startDateCertain) {
+ // Time only: "2pm", "14:00"
+ const sh = start.get('hour')
+ const sm = start.get('minute') ?? 0
+ return {
+ type: 'time-only',
+ startHour: sh,
+ startMinute: sm,
+ displayText: formatTimeParts(sh, sm),
+ matchedText: result.text,
+ matchedIndex: result.index,
+ }
+ }
+
+ const endDateCertain = end && (end.isCertain('day') || end.isCertain('month'))
+
+ if (startDateCertain && endDateCertain && !startHourCertain) {
+ // Date range: "June 1 to June 3", "June 1st - June 3rd"
+ const sy = start.get('year')
+ const smo = start.get('month')
+ const sd = start.get('day')
+ const ey = end.get('year')
+ const emo = end.get('month')
+ const ed = end.get('day')
+ const fmt = { day: 'numeric', month: 'long', year: 'numeric' }
+ return {
+ type: 'date-range',
+ startYear: sy,
+ startMonth: smo,
+ startDay: sd,
+ endYear: ey,
+ endMonth: emo,
+ endDay: ed,
+ displayText: `${new Date(sy, smo - 1, sd).toLocaleDateString(undefined, fmt)} – ${new Date(ey, emo - 1, ed).toLocaleDateString(undefined, fmt)}`,
+ matchedText: result.text,
+ matchedIndex: result.index,
+ }
+ }
+
+ if (startDateCertain && !startHourCertain) {
+ // Date only: "July 23", "23.07.26", "07/23/2026"
+ const y = start.get('year')
+ const mo = start.get('month')
+ const d = start.get('day')
+ return {
+ type: 'date',
+ year: y,
+ month: mo,
+ day: d,
+ displayText: new Date(y, mo - 1, d).toLocaleDateString(undefined, {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }),
+ matchedText: result.text,
+ matchedIndex: result.index,
+ }
+ }
+
+ if (startDateCertain && startHourCertain) {
+ // Date + time: "July 23, 4pm", "14.08.2026 19:00"
+ const y = start.get('year')
+ const mo = start.get('month')
+ const d = start.get('day')
+ const sh = start.get('hour')
+ const sm = start.get('minute') ?? 0
+ return {
+ type: 'datetime',
+ year: y,
+ month: mo,
+ day: d,
+ startHour: sh,
+ startMinute: sm,
+ displayText: `${new Date(y, mo - 1, d).toLocaleDateString(undefined, {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ })} ${formatTimeParts(sh, sm)}`,
+ matchedText: result.text,
+ matchedIndex: result.index,
+ }
+ }
+
+ return null
+}
diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue
index 2a08cc0e91..6496d19c57 100644
--- a/src/views/EditFull.vue
+++ b/src/views/EditFull.vue
@@ -40,10 +40,18 @@
-
+
@@ -382,10 +390,12 @@ import PropertySelectMultiple from '../components/Editor/Properties/PropertySele
import PropertyText from '../components/Editor/Properties/PropertyText.vue'
import PropertyTitle from '../components/Editor/Properties/PropertyTitle.vue'
import PropertyTitleTimePicker from '../components/Editor/Properties/PropertyTitleTimePicker.vue'
+import TimeSuggestion from '../components/Editor/Properties/TimeSuggestion.vue'
import Repeat from '../components/Editor/Repeat/Repeat.vue'
import ResourceList from '../components/Editor/Resources/ResourceList.vue'
import SaveButtons from '../components/Editor/SaveButtons.vue'
import EditorMixin from '../mixins/EditorMixin.js'
+import TimeSuggestionMixin from '../mixins/TimeSuggestionMixin.js'
import { shareFile } from '../services/attachmentService.js'
import getTimezoneManager from '../services/timezoneDataProviderService.js'
import useCalendarObjectInstanceStore from '../store/calendarObjectInstance.js'
@@ -426,6 +436,7 @@ export default {
AttachmentsList,
CalendarPickerHeader,
PropertyTitle,
+ TimeSuggestion,
IconVideo,
HelpCircleIcon,
NcActions,
@@ -435,6 +446,7 @@ export default {
mixins: [
EditorMixin,
+ TimeSuggestionMixin,
],
data() {
@@ -545,6 +557,12 @@ export default {
},
},
+ watch: {
+ calendarObjectInstance() {
+ this.timeSuggestion = null
+ },
+ },
+
mounted() {
window.addEventListener('keydown', this.keyboardCloseEditor)
window.addEventListener('keydown', this.keyboardSaveEvent)
@@ -807,6 +825,7 @@ export default {
this.toggleAllDay()
},
+
},
}
diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue
index 61102d50f2..4d08e105c9 100644
--- a/src/views/EditSimple.vue
+++ b/src/views/EditSimple.vue
@@ -129,11 +129,19 @@
:isViewedByAttendee="isViewedByOrganizer === false"
@update:value="changeCalendar" />
-
+
{{ $t('calendar', 'This event was cancelled') }}
@@ -302,8 +310,10 @@ import PropertyText from '../components/Editor/Properties/PropertyText.vue'
import PropertyTitle from '../components/Editor/Properties/PropertyTitle.vue'
import PropertyTitleTimePicker
from '../components/Editor/Properties/PropertyTitleTimePicker.vue'
+import TimeSuggestion from '../components/Editor/Properties/TimeSuggestion.vue'
import SaveButtons from '../components/Editor/SaveButtons.vue'
import EditorMixin from '../mixins/EditorMixin.js'
+import TimeSuggestionMixin from '../mixins/TimeSuggestionMixin.js'
import useCalendarObjectInstanceStore from '../store/calendarObjectInstance.js'
import useSettingsStore from '../store/settings.js'
import useWidgetStore from '../store/widget.js'
@@ -317,6 +327,7 @@ export default {
PropertyText,
PropertyTitleTimePicker,
PropertyTitle,
+ TimeSuggestion,
NcPopover,
Actions,
ActionButton,
@@ -344,6 +355,7 @@ export default {
mixins: [
EditorMixin,
+ TimeSuggestionMixin,
],
props: {
@@ -457,6 +469,7 @@ export default {
this.hasAttendees = false
this.hasAlarms = false
this.isCancelled = false
+ this.timeSuggestion = null
if (this.calendarObjectInstance) {
if (typeof this.calendarObjectInstance.location === 'string' && this.calendarObjectInstance.location.trim() !== '') {
@@ -824,6 +837,7 @@ export default {
this.toggleAllDay()
},
+
},
}
diff --git a/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js b/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js
new file mode 100644
index 0000000000..906c71cacf
--- /dev/null
+++ b/tests/javascript/unit/services/naturalLanguageTimeParserService.test.js
@@ -0,0 +1,731 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { parseNaturalLanguageTime } from '../../../../src/services/naturalLanguageTimeParserService.js'
+
+// Fixed reference: Monday 15 June 2026, 09:00 local time
+const REF = new Date(2026, 5, 15, 9, 0, 0)
+
+describe('services/naturalLanguageTimeParserService', () => {
+
+ describe('null / empty input', () => {
+ it('returns null for null', () => {
+ expect(parseNaturalLanguageTime(null)).toBeNull()
+ })
+
+ it('returns null for empty string', () => {
+ expect(parseNaturalLanguageTime('')).toBeNull()
+ })
+
+ it('returns null for whitespace-only string', () => {
+ expect(parseNaturalLanguageTime(' ')).toBeNull()
+ })
+
+ it('returns null for plain title with no time', () => {
+ expect(parseNaturalLanguageTime('Team retrospective', REF)).toBeNull()
+ })
+ })
+
+ describe('all-day detection', () => {
+ it('recognises "all day"', () => {
+ const result = parseNaturalLanguageTime('all day', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('all-day')
+ })
+
+ it('recognises "all-day"', () => {
+ const result = parseNaturalLanguageTime('all-day', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('all-day')
+ })
+
+ it('recognises "All Day" (case-insensitive)', () => {
+ expect(parseNaturalLanguageTime('All Day', REF).type).toBe('all-day')
+ })
+
+ it('finds "all day" embedded in a title', () => {
+ expect(parseNaturalLanguageTime('Birthday party all day', REF).type).toBe('all-day')
+ })
+
+ it('provides matchedText and matchedIndex for "all day"', () => {
+ const result = parseNaturalLanguageTime('Birthday party all day', REF)
+ expect(result.matchedText).toBe('all day')
+ expect(result.matchedIndex).toBe(15)
+ })
+ })
+
+ describe('time-only detection', () => {
+ it('parses 12-hour time "2pm"', () => {
+ const result = parseNaturalLanguageTime('2pm', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses 12-hour time "2 pm"', () => {
+ const result = parseNaturalLanguageTime('2 pm', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ })
+
+ it('parses 12-hour time with minutes "2:30pm"', () => {
+ const result = parseNaturalLanguageTime('2:30pm', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ expect(result.startMinute).toBe(30)
+ })
+
+ it('parses AM time "10am"', () => {
+ const result = parseNaturalLanguageTime('10am', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(10)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses midnight "12am"', () => {
+ const result = parseNaturalLanguageTime('12am', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(0)
+ })
+
+ it('parses noon "12pm"', () => {
+ const result = parseNaturalLanguageTime('12pm', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(12)
+ })
+
+ it('parses 24-hour time "14:00"', () => {
+ const result = parseNaturalLanguageTime('14:00', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses 24-hour time "19:00"', () => {
+ const result = parseNaturalLanguageTime('19:00', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(19)
+ })
+
+ it('parses 24-hour time with minutes "09:30"', () => {
+ const result = parseNaturalLanguageTime('09:30', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(9)
+ expect(result.startMinute).toBe(30)
+ })
+
+ it('picks up time embedded in a title', () => {
+ const result = parseNaturalLanguageTime('Team standup 9am', REF)
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(9)
+ })
+
+ it('includes formatted displayText', () => {
+ const result = parseNaturalLanguageTime('14:00', REF)
+ expect(result.displayText).toBe('14:00')
+ })
+
+ it('provides matchedText and matchedIndex for embedded time', () => {
+ const result = parseNaturalLanguageTime('Team standup 9am', REF)
+ expect(result.matchedText).toBe('9am')
+ expect(result.matchedIndex).toBe(13)
+ })
+ })
+
+ describe('time-range detection', () => {
+ it('parses "10:00 - 14:00"', () => {
+ const result = parseNaturalLanguageTime('10:00 - 14:00', REF)
+ expect(result.type).toBe('time-range')
+ expect(result.startHour).toBe(10)
+ expect(result.startMinute).toBe(0)
+ expect(result.endHour).toBe(14)
+ expect(result.endMinute).toBe(0)
+ })
+
+ it('parses "10am to 2pm"', () => {
+ const result = parseNaturalLanguageTime('10am to 2pm', REF)
+ expect(result.type).toBe('time-range')
+ expect(result.startHour).toBe(10)
+ expect(result.endHour).toBe(14)
+ })
+
+ it('parses "9:00 - 10:30" with non-zero minutes', () => {
+ const result = parseNaturalLanguageTime('9:00 - 10:30', REF)
+ expect(result.type).toBe('time-range')
+ expect(result.startHour).toBe(9)
+ expect(result.startMinute).toBe(0)
+ expect(result.endHour).toBe(10)
+ expect(result.endMinute).toBe(30)
+ })
+
+ it('parses ranges embedded in a title', () => {
+ const result = parseNaturalLanguageTime('Workshop 10am - 1pm', REF)
+ expect(result.type).toBe('time-range')
+ expect(result.startHour).toBe(10)
+ expect(result.endHour).toBe(13)
+ })
+
+ it('includes both times in displayText', () => {
+ const result = parseNaturalLanguageTime('10:00 - 14:00', REF)
+ expect(result.displayText).toContain('10:00')
+ expect(result.displayText).toContain('14:00')
+ })
+
+ it('provides matchedText and matchedIndex for a range', () => {
+ const result = parseNaturalLanguageTime('Workshop 10:00 - 14:00', REF)
+ expect(result.matchedText).toBe('10:00 - 14:00')
+ expect(result.matchedIndex).toBe(9)
+ })
+ })
+
+ describe('date detection', () => {
+ it('parses European format "23.07.26"', () => {
+ const result = parseNaturalLanguageTime('23.07.26', REF)
+ expect(result.type).toBe('date')
+ expect(result.year).toBe(2026)
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ })
+
+ it('parses European format with full year "23.07.2026"', () => {
+ const result = parseNaturalLanguageTime('23.07.2026', REF)
+ expect(result.type).toBe('date')
+ expect(result.year).toBe(2026)
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ })
+
+ it('parses US slash format "07/23/2026"', () => {
+ const result = parseNaturalLanguageTime('07/23/2026', REF)
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ expect(result.year).toBe(2026)
+ })
+
+ it('parses month-name format "July 23"', () => {
+ const result = parseNaturalLanguageTime('July 23', REF)
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ })
+
+ it('parses month-name with year "July 23 2026"', () => {
+ const result = parseNaturalLanguageTime('July 23 2026', REF)
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ expect(result.year).toBe(2026)
+ })
+
+ it('parses abbreviated month "Sep 4"', () => {
+ const result = parseNaturalLanguageTime('Sep 4', REF)
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(9)
+ expect(result.day).toBe(4)
+ })
+
+ it('picks up date embedded in a title', () => {
+ const result = parseNaturalLanguageTime('Flight 23.07.26', REF)
+ expect(result.type).toBe('date')
+ expect(result.day).toBe(23)
+ expect(result.month).toBe(7)
+ })
+
+ it('provides matchedText and matchedIndex for an embedded date', () => {
+ const result = parseNaturalLanguageTime('Flight 23.07.26', REF)
+ expect(result.matchedText).toBe('23.07.26')
+ expect(result.matchedIndex).toBe(7)
+ })
+ })
+
+ describe('title stripping via matchedText / matchedIndex', () => {
+ function stripMatch(title, result) {
+ const before = title.slice(0, result.matchedIndex)
+ const after = title.slice(result.matchedIndex + result.matchedText.length)
+ return (before + after).replace(/\s+/g, ' ').trim()
+ }
+
+ function parseWithStrip(title) {
+ const result = parseNaturalLanguageTime(title, REF)
+ if (!result) return null
+ result.strippedTitle = stripMatch(title, result)
+ return result
+ }
+
+ it('strips standalone "1pm" leaving an empty title', () => {
+ const r = parseWithStrip('1pm')
+ expect(r.type).toBe('time-only')
+ expect(r.matchedText).toBe('1pm')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('')
+ })
+
+ it('strips trailing "1pm" — "Meeting 1pm"', () => {
+ const r = parseWithStrip('Meeting 1pm')
+ expect(r.matchedText).toBe('1pm')
+ expect(r.matchedIndex).toBe(8)
+ expect(r.strippedTitle).toBe('Meeting')
+ })
+
+ it('strips leading "1pm" — "1pm dentist"', () => {
+ const r = parseWithStrip('1pm dentist')
+ expect(r.matchedText).toBe('1pm')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('dentist')
+ })
+
+ it('strips a trailing time — "Team standup 9am"', () => {
+ const r = parseWithStrip('Team standup 9am')
+ expect(r.matchedText).toBe('9am')
+ expect(r.matchedIndex).toBe(13)
+ expect(r.strippedTitle).toBe('Team standup')
+ })
+
+ it('strips a leading time — "9am Team standup"', () => {
+ const r = parseWithStrip('9am Team standup')
+ expect(r.matchedText).toBe('9am')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('Team standup')
+ })
+
+ it('strips a mid-title time — "Lunch 12:00 with Anna"', () => {
+ const r = parseWithStrip('Lunch 12:00 with Anna')
+ expect(r.matchedText).toBe('12:00')
+ expect(r.matchedIndex).toBe(6)
+ expect(r.strippedTitle).toBe('Lunch with Anna')
+ })
+
+ it('strips a trailing time range — "Workshop 10:00 - 14:00"', () => {
+ const r = parseWithStrip('Workshop 10:00 - 14:00')
+ expect(r.matchedText).toBe('10:00 - 14:00')
+ expect(r.matchedIndex).toBe(9)
+ expect(r.strippedTitle).toBe('Workshop')
+ })
+
+ it('strips a mid-title time range — "Workshop 10:00 - 14:00 venue TBD"', () => {
+ const r = parseWithStrip('Workshop 10:00 - 14:00 venue TBD')
+ expect(r.matchedIndex).toBe(9)
+ expect(r.strippedTitle).toBe('Workshop venue TBD')
+ })
+
+ it('strips a leading time range — "10am to 2pm team sync"', () => {
+ const r = parseWithStrip('10am to 2pm team sync')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('team sync')
+ })
+
+ it('strips trailing "all day" — "Birthday party all day"', () => {
+ const r = parseWithStrip('Birthday party all day')
+ expect(r.matchedText).toBe('all day')
+ expect(r.matchedIndex).toBe(15)
+ expect(r.strippedTitle).toBe('Birthday party')
+ })
+
+ it('strips leading "all-day" — "all-day conference"', () => {
+ const r = parseWithStrip('all-day conference')
+ expect(r.matchedText).toBe('all-day')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('conference')
+ })
+
+ it('strips a trailing European date — "Flight 23.07.26"', () => {
+ const r = parseWithStrip('Flight 23.07.26')
+ expect(r.matchedText).toBe('23.07.26')
+ expect(r.matchedIndex).toBe(7)
+ expect(r.strippedTitle).toBe('Flight')
+ })
+
+ it('strips a leading European date — "23.07.26 dentist"', () => {
+ const r = parseWithStrip('23.07.26 dentist')
+ expect(r.matchedText).toBe('23.07.26')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('dentist')
+ })
+
+ it('strips a month-name date — "July 23 dentist"', () => {
+ const r = parseWithStrip('July 23 dentist')
+ expect(r.matchedIndex).toBe(0)
+ expect(r.strippedTitle).toBe('dentist')
+ })
+
+ it('strips datetime from title — "Dentist July 23, 4pm downtown"', () => {
+ const r = parseWithStrip('Dentist July 23, 4pm downtown')
+ expect(r.type).toBe('datetime')
+ expect(r.strippedTitle).toBe('Dentist downtown')
+ })
+ })
+
+ describe('datetime detection (date + time combined)', () => {
+ it('parses "July 23, 4pm"', () => {
+ const result = parseNaturalLanguageTime('July 23, 4pm', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('datetime')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ expect(result.startHour).toBe(16)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses "July 23 2026 4pm"', () => {
+ const result = parseNaturalLanguageTime('July 23 2026 4pm', REF)
+ expect(result.type).toBe('datetime')
+ expect(result.year).toBe(2026)
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ expect(result.startHour).toBe(16)
+ })
+
+ it('parses "14.08.2026 19:00"', () => {
+ const result = parseNaturalLanguageTime('14.08.2026 19:00', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('datetime')
+ expect(result.year).toBe(2026)
+ expect(result.month).toBe(8)
+ expect(result.day).toBe(14)
+ expect(result.startHour).toBe(19)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses datetime embedded in a title — "Dentist July 23 4pm downtown"', () => {
+ const result = parseNaturalLanguageTime('Dentist July 23 4pm downtown', REF)
+ expect(result.type).toBe('datetime')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ expect(result.startHour).toBe(16)
+ })
+
+ it('parses European date + 12h time without colon "12.07.2026 9am"', () => {
+ const result = parseNaturalLanguageTime('12.07.2026 9am', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('datetime')
+ expect(result.year).toBe(2026)
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(12)
+ expect(result.startHour).toBe(9)
+ expect(result.startMinute).toBe(0)
+ })
+
+ it('parses European short year + 12h time "12.07.26 9am"', () => {
+ const result = parseNaturalLanguageTime('12.07.26 9am', REF)
+ expect(result.type).toBe('datetime')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(12)
+ expect(result.startHour).toBe(9)
+ })
+
+ it('does not misread "12.07.2026" as December 7 when day > 12 is not possible', () => {
+ // Regression: built-in parser would interpret 12.07 as month=12 day=7
+ const result = parseNaturalLanguageTime('12.07.2026', REF)
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(12)
+ })
+
+ it('includes date and time in displayText', () => {
+ const result = parseNaturalLanguageTime('July 23, 4pm', REF)
+ expect(result.displayText).toContain('16:00')
+ })
+
+ it('provides matchedText and matchedIndex for "July 23, 4pm"', () => {
+ const result = parseNaturalLanguageTime('Meeting July 23, 4pm', REF)
+ expect(result.matchedIndex).toBe(8)
+ expect(result.matchedText).toBe('July 23, 4pm')
+ })
+
+ it('strips datetime from title correctly', () => {
+ const title = 'Dentist July 23, 4pm downtown'
+ const result = parseNaturalLanguageTime(title, REF)
+ const before = title.slice(0, result.matchedIndex)
+ const after = title.slice(result.matchedIndex + result.matchedText.length)
+ expect((before + after).replace(/\s+/g, ' ').trim()).toBe('Dentist downtown')
+ })
+ })
+
+ describe('date-range detection', () => {
+ it('parses "June 1st to June 3rd"', () => {
+ const result = parseNaturalLanguageTime('June 1st to June 3rd', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date-range')
+ expect(result.startMonth).toBe(6)
+ expect(result.startDay).toBe(1)
+ expect(result.endMonth).toBe(6)
+ expect(result.endDay).toBe(3)
+ })
+
+ it('parses "June 1 to June 3"', () => {
+ const result = parseNaturalLanguageTime('June 1 to June 3', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date-range')
+ expect(result.startDay).toBe(1)
+ expect(result.endDay).toBe(3)
+ })
+
+ it('parses "June 1st - June 3rd"', () => {
+ const result = parseNaturalLanguageTime('June 1st - June 3rd', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date-range')
+ expect(result.startDay).toBe(1)
+ expect(result.endDay).toBe(3)
+ })
+
+ it('parses "June 1-3" (short form)', () => {
+ const result = parseNaturalLanguageTime('June 1-3', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date-range')
+ expect(result.startDay).toBe(1)
+ expect(result.endDay).toBe(3)
+ })
+
+ it('includes both dates in displayText', () => {
+ const result = parseNaturalLanguageTime('June 20 to June 25', REF)
+ expect(result.displayText).toContain('20')
+ expect(result.displayText).toContain('25')
+ })
+
+ it('provides matchedText and matchedIndex', () => {
+ const result = parseNaturalLanguageTime('Conference June 1st to June 3rd downtown', REF)
+ expect(result.matchedText).toBe('June 1st to June 3rd')
+ expect(result.matchedIndex).toBe(11)
+ })
+
+ it('strips a date range from a title', () => {
+ const result = parseNaturalLanguageTime('Conference June 1st to June 3rd', REF)
+ const before = 'Conference June 1st to June 3rd'.slice(0, result.matchedIndex)
+ const after = 'Conference June 1st to June 3rd'.slice(result.matchedIndex + result.matchedText.length)
+ const stripped = (before + after).replace(/\s+/g, ' ').trim()
+ expect(stripped).toBe('Conference')
+ })
+ })
+
+ describe('relative date/time expressions', () => {
+ it('parses "tomorrow" as an all-day date', () => {
+ const result = parseNaturalLanguageTime('tomorrow', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.day).toBe(16)
+ expect(result.month).toBe(6)
+ expect(result.year).toBe(2026)
+ })
+
+ it('parses "in 3 days" as a date', () => {
+ const result = parseNaturalLanguageTime('in 3 days', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.day).toBe(18)
+ expect(result.month).toBe(6)
+ })
+
+ it('parses "next week" as a date', () => {
+ const result = parseNaturalLanguageTime('next week', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ })
+
+ it('parses "next Monday" as a date', () => {
+ const result = parseNaturalLanguageTime('next Monday', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.day).toBe(22)
+ expect(result.month).toBe(6)
+ })
+
+ it('parses "this Friday" as a date', () => {
+ const result = parseNaturalLanguageTime('this Friday', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.day).toBe(19)
+ expect(result.month).toBe(6)
+ })
+
+ it('parses "tomorrow 3pm" as datetime', () => {
+ const result = parseNaturalLanguageTime('tomorrow 3pm', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('datetime')
+ expect(result.day).toBe(16)
+ expect(result.startHour).toBe(15)
+ })
+
+ it('parses "next Friday 14:00" as datetime (not time-only)', () => {
+ const result = parseNaturalLanguageTime('next Friday 14:00', REF)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('datetime')
+ expect(result.day).toBe(26)
+ expect(result.startHour).toBe(14)
+ })
+
+ it('strips "tomorrow" from title', () => {
+ const result = parseNaturalLanguageTime('tomorrow', REF)
+ const before = 'tomorrow'.slice(0, result.matchedIndex)
+ const after = 'tomorrow'.slice(result.matchedIndex + result.matchedText.length)
+ expect((before + after).replace(/\s+/g, ' ').trim()).toBe('')
+ })
+
+ it('strips "next Monday" from a title', () => {
+ const result = parseNaturalLanguageTime('Team lunch next Monday', REF)
+ const before = 'Team lunch next Monday'.slice(0, result.matchedIndex)
+ const after = 'Team lunch next Monday'.slice(result.matchedIndex + result.matchedText.length)
+ expect((before + after).replace(/\s+/g, ' ').trim()).toBe('Team lunch')
+ })
+ })
+
+ describe('multi-language support', () => {
+ describe('German (de)', () => {
+ it('parses "14 Uhr" as time-only', () => {
+ const result = parseNaturalLanguageTime('14 Uhr', REF, 'de')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ })
+
+ it('parses "15. Juli" as date', () => {
+ const result = parseNaturalLanguageTime('15. Juli', REF, 'de')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+
+ it('parses "20. Juni bis 22. Juni" as date-range', () => {
+ const result = parseNaturalLanguageTime('20. Juni bis 22. Juni', REF, 'de')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date-range')
+ expect(result.startDay).toBe(20)
+ expect(result.endDay).toBe(22)
+ })
+
+ it('parses European date "23.07.2026" via de locale', () => {
+ const result = parseNaturalLanguageTime('23.07.2026', REF, 'de')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(23)
+ })
+
+ it('accepts regional code "de_DE"', () => {
+ const result = parseNaturalLanguageTime('14 Uhr', REF, 'de_DE')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ })
+ })
+
+ describe('French (fr)', () => {
+ it('parses "14h30" as time-only', () => {
+ const result = parseNaturalLanguageTime('14h30', REF, 'fr')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ expect(result.startMinute).toBe(30)
+ })
+
+ it('parses "15 juillet" as date', () => {
+ const result = parseNaturalLanguageTime('15 juillet', REF, 'fr')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Spanish (es)', () => {
+ it('parses "15 de julio" as date', () => {
+ const result = parseNaturalLanguageTime('15 de julio', REF, 'es')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Italian (it)', () => {
+ it('parses "15 luglio" as date', () => {
+ const result = parseNaturalLanguageTime('15 luglio', REF, 'it')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Dutch (nl)', () => {
+ it('parses "15 juli" as date', () => {
+ const result = parseNaturalLanguageTime('15 juli', REF, 'nl')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Russian (ru)', () => {
+ it('parses "15 июля" as date', () => {
+ const result = parseNaturalLanguageTime('15 июля', REF, 'ru')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Portuguese (pt)', () => {
+ it('parses "15 julho" as date', () => {
+ const result = parseNaturalLanguageTime('15 julho', REF, 'pt')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Swedish (sv)', () => {
+ it('parses "15 juli" as date', () => {
+ const result = parseNaturalLanguageTime('15 juli', REF, 'sv')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('Chinese Simplified (zh_CN)', () => {
+ it('parses "7月15日" as date', () => {
+ const result = parseNaturalLanguageTime('7月15日', REF, 'zh_CN')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('date')
+ expect(result.month).toBe(7)
+ expect(result.day).toBe(15)
+ })
+ })
+
+ describe('language fallback', () => {
+ it('falls back to English for an unsupported language', () => {
+ const result = parseNaturalLanguageTime('2pm', REF, 'ar')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ })
+
+ it('falls back to English when language is null', () => {
+ const result = parseNaturalLanguageTime('2pm', REF, null)
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ })
+ })
+ })
+
+ describe('edge cases', () => {
+ it('returns null when there is no recognisable pattern', () => {
+ expect(parseNaturalLanguageTime('Quarterly review v2', REF)).toBeNull()
+ })
+
+ it('works without a reference date argument', () => {
+ const result = parseNaturalLanguageTime('14:00')
+ expect(result).not.toBeNull()
+ expect(result.type).toBe('time-only')
+ expect(result.startHour).toBe(14)
+ })
+ })
+})