diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue index 9a245ec8c..233b3a300 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue @@ -100,6 +100,8 @@ export default { onCmdSnoozeConversation(snoozeType) { if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { this.showCustomTimeSnoozeModal = true; + } else if (typeof snoozeType === 'number') { + this.updateConversations('snoozed', snoozeType); } else { this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null); } diff --git a/app/javascript/dashboard/helper/snoozeDateParser.js b/app/javascript/dashboard/helper/snoozeDateParser.js new file mode 100644 index 000000000..8aa8a2304 --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser.js @@ -0,0 +1,873 @@ +/** + * snoozeDateParser — Natural language date/time parser for snooze. + * + * Converts free-form English text into a future Date object. + * Handles relative durations ("in 2 hours", "half day"), named days + * ("tomorrow at 3pm", "next friday"), absolute dates ("jan 15", "2025-01-15"), + * time-of-day phrases ("morning", "eod"), and various noise/prefix stripping. + * + * All results are guaranteed to be strictly in the future relative to the + * reference date, with a 999-year maximum cap. + */ + +import { + add, + set, + startOfDay, + getDay, + isSaturday, + isSunday, + nextMonday, + nextTuesday, + nextWednesday, + nextThursday, + nextFriday, + nextSaturday, + nextSunday, + getUnixTime, + isValid, + startOfWeek, + addWeeks, + isBefore, + isAfter, +} from 'date-fns'; + +// ─── Token Definitions ─────────────────────────────────────────────────────── + +const WEEKDAY_MAP = { + sunday: 0, + sun: 0, + monday: 1, + mon: 1, + tuesday: 2, + tue: 2, + tues: 2, + wednesday: 3, + wed: 3, + thursday: 4, + thu: 4, + thur: 4, + thurs: 4, + friday: 5, + fri: 5, + saturday: 6, + sat: 6, +}; + +const MONTH_MAP = { + january: 0, + jan: 0, + february: 1, + feb: 1, + march: 2, + mar: 2, + april: 3, + apr: 3, + may: 4, + june: 5, + jun: 5, + july: 6, + jul: 6, + august: 7, + aug: 7, + september: 8, + sep: 8, + sept: 8, + october: 9, + oct: 9, + november: 10, + nov: 10, + december: 11, + dec: 11, +}; + +const RELATIVE_DAY_MAP = { + today: 0, + tonight: 0, + tomorrow: 1, + tmr: 1, + tmrw: 1, +}; + +const UNIT_MAP = { + s: 'seconds', + sec: 'seconds', + secs: 'seconds', + second: 'seconds', + seconds: 'seconds', + m: 'minutes', + min: 'minutes', + mins: 'minutes', + minute: 'minutes', + minutes: 'minutes', + h: 'hours', + hr: 'hours', + hrs: 'hours', + hour: 'hours', + hours: 'hours', + d: 'days', + day: 'days', + days: 'days', + w: 'weeks', + wk: 'weeks', + wks: 'weeks', + week: 'weeks', + weeks: 'weeks', + mo: 'months', + month: 'months', + months: 'months', + y: 'years', + yr: 'years', + yrs: 'years', + year: 'years', + years: 'years', +}; + +const WORD_NUMBER_MAP = { + a: 1, + an: 1, + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + eleven: 11, + twelve: 12, + fifteen: 15, + twenty: 20, + thirty: 30, + forty: 40, + fifty: 50, + sixty: 60, + ninety: 90, + half: 0.5, +}; + +const NEXT_WEEKDAY_FN = { + 0: nextSunday, + 1: nextMonday, + 2: nextTuesday, + 3: nextWednesday, + 4: nextThursday, + 5: nextFriday, + 6: nextSaturday, +}; + +const TIME_OF_DAY_MAP = { + morning: { hours: 9, minutes: 0 }, + noon: { hours: 12, minutes: 0 }, + afternoon: { hours: 14, minutes: 0 }, + evening: { hours: 18, minutes: 0 }, + night: { hours: 20, minutes: 0 }, + tonight: { hours: 20, minutes: 0 }, + midnight: { hours: 0, minutes: 0 }, + eod: { hours: 17, minutes: 0 }, + 'end of day': { hours: 17, minutes: 0 }, + 'end of the day': { hours: 17, minutes: 0 }, +}; + +const TOD_HOUR_RANGE = { + morning: [4, 12], + noon: [11, 13], + afternoon: [12, 18], + evening: [16, 22], + night: [18, 24], + tonight: [18, 24], + midnight: [23, 25], +}; + +// ─── Generated Regex Fragments (from maps, not hand-duplicated) ────────────── + +const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|'); +const MONTH_NAMES = Object.keys(MONTH_MAP).join('|'); +const UNIT_NAMES = Object.keys(UNIT_MAP).join('|'); +const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|'); +const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|'); +const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight'; + +const NUM_RE = `(\\d+|${WORD_NUMBERS})`; +const UNIT_RE = `(${UNIT_NAMES})`; +const TIME_SUFFIX_RE = + '(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const NOISE_RE = + /^(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push(?:\s+(?:it|this))?)\s+)?(?:(?:on|to|for|at|until|till|by)\s+)?/; + +const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/; + +const normalize = text => { + let t = text + .toLowerCase() + .replace(/[,!?;]+/g, ' ') + .replace(/\.+$/g, '') + .replace(/\s+/g, ' ') + .trim(); + t = t.replace(NOISE_RE, '').trim(); + t = t.replace(APPROX_RE, '').trim(); + return t; +}; + +const parseNumber = str => { + if (!str) return null; + const lower = str.toLowerCase().trim(); + if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower]; + const num = Number(lower); + return Number.isNaN(num) ? null : num; +}; + +const applyTimeToDate = (date, hours, minutes = 0) => + set(date, { hours, minutes, seconds: 0, milliseconds: 0 }); + +const parseTimeString = timeStr => { + if (!timeStr) return null; + const cleaned = timeStr.toLowerCase().replace(/\s+/g, '').trim(); + + const match = cleaned.match( + /^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/ + ); + if (!match) return null; + + let hours = parseInt(match[1], 10); + const minutes = match[2] ? parseInt(match[2], 10) : 0; + const meridiem = match[3]?.replace(/\./g, ''); + + if (meridiem) { + if (hours < 1 || hours > 12) return null; + } + if (meridiem === 'pm' && hours < 12) hours += 12; + if (meridiem === 'am' && hours === 12) hours = 0; + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + + return { hours, minutes }; +}; + +const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => { + if (timeStr) { + const time = parseTimeString(timeStr); + if (!time) return null; + return applyTimeToDate(date, time.hours, time.minutes); + } + return applyTimeToDate(date, defaultHours, 0); +}; + +const strictDate = (year, month, day) => { + const date = new Date(year, month, day); + if ( + !isValid(date) || + date.getFullYear() !== year || + date.getMonth() !== month || + date.getDate() !== day + ) + return null; + return date; +}; + +const futureOrNextYear = (year, month, day, timeStr, now) => { + // Scan up to 8 years ahead to find the next valid future date. + // Handles feb 29 in non-leap years (leap years repeat every 4 years). + const years = Array.from({ length: 9 }, (_, i) => year + i); + let result = null; + years.some(y => { + const base = strictDate(y, month, day); + if (!base) return false; + const date = applyTimeOrDefault(base, timeStr); + if (date && isAfter(date, now)) { + result = date; + return true; + } + return !date; + }); + return result; +}; + +const resolveTimeOfDay = (text, now) => { + const tod = TIME_OF_DAY_MAP[text]; + if (!tod) return null; + let date = applyTimeToDate(now, tod.hours, tod.minutes); + if (!isAfter(date, now)) date = add(date, { days: 1 }); + return date; +}; + +// ─── Pattern Matchers ──────────────────────────────────────────────────────── + +const matchRelativeDuration = (text, now) => { + if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?hour$/)) { + return add(now, { minutes: 30 }); + } + if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?day$/)) { + return add(now, { hours: 12 }); + } + + const match = text.match(new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`)); + if (!match) return null; + + const amount = parseNumber(match[1]); + const unit = UNIT_MAP[match[2]]; + if (amount == null || !unit) return null; + + return add(now, { [unit]: amount }); +}; + +const matchDurationFromNow = (text, now) => { + const match = text.match( + new RegExp(`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`) + ); + if (!match) return null; + + const amount = parseNumber(match[1]); + const unit = UNIT_MAP[match[2]]; + if (amount == null || !unit) return null; + + return add(now, { [unit]: amount }); +}; + +const matchRelativeDay = (text, now) => { + const dayOnlyMatch = text.match(new RegExp(`^(${RELATIVE_DAYS})$`)); + if (dayOnlyMatch) { + const key = dayOnlyMatch[1]; + const offset = RELATIVE_DAY_MAP[key]; + let date = add(startOfDay(now), { days: offset }); + if (key === 'tonight') { + date = applyTimeToDate(date, 20, 0); + if (!isAfter(date, now)) date = add(date, { days: 1 }); + } else if (offset === 1) { + date = applyTimeToDate(date, 9, 0); + } else { + date = add(now, { hours: 1 }); + } + return date; + } + + const dayTodMatch = text.match( + new RegExp(`^(${RELATIVE_DAYS})\\s+(${TIME_OF_DAY_NAMES})$`) + ); + if (dayTodMatch) { + const offset = RELATIVE_DAY_MAP[dayTodMatch[1]]; + const tod = TIME_OF_DAY_MAP[dayTodMatch[2]]; + let base = add(startOfDay(now), { days: offset }); + let date = applyTimeToDate(base, tod.hours, tod.minutes); + if (!isAfter(date, now)) { + base = add(base, { days: 1 }); + date = applyTimeToDate(base, tod.hours, tod.minutes); + } + return date; + } + + const dayAtTimeMatch = text.match( + new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$` + ) + ); + if (dayAtTimeMatch) { + const offset = RELATIVE_DAY_MAP[dayAtTimeMatch[1]]; + const time = parseTimeString(dayAtTimeMatch[2]); + if (!time) return null; + let base = add(startOfDay(now), { days: offset }); + let date = applyTimeToDate(base, time.hours, time.minutes); + if (!isAfter(date, now)) { + base = add(base, { days: 1 }); + date = applyTimeToDate(base, time.hours, time.minutes); + } + return date; + } + + const sameTimeMatch = text.match( + new RegExp(`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`) + ); + if (sameTimeMatch) { + const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]]; + if (offset <= 0) return null; + const base = add(startOfDay(now), { days: offset }); + return applyTimeToDate(base, now.getHours(), now.getMinutes()); + } + + return null; +}; + +const matchNextPattern = (text, now) => { + const nextUnitMatch = text.match(/^next\s+(hour|minute|week|month|year)$/); + if (nextUnitMatch) { + const unit = nextUnitMatch[1]; + if (unit === 'hour') return add(now, { hours: 1 }); + if (unit === 'minute') return add(now, { minutes: 1 }); + if (unit === 'week') { + const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }); + return applyTimeToDate(base, 9, 0); + } + const base = add(startOfDay(now), { [`${unit}s`]: 1 }); + return applyTimeToDate(base, 9, 0); + } + + const weekdayOfNextMatch = text.match( + new RegExp( + `^(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week${TIME_SUFFIX_RE}$` + ) + ); + if (weekdayOfNextMatch) { + const dayIndex = WEEKDAY_MAP[weekdayOfNextMatch[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + let date = fn(now); + const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 }); + const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 }); + if (nowWeekStart.getTime() === dateWeekStart.getTime()) { + date = fn(date); + } + return applyTimeOrDefault(date, weekdayOfNextMatch[2]); + } + + const nextWeekDayMatch = text.match( + new RegExp(`^next\\s+week\\s+(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`) + ); + if (nextWeekDayMatch) { + const dayIndex = WEEKDAY_MAP[nextWeekDayMatch[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + let date = fn(now); + const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 }); + const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 }); + if (nowWeekStart.getTime() === dateWeekStart.getTime()) { + date = fn(date); + } + return applyTimeOrDefault(date, nextWeekDayMatch[2]); + } + + const nextDayMatch = text.match( + new RegExp(`^next\\s+(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`) + ); + if (nextDayMatch) { + const dayIndex = WEEKDAY_MAP[nextDayMatch[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + + let date = fn(now); + const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 }); + const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 }); + if (nowWeekStart.getTime() === dateWeekStart.getTime()) { + date = fn(date); + } + + return applyTimeOrDefault(date, nextDayMatch[2]); + } + + return null; +}; + +const matchWeekday = (text, now) => { + const sameTimeWeekday = text.match( + new RegExp(`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`) + ); + if (sameTimeWeekday) { + const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + const target = fn(now); + return applyTimeToDate(target, now.getHours(), now.getMinutes()); + } + + const match = text.match( + new RegExp( + `^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$` + ) + ); + if (!match) return null; + + const dayIndex = WEEKDAY_MAP[match[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + + if (getDay(now) === dayIndex) { + const todayDate = applyTimeOrDefault(now, match[2]); + if (todayDate && isAfter(todayDate, now)) return todayDate; + } + + return applyTimeOrDefault(fn(now), match[2]); +}; + +const matchTimeOnly = (text, now) => { + let match = text.match( + /^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/ + ); + if (!match) match = text.match(/^(?:at\s+)?(\d{1,2}:\d{2})$/); + if (!match) return null; + + const time = parseTimeString(match[1]); + if (!time) return null; + + let date = applyTimeToDate(now, time.hours, time.minutes); + if (!isAfter(date, now)) date = add(date, { days: 1 }); + return date; +}; + +const isTimeConsistentWithTOD = (todLabel, hours) => { + const range = TOD_HOUR_RANGE[todLabel]; + if (!range) return true; + const h = hours === 0 ? 24 : hours; + return h >= range[0] && h < range[1]; +}; + +const matchTimeOfDay = (text, now) => { + const todWithTime = text.match( + new RegExp( + `^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?))$` + ) + ); + if (todWithTime) { + const todLabel = todWithTime[1]; + const time = parseTimeString(todWithTime[2]); + if (!time) return null; + if (!isTimeConsistentWithTOD(todLabel, time.hours)) return null; + let date = applyTimeToDate(now, time.hours, time.minutes); + if (!isAfter(date, now)) date = add(date, { days: 1 }); + return date; + } + + const match = text.match( + new RegExp( + `^(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$` + ) + ); + if (!match) return null; + + const key = text + .replace(/^(?:later|in)\s+/, '') + .replace(/^(?:this|the)\s+/, '') + .trim(); + return resolveTimeOfDay(key, now); +}; + +const matchAbsoluteDate = (text, now) => { + const match = text.match( + new RegExp( + `^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$` + ) + ); + if (!match) return null; + + const month = MONTH_MAP[match[1]]; + const day = parseInt(match[2], 10); + let year = now.getFullYear(); + if (match[3] && /next\s+year/i.test(match[3])) { + year = now.getFullYear() + 1; + } else if (match[3]) { + year = parseInt(match[3], 10); + } + + if (match[3]) { + const base = strictDate(year, month, day); + if (!base) return null; + const date = applyTimeOrDefault(base, match[4]); + if (!date || !isAfter(date, now)) return null; + return date; + } + return futureOrNextYear(year, month, day, match[4], now); +}; + +const matchAbsoluteDateReversed = (text, now) => { + const match = text.match( + new RegExp( + `^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$` + ) + ); + if (!match) return null; + + const day = parseInt(match[1], 10); + const month = MONTH_MAP[match[2]]; + let year = now.getFullYear(); + if (match[3] && /next\s+year/i.test(match[3])) { + year = now.getFullYear() + 1; + } else if (match[3]) { + year = parseInt(match[3], 10); + } + + if (match[3]) { + const base = strictDate(year, month, day); + if (!base) return null; + const date = applyTimeOrDefault(base, match[4]); + if (!date || !isAfter(date, now)) return null; + return date; + } + return futureOrNextYear(year, month, day, match[4], now); +}; + +const matchMonthYear = (text, now) => { + const match = text.match(new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`)); + if (!match) return null; + + const month = MONTH_MAP[match[1]]; + const year = parseInt(match[2], 10); + const date = new Date(year, month, 1); + if (!isValid(date)) return null; + if (!isAfter(applyTimeToDate(date, 9, 0), now)) return null; + return applyTimeToDate(date, 9, 0); +}; + +const buildDateWithOptionalTime = (year, month, day, timeStr) => { + const date = strictDate(year, month, day); + if (!date) return null; + return applyTimeOrDefault(date, timeStr); +}; + +const TIME_SUFFIX = + /(?:\s+(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?)?))?$/; + +const ISO_DATE_RE = new RegExp( + `^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX.source}` +); +const SLASH_DATE_RE = new RegExp( + `^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX.source}` +); +const DASH_DATE_RE = new RegExp( + `^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX.source}` +); + +const disambiguateDayMonth = (a, b) => { + if (a > 12) return { day: a, month: b - 1 }; + if (b > 12) return { month: a - 1, day: b }; + return { month: a - 1, day: b }; +}; + +const matchFormalDate = (text, now) => { + const ensureFuture = date => { + if (!date || !isAfter(date, now)) return null; + return date; + }; + + let match = text.match(ISO_DATE_RE); + if (match) { + return ensureFuture( + buildDateWithOptionalTime( + parseInt(match[1], 10), + parseInt(match[2], 10) - 1, + parseInt(match[3], 10), + match[4] + ) + ); + } + + const parseAmbiguous = m => { + const { month, day } = disambiguateDayMonth( + parseInt(m[1], 10), + parseInt(m[2], 10) + ); + return ensureFuture( + buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4]) + ); + }; + + match = text.match(SLASH_DATE_RE); + if (match) return parseAmbiguous(match); + + match = text.match(DASH_DATE_RE); + if (match) return parseAmbiguous(match); + + return null; +}; + +const matchDayAfterTomorrow = (text, now) => { + const match = text.match( + new RegExp(`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`) + ); + if (!match) return null; + return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), match[1]); +}; + +const matchThisWeekend = (text, now) => { + if (text !== 'this weekend' && text !== 'weekend') return null; + + if (isSaturday(now)) { + if (now.getHours() < 10) return applyTimeToDate(now, 10, 0); + if (now.getHours() < 18) return add(now, { hours: 2 }); + return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0); + } + + if (isSunday(now)) { + if (now.getHours() < 10) return applyTimeToDate(now, 10, 0); + return add(now, { hours: 2 }); + } + + return applyTimeToDate(nextSaturday(now), 10, 0); +}; + +const matchNextWeekend = (text, now) => { + if (text !== 'next weekend') return null; + const sat = nextSaturday(now); + const date = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 }); + return applyTimeToDate(date, 10, 0); +}; + +// ─── Main Parser ───────────────────────────────────────────────────────────── + +const MATCHERS = [ + matchRelativeDuration, + matchDurationFromNow, + matchDayAfterTomorrow, + matchRelativeDay, + matchNextPattern, + matchThisWeekend, + matchNextWeekend, + matchTimeOfDay, + matchWeekday, + matchTimeOnly, + matchAbsoluteDate, + matchAbsoluteDateReversed, + matchMonthYear, + matchFormalDate, +]; + +/** + * Parse a natural language English date/time string into a Date object. + * + * Supported patterns: + * - Relative durations: "in 30 minutes", "in 2 hours", "in half an hour" + * - Duration from now: "5 minutes from now", "a week from now" + * - Relative days: "today", "tomorrow", "tomorrow morning", "tomorrow at 3pm" + * - Day after tomorrow: "day after tomorrow", "day after tomorrow at 9am" + * - Next patterns: "next monday", "next week", "next friday at 2pm" + * - Weekdays: "monday", "friday at 3pm", "this wednesday" + * - Time of day: "morning", "this afternoon", "later this evening", "eod" + * - Time only: "at 3pm", "9:30am", "at 14:00" + * - Weekend: "this weekend", "next weekend" + * - Absolute dates: "jan 15", "march 5 2025", "dec 25 at 9am" + * - Reversed dates: "15 jan", "5th march 2025" + * - Formal dates: "01/15/2025", "2025-01-15" + * - Month + year: "jan 2028", "december 2025" + * - Same time: "tomorrow same time", "same time friday" + * - Time-of-day + time: "morning 6am", "evening 7pm" + * - Noise stripping: "remind me tomorrow", "snooze for 3 days", "approx 2 hours" + * + * Future-only: All matchers return dates strictly after referenceDate. + * If a relative expression like "today at 3pm" is already past, it rolls + * forward to the next valid occurrence (tomorrow at 3pm). This is intentional + * for snooze/reminder use cases where past times are meaningless. + * Zero durations ("0 days", "0 minutes") are also rejected. + * Invalid time expressions ("at 99", "at 25", "13pm") return null. + * + * Timezone: All operations use the runtime's local timezone via native Date. + * "tomorrow at 9am" means 9am in the user's browser timezone. The returned + * unix timestamp (via getUnixTime) is UTC-correct regardless of timezone. + * + * @param {string} text - Natural language date/time string + * @param {Date} [referenceDate=new Date()] - Reference date for relative calculations + * @returns {{ date: Date, unix: number } | null} Parsed date or null if unrecognized + */ +export const parseDateFromText = (text, referenceDate = new Date()) => { + if (!text || typeof text !== 'string') return null; + + const normalized = normalize(text); + if (!normalized) return null; + + const maxDate = add(referenceDate, { years: 999 }); + + let parsed = null; + MATCHERS.some(matcher => { + const result = matcher(normalized, referenceDate); + if ( + result && + isValid(result) && + isAfter(result, referenceDate) && + !isBefore(maxDate, result) + ) { + parsed = { date: result, unix: getUnixTime(result) }; + return true; + } + return false; + }); + + return parsed; +}; + +// ─── Suggestion Candidates (uses maps already defined above) ───────────────── + +const CANONICAL_UNITS = [...new Set(Object.values(UNIT_MAP))]; + +const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3); + +const PHRASE_CANDIDATES = [ + ...Object.keys(RELATIVE_DAY_MAP), + ...Object.keys(WEEKDAY_MAP).filter(k => k.length > 3), + ...Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' ')), + ...MONTH_NAMES_LONG.map(m => `${m} 1`), + 'next monday', + 'next tuesday', + 'next wednesday', + 'next thursday', + 'next friday', + 'next saturday', + 'next sunday', + 'next week', + 'next month', + 'this friday', + 'this saturday', + 'this sunday', + 'this weekend', + 'next weekend', + 'day after tomorrow', +]; + +const matchesPrefix = (candidate, text) => { + if (candidate === text) return false; + if (candidate.startsWith(text)) return true; + const words = candidate.split(' '); + return words.length > 1 && words.some(w => w.startsWith(text) && w !== text); +}; + +const buildSuggestionCandidates = text => { + if (/^\d/.test(text)) { + const num = text.match(/^\d+/)[0]; + return CANONICAL_UNITS.map(u => `${num} ${u}`); + } + return PHRASE_CANDIDATES.filter(c => matchesPrefix(c, text)); +}; + +const MAX_SUGGESTIONS = 5; + +/** + * Generate multiple snooze date suggestions for a search prefix. + * + * For numeric input (e.g. "100", "2 h"), generates duration candidates + * by appending units (minutes, hours, days, weeks, months). + * For text input (e.g. "t", "next"), matches known phrases from the + * parser's own maps (weekdays, relative days, time-of-day, next patterns). + * + * Each candidate is validated through parseDateFromText. Results are + * deduplicated by unix timestamp and capped at 5. + * + * @param {string} text - Search prefix + * @param {Date} [referenceDate=new Date()] - Reference date + * @returns {Array<{ label: string, date: Date, unix: number }>} + */ +export const generateDateSuggestions = (text, referenceDate = new Date()) => { + if (!text || typeof text !== 'string') return []; + const normalized = text.trim().toLowerCase(); + if (!normalized) return []; + + const seen = new Set(); + const results = []; + + const exact = parseDateFromText(normalized, referenceDate); + if (exact) { + seen.add(exact.unix); + results.push({ label: normalized, ...exact }); + } + + buildSuggestionCandidates(normalized).some(candidate => { + if (results.length >= MAX_SUGGESTIONS) return true; + + const result = parseDateFromText(candidate, referenceDate); + if (result && !seen.has(result.unix)) { + seen.add(result.unix); + results.push({ label: candidate, ...result }); + } + return false; + }); + + return results; +}; diff --git a/app/javascript/dashboard/helper/snoozeHelpers.js b/app/javascript/dashboard/helper/snoozeHelpers.js index efe526562..b09642844 100644 --- a/app/javascript/dashboard/helper/snoozeHelpers.js +++ b/app/javascript/dashboard/helper/snoozeHelpers.js @@ -7,11 +7,13 @@ import { startOfMonth, isMonday, isToday, + isSameYear, setHours, setMinutes, setSeconds, } from 'date-fns'; import wootConstants from 'dashboard/constants/globals'; +import { generateDateSuggestions } from 'dashboard/helper/snoozeDateParser'; const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS; @@ -64,12 +66,36 @@ export const snoozedReopenTime = snoozedUntil => { if (isToday(date)) { return format(date, 'h.mmaaa'); } + if (!isSameYear(date, new Date())) { + return format(date, 'd MMM yyyy, h.mmaaa'); + } return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null; }; export const snoozedReopenTimeToTimestamp = snoozedUntil => { return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null; }; +const formatSnoozeDate = (snoozeDate, currentDate) => { + return isSameYear(snoozeDate, currentDate) + ? format(snoozeDate, 'EEE, d MMM, h:mm a') + : format(snoozeDate, 'EEE, d MMM yyyy, h:mm a'); +}; + +const capitalizeLabel = text => text.replace(/^\w/, c => c.toUpperCase()); + +export const generateSnoozeSuggestions = ( + searchText, + currentDate = new Date() +) => { + const suggestions = generateDateSuggestions(searchText, currentDate); + return suggestions.map(s => ({ + date: s.date, + unixTime: s.unix, + label: capitalizeLabel(s.label), + formattedDate: formatSnoozeDate(s.date, currentDate), + })); +}; + export const shortenSnoozeTime = snoozedUntil => { if (!snoozedUntil) { return null; diff --git a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js new file mode 100644 index 000000000..4114f1ac7 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -0,0 +1,732 @@ +import { parseDateFromText } from '../snoozeDateParser'; + +const now = new Date('2023-06-16T10:00:00'); + +const examples = [ + 'mar 20 next year', + 'snooze for a day', + 'snooze till jan 2028', + '3 weeks', + '5 d', + 'two months', + 'half day', + 'a week', + 'tomorrow', + 'tomorrow at 3pm', + 'tonight', + 'next friday', + 'next week', + 'next month', + 'friday', + 'this friday at 13:00', + 'march 5th', + 'jan 20', + 'march 5 at 2pm', + 'in 10 days', + 'snooze for 2 hours', + 'for 3 weeks', + 'day after tomorrow', + 'this weekend', + 'next weekend', + 'morning', + 'eod', + 'at 3pm', + '9:30am', + '15 jan', + '2025-01-15', + '01/15/2025', + 'tomorrow morning', + 'this afternoon', + 'in half an hour', + '5 minutes from now', + // New natural language patterns + 'Tonight at 8 PM', + 'Tomorrow same time', + 'Upcoming Friday', + 'Monday of next week', + 'Approx 2 hours from now', + 'next hour', + 'add a deadline on march 30th', + 'remind me tomorrow at 9am', + 'please snooze for 3 days', + 'coming wednesday', + 'about 30 minutes from now', + 'schedule on jan 15', + 'postpone till next week', + 'tomorrow this time', + 'midnight', + 'monday next week', + 'next week monday', + 'same time friday', + 'this time wednesday', + 'morning 6am', + 'evening 7pm', + 'afternoon at 2pm', +]; + +describe('snooze examples', () => { + examples.forEach(input => { + it(`"${input}" parses to a future date`, () => { + const result = parseDateFromText(input, now); + expect(result).not.toBeNull(); + expect(result.date).toBeInstanceOf(Date); + expect(result.date > now).toBe(true); + expect(typeof result.unix).toBe('number'); + }); + }); +}); + +const invalidDates = [ + 'feb 30', + 'feb 31', + 'apr 31', + 'jun 31', + 'feb 30 2025', + '30 feb', + '31st feb 2025', + // Past formal dates should also return null + '2020-01-15', + '01/15/2020', + '15-01-2020', +]; + +describe('today at past time should roll forward', () => { + it('"today at 9am" (already past 10am) should roll to tomorrow', () => { + const result = parseDateFromText('today at 9am', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + expect(result.date.getHours()).toEqual(9); + }); + + it('"today at 3pm" (still future) should stay today', () => { + const result = parseDateFromText('today at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(16); + expect(result.date.getHours()).toEqual(15); + }); +}); + +describe('invalid dates should return null', () => { + invalidDates.forEach(input => { + it(`"${input}" → null`, () => { + const result = parseDateFromText(input, now); + expect(result).toBeNull(); + }); + }); +}); + +// ─── Regression Test Matrix ─────────────────────────────────────────────────── + +describe('regression: leap day / end-of-month', () => { + const jan30 = new Date('2024-01-30T10:00:00'); + + it('feb 29 on leap year (2024) should resolve', () => { + const result = parseDateFromText('feb 29', jan30); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(29); + }); + + it('feb 29 on non-leap year should return null', () => { + const jan2025 = new Date('2025-01-30T10:00:00'); + const result = parseDateFromText('feb 29 2025', jan2025); + expect(result).toBeNull(); + }); + + it('feb 29 2028 explicit leap year should resolve', () => { + const result = parseDateFromText('feb 29 2028', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2028); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(29); + }); + + it('feb 29 without year in non-leap year scans to next leap year', () => { + const mar2025 = new Date('2025-03-01T10:00:00'); + const result = parseDateFromText('feb 29', mar2025); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2028); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(29); + }); +}); + +describe('regression: "next year" suffix', () => { + it('"feb 20 next year" resolves to next year', () => { + const result = parseDateFromText('feb 20 next year', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2024); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(20); + }); + + it('"20 feb next year" (reversed) resolves to next year', () => { + const result = parseDateFromText('20 feb next year', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2024); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(20); + }); + + it('"dec 25 next year at 3pm" resolves with time', () => { + const result = parseDateFromText('dec 25 next year at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2024); + expect(result.date.getHours()).toEqual(15); + }); +}); + +describe('regression: weekend semantics', () => { + it('"this weekend" on Saturday morning should be today', () => { + const satMorning = new Date('2023-06-17T07:00:00'); + const result = parseDateFromText('this weekend', satMorning); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + expect(result.date.getHours()).toEqual(10); + }); + + it('"this weekend" on Sunday should be today', () => { + const sunMorning = new Date('2023-06-18T07:00:00'); + const result = parseDateFromText('this weekend', sunMorning); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(18); + expect(result.date.getHours()).toEqual(10); + }); + + it('"next weekend" on Saturday should skip to next Saturday', () => { + const satMorning = new Date('2023-06-17T07:00:00'); + const result = parseDateFromText('next weekend', satMorning); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(24); + }); + + it('"this weekend" on a weekday should be next Saturday', () => { + const result = parseDateFromText('this weekend', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + }); +}); + +describe('regression: ambiguous numeric dates', () => { + it('"01/05/2025" treats first number as month (US format)', () => { + const result = parseDateFromText('01/05/2025', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(0); + expect(result.date.getDate()).toEqual(5); + }); + + it('"13/05/2025" disambiguates — 13 must be day', () => { + const result = parseDateFromText('13/05/2025', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(4); + expect(result.date.getDate()).toEqual(13); + }); +}); + +describe('regression: same-time edge cases', () => { + it('"today same time" should return null (not future)', () => { + const result = parseDateFromText('today same time', now); + expect(result).toBeNull(); + }); + + it('"tomorrow same time" preserves hour and minute', () => { + const at1430 = new Date('2023-06-16T14:30:00'); + const result = parseDateFromText('tomorrow same time', at1430); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + expect(result.date.getHours()).toEqual(14); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"tomorrow same time" with seconds does not produce past', () => { + const at1030WithSecs = new Date('2023-06-16T10:00:45.500'); + const result = parseDateFromText('tomorrow same time', at1030WithSecs); + expect(result).not.toBeNull(); + expect(result.date > at1030WithSecs).toBe(true); + }); +}); + +describe('regression: future-only rollover', () => { + it('"today morning" at 11am rolls to tomorrow morning', () => { + const at11am = new Date('2023-06-16T11:00:00'); + const result = parseDateFromText('today morning', at11am); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + expect(result.date.getHours()).toEqual(9); + }); + + it('"today afternoon" at 10am stays today', () => { + const result = parseDateFromText('today afternoon', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(16); + expect(result.date.getHours()).toEqual(14); + }); + + it('"at 9am" when it is 10am rolls to tomorrow', () => { + const result = parseDateFromText('at 9am', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + }); + + it('past formal date "2020-06-01" returns null', () => { + const result = parseDateFromText('2020-06-01', now); + expect(result).toBeNull(); + }); + + it('past month-day "jan 1" rolls to next year', () => { + const result = parseDateFromText('jan 1', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2024); + }); + + it('past month-name with explicit year "jan 5 2024" returns null', () => { + const feb2025 = new Date('2025-02-01T10:00:00'); + const result = parseDateFromText('jan 5 2024', feb2025); + expect(result).toBeNull(); + }); + + it('past reversed date with explicit year "5 jan 2024" returns null', () => { + const feb2025 = new Date('2025-02-01T10:00:00'); + const result = parseDateFromText('5 jan 2024', feb2025); + expect(result).toBeNull(); + }); + + it('future month-name with explicit year "dec 25 2025" resolves', () => { + const result = parseDateFromText('dec 25 2025', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2025); + expect(result.date.getMonth()).toEqual(11); + expect(result.date.getDate()).toEqual(25); + }); +}); + +describe('regression: noise-stripped bare durations', () => { + it('"approx 2 hours" resolves (noise stripped to "2 hours")', () => { + const result = parseDateFromText('approx 2 hours', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"about 3 days" resolves', () => { + const result = parseDateFromText('about 3 days', now); + expect(result).not.toBeNull(); + }); + + it('"roughly 30 minutes" resolves', () => { + const result = parseDateFromText('roughly 30 minutes', now); + expect(result).not.toBeNull(); + }); + + it('"~ 1 hour" resolves', () => { + const result = parseDateFromText('~ 1 hour', now); + expect(result).not.toBeNull(); + }); +}); + +describe('regression: invalid meridiem inputs', () => { + it('"0am" should return null', () => { + const result = parseDateFromText('tomorrow at 0am', now); + expect(result).toBeNull(); + }); + + it('"13pm" should return null', () => { + const result = parseDateFromText('tomorrow at 13pm', now); + expect(result).toBeNull(); + }); + + it('"0pm" should return null', () => { + const result = parseDateFromText('tomorrow at 0pm', now); + expect(result).toBeNull(); + }); + + it('"12am" is valid (midnight)', () => { + const result = parseDateFromText('tomorrow at 12am', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(0); + }); + + it('"12pm" is valid (noon)', () => { + const result = parseDateFromText('tomorrow at 12pm', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(12); + }); +}); + +describe('regression: strict future (> not >=)', () => { + it('"today at 10:00am" when now is exactly 10:00:00 rolls to tomorrow', () => { + const exact10 = new Date('2023-06-16T10:00:00.000'); + const result = parseDateFromText('today at 10am', exact10); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + }); + + it('"eod" at exactly 5pm rolls to tomorrow', () => { + const exact5pm = new Date('2023-06-16T17:00:00.000'); + const result = parseDateFromText('eod', exact5pm); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + }); +}); + +describe('regression: DST / month-end rollovers', () => { + it('"in 1 day" always advances by ~24h regardless of DST', () => { + const ref = new Date('2025-03-09T01:00:00'); + const result = parseDateFromText('in 1 day', ref); + expect(result).not.toBeNull(); + const diffMs = result.date.getTime() - ref.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + // date-fns add({ days: 1 }) adds a calendar day; exact hours vary by TZ + expect(diffHours).toBeGreaterThanOrEqual(22); + expect(diffHours).toBeLessThanOrEqual(48); + }); + + it('"tomorrow" at end of month (Jan 31 → Feb 1)', () => { + const jan31 = new Date('2025-01-31T10:00:00'); + const result = parseDateFromText('tomorrow', jan31); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(1); + }); + + it('"in 1 month" from Jan 31 clamps to Feb 28 (date-fns behavior)', () => { + const jan31 = new Date('2025-01-31T10:00:00'); + const result = parseDateFromText('in 1 month', jan31); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(1); + expect(result.date.getDate()).toEqual(28); + expect(result.date > jan31).toBe(true); + }); + + it('"next friday" across year boundary (Dec 29 → Jan 2026)', () => { + const dec29 = new Date('2025-12-29T10:00:00'); + const result = parseDateFromText('next friday', dec29); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2026); + expect(result.date.getMonth()).toEqual(0); + }); +}); + +describe('regression: max 999 years cap', () => { + it('"jan 1 9999" should return null (>999 years from now)', () => { + const result = parseDateFromText('jan 1 9999', now); + expect(result).toBeNull(); + }); + + it('"dec 25 2999" should resolve (within 999 years)', () => { + const result = parseDateFromText('dec 25 2999', now); + expect(result).not.toBeNull(); + }); + + it('"9999-01-01" should return null', () => { + const result = parseDateFromText('9999-01-01', now); + expect(result).toBeNull(); + }); +}); + +describe('regression: invalid time in applyTimeOrDefault', () => { + it('"next monday at 99" should return null (invalid hour)', () => { + const result = parseDateFromText('next monday at 99', now); + expect(result).toBeNull(); + }); + + it('"jan 5 at 25" should return null (invalid hour)', () => { + const result = parseDateFromText('jan 5 at 25', now); + expect(result).toBeNull(); + }); + + it('"friday at 10:99" should return null (invalid minutes)', () => { + const result = parseDateFromText('friday at 10:99', now); + expect(result).toBeNull(); + }); + + it('"tomorrow at 3pm" is still valid', () => { + const result = parseDateFromText('tomorrow at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(15); + }); +}); + +describe('regression: zero durations rejected', () => { + it('"0 minutes" should return null', () => { + const result = parseDateFromText('0 minutes', now); + expect(result).toBeNull(); + }); + + it('"0 days" should return null', () => { + const result = parseDateFromText('0 days', now); + expect(result).toBeNull(); + }); + + it('"in 0 hours" should return null', () => { + const result = parseDateFromText('in 0 hours', now); + expect(result).toBeNull(); + }); + + it('"0 days from now" should return null', () => { + const result = parseDateFromText('0 days from now', now); + expect(result).toBeNull(); + }); + + it('"1 minute" is still valid', () => { + const result = parseDateFromText('1 minute', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); +}); + +describe('regression: today date with default 9am past now', () => { + it('"jun 16" at 10am defaults to 9am which is past → rolls to next year', () => { + // now = 2023-06-16T10:00:00 (Friday) + // "jun 16" defaults to 9am today → past → futureOrNextYear bumps to 2024 + const result = parseDateFromText('jun 16', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2024); + expect(result.date.getMonth()).toEqual(5); + expect(result.date.getDate()).toEqual(16); + expect(result.date.getHours()).toEqual(9); + }); + + it('"jun 16 at 3pm" at 10am stays today (3pm is future)', () => { + const result = parseDateFromText('jun 16 at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2023); + expect(result.date.getDate()).toEqual(16); + expect(result.date.getHours()).toEqual(15); + }); +}); + +describe('regression: 24h time support', () => { + it('"today at 14:30" resolves to 2:30pm today', () => { + const result = parseDateFromText('today at 14:30', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(14); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"tomorrow at 14:00" resolves', () => { + const result = parseDateFromText('tomorrow at 14:00', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(17); + expect(result.date.getHours()).toEqual(14); + }); + + it('"jan 15 at 14:00" resolves with 24h time', () => { + const result = parseDateFromText('jan 15 at 14:00', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(14); + expect(result.date.getMinutes()).toEqual(0); + }); + + it('"next monday 18:00" resolves', () => { + const result = parseDateFromText('next monday 18:00', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(18); + }); + + it('"friday 16:30" resolves', () => { + const result = parseDateFromText('friday 16:30', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(16); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"day after tomorrow 13:00" resolves', () => { + const result = parseDateFromText('day after tomorrow 13:00', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(18); + expect(result.date.getHours()).toEqual(13); + }); +}); + +// ─── parseDateFromText direct tests ────────────────────────────────────────── + +describe('parseDateFromText: relative durations', () => { + it('"in 2 hours" adds 2 hours', () => { + const result = parseDateFromText('in 2 hours', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(12); + }); + + it('"half hour" adds 30 minutes', () => { + const result = parseDateFromText('half hour', now); + expect(result).not.toBeNull(); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"3 days from now" adds 3 days', () => { + const result = parseDateFromText('3 days from now', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(19); + }); + + it('"a week" adds 7 days', () => { + const result = parseDateFromText('a week', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(23); + }); + + it('"two months" adds 2 months', () => { + const result = parseDateFromText('two months', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(7); + }); +}); + +describe('parseDateFromText: next patterns', () => { + it('"next week" returns next Monday 9am', () => { + const result = parseDateFromText('next week', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toEqual(1); + expect(result.date.getHours()).toEqual(9); + }); + + it('"next month" returns same day next month at 9am', () => { + // add(startOfDay(Jun 16), { months: 1 }) → Jul 16 + const result = parseDateFromText('next month', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(6); + expect(result.date.getDate()).toEqual(16); + expect(result.date.getHours()).toEqual(9); + }); + + it('"next hour" adds 1 hour', () => { + const result = parseDateFromText('next hour', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(11); + }); +}); + +describe('parseDateFromText: weekday patterns', () => { + it('"friday" returns this friday with default time', () => { + const result = parseDateFromText('friday', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toEqual(5); + }); + + it('"this wednesday at 2pm" returns wednesday 2pm', () => { + const result = parseDateFromText('this wednesday at 2pm', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toEqual(3); + expect(result.date.getHours()).toEqual(14); + }); + + it('"upcoming thursday" returns next thursday', () => { + const result = parseDateFromText('upcoming thursday', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toEqual(4); + }); +}); + +describe('parseDateFromText: formal date formats', () => { + it('"2025-01-15" parses YYYY-MM-DD', () => { + const result = parseDateFromText('2025-01-15', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2025); + expect(result.date.getMonth()).toEqual(0); + expect(result.date.getDate()).toEqual(15); + }); + + it('"01/15/2025" parses MM/DD/YYYY', () => { + const result = parseDateFromText('01/15/2025', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(0); + expect(result.date.getDate()).toEqual(15); + }); + + it('"15-01-2025" parses DD-MM-YYYY', () => { + const result = parseDateFromText('15-01-2025', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(15); + expect(result.date.getMonth()).toEqual(0); + }); +}); + +describe('parseDateFromText: returns null for garbage', () => { + it('empty string returns null', () => { + expect(parseDateFromText('', now)).toBeNull(); + }); + + it('random text returns null', () => { + expect(parseDateFromText('hello world', now)).toBeNull(); + }); + + it('null input returns null', () => { + expect(parseDateFromText(null, now)).toBeNull(); + }); + + it('number input returns null', () => { + expect(parseDateFromText(123, now)).toBeNull(); + }); +}); + +describe('regression: mid-text punctuation is stripped', () => { + it('"today, at 3pm" resolves (comma stripped)', () => { + const result = parseDateFromText('today, at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(15); + }); + + it('"tomorrow; 9am" resolves (semicolon stripped)', () => { + const result = parseDateFromText('tomorrow; 9am', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(9); + }); + + it('"jan 15, 2025" resolves (comma after day)', () => { + const result = parseDateFromText('jan 15, 2025', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(15); + }); + + it('"next friday!" resolves (trailing punctuation)', () => { + const result = parseDateFromText('next friday!', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toEqual(5); + }); + + it('"tomorrow at 3p.m." still works (periods preserved for a.m./p.m.)', () => { + const result = parseDateFromText('tomorrow at 3p.m.', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(15); + }); +}); + +describe('regression: contradictory time-of-day + time rejected', () => { + it('"morning 7pm" returns null', () => { + const result = parseDateFromText('morning 7pm', now); + expect(result).toBeNull(); + }); + + it('"evening 6am" returns null', () => { + const result = parseDateFromText('evening 6am', now); + expect(result).toBeNull(); + }); + + it('"night 8am" returns null', () => { + const result = parseDateFromText('night 8am', now); + expect(result).toBeNull(); + }); + + it('"afternoon 7am" returns null', () => { + const result = parseDateFromText('afternoon 7am', now); + expect(result).toBeNull(); + }); + + it('"morning 6am" is valid (consistent)', () => { + const result = parseDateFromText('morning 6am', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(6); + }); + + it('"evening 7pm" is valid (consistent)', () => { + const result = parseDateFromText('evening 7pm', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(19); + }); + + it('"afternoon at 2pm" is valid (consistent)', () => { + const result = parseDateFromText('afternoon at 2pm', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(14); + }); +}); diff --git a/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js index 0fb87eb41..6ab385aa5 100644 --- a/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js @@ -95,9 +95,16 @@ describe('#Snooze Helpers', () => { expect(snoozedReopenTime(null)).toEqual(null); }); - it('should return formatted date if snoozedUntil is not nil', () => { + it('should return formatted date with year if snoozedUntil is not in current year', () => { expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual( - '7 Jun, 9.00am' + '7 Jun 2023, 2.30pm' + ); + }); + + it('should return formatted date without year if snoozedUntil is in current year', () => { + const currentYear = new Date().getFullYear(); + expect(snoozedReopenTime(`${currentYear}-06-07T09:00:00.000Z`)).toEqual( + '7 Jun, 2.30pm' ); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index d924bffbd..fab8020e2 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -182,6 +182,7 @@ }, "COMMAND_BAR": { "SEARCH_PLACEHOLDER": "Search or jump to", + "SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...", "SECTIONS": { "GENERAL": "General", "REPORTS": "Reports", diff --git a/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue b/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue index c38586ad4..3cb812a1e 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue @@ -31,6 +31,8 @@ const toggleStatus = async (status, snoozedUntil) => { const onCmdSnoozeConversation = snoozeType => { if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { showCustomSnoozeModal.value = true; + } else if (typeof snoozeType === 'number') { + toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType); } else { toggleStatus( wootConstants.STATUS_TYPE.SNOOZED, diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue index afd15283e..01c1c8d1d 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue @@ -11,6 +11,14 @@ import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkAct import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys'; import wootConstants from 'dashboard/constants/globals'; import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; +import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers'; +import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons'; +import { + CMD_SNOOZE_CONVERSATION, + CMD_SNOOZE_NOTIFICATION, + CMD_BULK_ACTION_SNOOZE_CONVERSATION, +} from 'dashboard/helper/commandbar/events'; +import { emitter } from 'shared/helpers/mitt'; const store = useStore(); const { t } = useI18n(); @@ -28,7 +36,21 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys(); const { bulkActionsHotKeys } = useBulkActionsHotKeys(); const { conversationHotKeys } = useConversationHotKeys(); -const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER')); +const SNOOZE_PARENT_IDS = [ + 'snooze_conversation', + 'snooze_notification', + 'bulk_action_snooze_conversation', +]; +const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_'; + +const dynamicSnoozeActions = ref([]); +const currentCommandRoot = ref(null); + +const placeholder = computed(() => + SNOOZE_PARENT_IDS.includes(currentCommandRoot.value) + ? t('COMMAND_BAR.SNOOZE_PLACEHOLDER') + : t('COMMAND_BAR.SEARCH_PLACEHOLDER') +); const hotKeys = computed(() => [ ...inboxHotKeys.value, @@ -36,15 +58,73 @@ const hotKeys = computed(() => [ ...goToAppearanceHotKeys.value, ...bulkActionsHotKeys.value, ...conversationHotKeys.value, + ...dynamicSnoozeActions.value, ]); const setCommandBarData = () => { ninjakeys.value.data = hotKeys.value; }; +const SNOOZE_EVENT_MAP = { + snooze_conversation: CMD_SNOOZE_CONVERSATION, + snooze_notification: CMD_SNOOZE_NOTIFICATION, + bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION, +}; + +const SNOOZE_SECTION_MAP = { + snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION', + snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', + bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS', +}; + +const buildDynamicSnoozeActions = (search, parentId) => { + const suggestions = generateSnoozeSuggestions(search); + if (!suggestions.length) return []; + + const busEvent = SNOOZE_EVENT_MAP[parentId]; + const section = t(SNOOZE_SECTION_MAP[parentId]); + + return suggestions.map((parsed, index) => ({ + id: `${DYNAMIC_SNOOZE_PREFIX}${index}`, + title: + parsed.label !== parsed.formattedDate + ? `${parsed.label} — ${parsed.formattedDate}` + : parsed.formattedDate, + parent: parentId, + section, + icon: ICON_SNOOZE_CONVERSATION, + keywords: search, + handler: () => emitter.emit(busEvent, parsed.unixTime), + })); +}; + +const patchNinjaKeysOpenClose = el => { + if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') { + return; + } + + const originalOpen = el.open.bind(el); + const originalClose = el.close.bind(el); + + el.open = (...args) => { + const [options = {}] = args; + currentCommandRoot.value = options.parent || null; + dynamicSnoozeActions.value = []; + return originalOpen(...args); + }; + + el.close = (...args) => { + currentCommandRoot.value = null; + dynamicSnoozeActions.value = []; + return originalClose(...args); + }; +}; + const onSelected = item => { const { - detail: { action: { title = null, section = null, id = null } = {} } = {}, + detail: { + action: { title = null, section = null, id = null, children = null } = {}, + } = {}, } = item; // Added this condition to prevent setting the selectedSnoozeType to null // When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open) @@ -54,6 +134,10 @@ const onSelected = item => { selectedSnoozeType.value = null; } + if (Array.isArray(children) && children.length) { + currentCommandRoot.value = id; + } + useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title, @@ -62,6 +146,35 @@ const onSelected = item => { setCommandBarData(); }; +const onCommandBarChange = item => { + const { detail: { search = '', actions = [] } = {} } = item; + const normalizedSearch = search.trim(); + + if (actions.length > 0) { + const uniqueParents = [ + ...new Set(actions.map(action => action.parent).filter(Boolean)), + ]; + if (uniqueParents.length === 1) { + currentCommandRoot.value = uniqueParents[0]; + } else if (uniqueParents.length > 1) { + currentCommandRoot.value = null; + } + } + + if ( + !normalizedSearch || + !SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '') + ) { + dynamicSnoozeActions.value = []; + return; + } + + dynamicSnoozeActions.value = buildDynamicSnoozeActions( + normalizedSearch, + currentCommandRoot.value + ); +}; + const onClosed = () => { // If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null // Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method @@ -70,6 +183,8 @@ const onClosed = () => { ) { store.dispatch('setContextMenuChatId', null); } + currentCommandRoot.value = null; + dynamicSnoozeActions.value = []; }; watchEffect(() => { @@ -78,7 +193,10 @@ watchEffect(() => { } }); -onMounted(setCommandBarData); +onMounted(() => { + setCommandBarData(); + patchNinjaKeysOpenClose(ninjakeys.value); +}); @@ -88,6 +206,7 @@ onMounted(setCommandBarData); noAutoLoadMdIcons hideBreadcrumbs :placeholder="placeholder" + @change="onCommandBarChange" @selected="onSelected" @closed="onClosed" /> diff --git a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue index 9a855188b..e6f940fdc 100644 --- a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue @@ -69,6 +69,8 @@ export default { onCmdSnoozeNotification(snoozeType) { if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { this.showCustomSnoozeModal = true; + } else if (typeof snoozeType === 'number') { + this.snoozeNotification(snoozeType); } else { const snoozedUntil = findSnoozeTime(snoozeType) || null; this.snoozeNotification(snoozedUntil);