diff --git a/app/javascript/dashboard/helper/snoozeDateParser.js b/app/javascript/dashboard/helper/snoozeDateParser.js index 4f4c94b42..e892ef508 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser.js +++ b/app/javascript/dashboard/helper/snoozeDateParser.js @@ -30,9 +30,10 @@ import { addWeeks, isBefore, isAfter, + endOfMonth, } from 'date-fns'; -// ─── Token Definitions ─────────────────────────────────────────────────────── +// ─── Token Maps ────────────────────────────────────────────────────────────── const WEEKDAY_MAP = { sunday: 0, @@ -84,6 +85,7 @@ const MONTH_MAP = { const RELATIVE_DAY_MAP = { today: 0, tonight: 0, + tonite: 0, tomorrow: 1, tmr: 1, tmrw: 1, @@ -138,7 +140,13 @@ const WORD_NUMBER_MAP = { ten: 10, eleven: 11, twelve: 12, + thirteen: 13, + fourteen: 14, fifteen: 15, + sixteen: 16, + seventeen: 17, + eighteen: 18, + nineteen: 19, twenty: 20, thirty: 30, forty: 40, @@ -146,6 +154,8 @@ const WORD_NUMBER_MAP = { sixty: 60, ninety: 90, half: 0.5, + couple: 2, + few: 3, }; const NEXT_WEEKDAY_FN = { @@ -195,28 +205,183 @@ 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}))?'; +// ─── Pre-compiled Regexes (avoid re-compilation on every parse call) ───────── + +const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/; +const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`); +const DURATION_FROM_NOW_RE = new RegExp( + `^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$` +); +const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`); +const RELATIVE_DAY_TOD_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$` +); +const RELATIVE_DAY_TOD_TIME_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$` +); +const RELATIVE_DAY_AT_TIME_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` + + '(\\d{1,2}(?::\\d{2})?\\s*' + + '(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$' +); +const RELATIVE_DAY_SAME_TIME_RE = new RegExp( + `^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$` +); +const NEXT_UNIT_RE = new RegExp( + `^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$` +); +const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`); +const NEXT_WEEKDAY_TOD_RE = new RegExp( + `^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$` +); +const NEXT_WEEKDAY_RE = new RegExp( + `^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` + + `|next\\s+week\\s+(${WEEKDAY_NAMES})` + + `|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$` +); +const SAME_TIME_WEEKDAY_RE = new RegExp( + `^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$` +); +const WEEKDAY_TOD_RE = new RegExp( + `^(?:(?:this|upcoming|coming)\\s+)?` + + `(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$` +); +const WEEKDAY_TOD_TIME_RE = new RegExp( + `^(?:(?:this|upcoming|coming)\\s+)?` + + `(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$` +); +const WEEKDAY_TIME_RE = new RegExp( + `^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$` +); +const TIME_ONLY_MERIDIEM_RE = + /^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/; +const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/; +const TOD_WITH_TIME_RE = new RegExp( + `^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` + + '(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' + + '(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$' +); +const TOD_PLAIN_RE = new RegExp( + '(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' + + `(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$` +); +const ABSOLUTE_DATE_RE = new RegExp( + `^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` + + `(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$` +); +const ABSOLUTE_DATE_REVERSED_RE = new RegExp( + `^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` + + `(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$` +); +const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`); +const DAY_AFTER_TOMORROW_RE = new RegExp( + `^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$` +); + +const COMPOUND_DURATION_RE = new RegExp( + `^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$` +); +const DURATION_AT_TIME_RE = new RegExp( + `^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` + + '(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$' +); +const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/; +const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/; + +const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`); +const ISO_DATE_RE = new RegExp( + `^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}` +); +const SLASH_DATE_RE = new RegExp( + `^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}` +); +const DASH_DATE_RE = new RegExp( + `^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}` +); +const DOT_DATE_RE = new RegExp( + `^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}` +); +const AMBIGUOUS_DATE_RES = [SLASH_DATE_RE, DASH_DATE_RE, DOT_DATE_RE]; + +const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i'); +const NUM_TOD_RE = + /\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g; +const TOD_TO_MERIDIEM = { + morning: 'am', + noon: 'pm', + afternoon: 'pm', + evening: 'pm', + night: 'pm', +}; + // ─── 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+)?(?:(?:it|this)\s+(?=(?:on|to|for|at|until|till|by|from)\s))?(?:(?:on|to|for|at|until|till|by|from)\s+)?/; + /^(?:(?:can|could|will|would)\s+you\s+)?(?:(?: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|from)\s+)?/; const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/; -const normalize = text => { - let t = text - .toLowerCase() +const UNICODE_DIGIT_RANGES = [ + [0x30, 0x39], + [0x660, 0x669], // Arabic-Indic + [0x6f0, 0x6f9], // Eastern Arabic-Indic + [0x966, 0x96f], // Devanagari + [0x9e6, 0x9ef], // Bengali + [0xa66, 0xa6f], // Gurmukhi + [0xae6, 0xaef], // Gujarati + [0xb66, 0xb6f], // Oriya + [0xbe6, 0xbef], // Tamil + [0xc66, 0xc6f], // Telugu + [0xce6, 0xcef], // Kannada + [0xd66, 0xd6f], // Malayalam +]; + +const toAsciiDigit = char => { + const code = char.codePointAt(0); + const range = UNICODE_DIGIT_RANGES.find( + ([start, end]) => code >= start && code <= end + ); + if (!range) return char; + return String(code - range[0]); +}; + +const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit); + +const ARABIC_PUNCT_MAP = { + '\u061f': '?', + '\u060c': ',', + '\u061b': ';', + '\u066b': '.', +}; + +const sanitize = text => + normalizeDigits( + text + .normalize('NFKC') + .toLowerCase() + .replace(/[\u200f\u200e\u066c\u0640]/g, '') + .replace(/[\u064b-\u065f]/g, '') + .replace(/\u00a0/g, ' ') + .replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c]) + ) .replace(/[,!?;]+/g, ' ') .replace(/\.+$/g, '') .replace(/\s+/g, ' ') .trim(); - t = t.replace(NOISE_RE, '').trim(); - t = t.replace(APPROX_RE, '').trim(); - return t; -}; + +const stripNoise = text => + text + .replace(NOISE_RE, '') + .replace(APPROX_RE, '') + .replace(/\bnxt\b/g, 'next') + .replace(/\bcouple\s+of\b/g, 'couple') + .replace(/\b(\d+)h(\d+)m?\b/g, '$1 hours $2 minutes') + .replace(/\btomm?orow\b/g, 'tomorrow') + .trim(); const parseNumber = str => { if (!str) return null; - const lower = str.toLowerCase().trim(); + const lower = normalizeDigits(str.toLowerCase().trim()); if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower]; const num = Number(lower); return Number.isNaN(num) ? null : num; @@ -227,24 +392,24 @@ const applyTimeToDate = (date, hours, minutes = 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\.?)?$/ - ); + const match = timeStr + .toLowerCase() + .replace(/\s+/g, '') + .match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/); if (!match) return null; - let hours = parseInt(match[1], 10); + const raw = parseInt(match[1], 10); const minutes = match[2] ? parseInt(match[2], 10) : 0; const meridiem = match[3]?.replace(/\./g, ''); + if (meridiem && (raw < 1 || raw > 12)) return null; - 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; - + const toHours = (h, m) => { + if (m === 'pm' && h < 12) return h + 12; + if (m === 'am' && h === 12) return 0; + return h; + }; + const hours = toHours(raw, meridiem); + if (hours > 23 || minutes > 59) return null; return { hours, minutes }; }; @@ -269,30 +434,41 @@ const strictDate = (year, month, day) => { return date; }; +// Scan up to 8 years ahead for the next valid future date (handles feb 29 across leap cycles). 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; + for (let i = 0; i < 9; i += 1) { + const base = strictDate(year + i, month, day); + if (base) { + const date = applyTimeOrDefault(base, timeStr); + if (!date) return null; + if (isAfter(date, now)) return date; } - return !date; - }); - return result; + } + return null; }; -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; +const ensureFutureOrNextDay = (date, now) => + isAfter(date, now) ? date : add(date, { days: 1 }); + +// Infer hours from a bare number using time-of-day context. +// "morning 6" → 6 (am), "evening 6" → 18 (6pm), "afternoon 3" → 15 (3pm) +const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => { + const h = parseInt(rawHour, 10); + const m = rawMinutes ? parseInt(rawMinutes, 10) : 0; + if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null; + const range = TOD_HOUR_RANGE[todLabel]; + if (!range) return { hours: h, minutes: m }; + // Try both am and pm interpretations, pick the one in range + const am = h === 12 ? 0 : h; + const pm = h === 12 ? 12 : h + 12; + const inRange = v => v >= range[0] && v < range[1]; + if (inRange(am)) return { hours: am, minutes: m }; + if (inRange(pm)) return { hours: pm, minutes: m }; + const mid = (range[0] + range[1]) / 2; + return { + hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm, + minutes: m, + }; }; // ─── Pattern Matchers ──────────────────────────────────────────────────────── @@ -322,174 +498,206 @@ const HALF_UNIT_DURATIONS = { year: { months: 6 }, }; -const matchRelativeDuration = (text, now) => { - const halfMatch = text.match( - /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/ - ); - if (halfMatch) { - const duration = HALF_UNIT_DURATIONS[halfMatch[1]]; - return duration ? add(now, duration) : null; - } - - const match = text.match(new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`)); +const parseDuration = (match, now) => { if (!match) return null; - const amount = parseNumber(match[1]); const unit = UNIT_MAP[match[2]]; if (amount == null || !unit) return null; - return addFractionalSafe(now, unit, amount); }; -const matchDurationFromNow = (text, now) => { - const match = text.match( - new RegExp(`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`) +const matchDuration = (text, now) => { + const half = text.match(HALF_UNIT_RE); + if (half) { + return HALF_UNIT_DURATIONS[half[1]] + ? add(now, HALF_UNIT_DURATIONS[half[1]]) + : null; + } + + const compound = text.match(COMPOUND_DURATION_RE); + if (compound) { + const a1 = parseNumber(compound[1]); + const u1 = UNIT_MAP[compound[2]]; + const a2 = parseNumber(compound[3]); + const u2 = UNIT_MAP[compound[4]]; + if (a1 == null || !u1 || a2 == null || !u2) { + return null; + } + return add(add(now, { [u1]: a1 }), { [u2]: a2 }); + } + + const atTime = text.match(DURATION_AT_TIME_RE); + if (atTime) { + const amount = parseNumber(atTime[1]); + const unit = UNIT_MAP[atTime[2]]; + const time = parseTimeString(atTime[3]); + if (amount == null || !unit || !time) { + return null; + } + return applyTimeToDate( + add(now, { [unit]: amount }), + time.hours, + time.minutes + ); + } + + return ( + parseDuration(text.match(DURATION_FROM_NOW_RE), now) || + parseDuration(text.match(RELATIVE_DURATION_RE), now) ); - if (!match) return null; +}; - const amount = parseNumber(match[1]); - const unit = UNIT_MAP[match[2]]; - if (amount == null || !unit) return null; - - return addFractionalSafe(now, unit, amount); +const applyTimeWithRollover = (offset, hours, minutes, now) => { + const base = add(startOfDay(now), { days: offset }); + const date = applyTimeToDate(base, hours, minutes); + if (isAfter(date, now)) return date; + return applyTimeToDate(add(base, { days: 1 }), hours, minutes); }; const matchRelativeDay = (text, now) => { - const dayOnlyMatch = text.match(new RegExp(`^(${RELATIVE_DAYS})$`)); + const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE); 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 }); + if (key === 'tonight' || key === 'tonite') { + return ensureFutureOrNextDay( + applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0), + now + ); } - return date; + if (offset === 1) { + return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0); + } + return add(now, { hours: 1 }); } - const dayTodMatch = text.match( - new RegExp(`^(${RELATIVE_DAYS})\\s+(${TIME_OF_DAY_NAMES})$`) - ); + const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE); + if (dayTodTimeMatch) { + const timeParts = dayTodTimeMatch[3].split(':'); + const time = inferHoursFromTOD( + dayTodTimeMatch[2], + timeParts[0], + timeParts[1] + ); + if (!time) return null; + return applyTimeWithRollover( + RELATIVE_DAY_MAP[dayTodTimeMatch[1]], + time.hours, + time.minutes, + now + ); + } + + const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE); 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 { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]]; + return applyTimeWithRollover( + RELATIVE_DAY_MAP[dayTodMatch[1]], + hours, + minutes, + now + ); } - 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})$` - ) - ); + const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE); 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; + return applyTimeWithRollover( + RELATIVE_DAY_MAP[dayAtTimeMatch[1]], + time.hours, + time.minutes, + now + ); } - const sameTimeMatch = text.match( - new RegExp(`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`) - ); + const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE); 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 applyTimeToDate( + add(startOfDay(now), { days: offset }), + now.getHours(), + now.getMinutes() + ); } return null; }; +const nextWeekdayInNextWeek = (dayIndex, now) => { + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + const date = fn(now); + const sameWeek = + startOfWeek(now, { weekStartsOn: 1 }).getTime() === + startOfWeek(date, { weekStartsOn: 1 }).getTime(); + return sameWeek ? fn(date) : date; +}; + const matchNextPattern = (text, now) => { - const nextUnitMatch = text.match(/^next\s+(hour|minute|week|month|year)$/); + const nextUnitMatch = text.match(NEXT_UNIT_RE); 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); + return applyTimeOrDefault(base, nextUnitMatch[2]); } const base = add(startOfDay(now), { [`${unit}s`]: 1 }); - return applyTimeToDate(base, 9, 0); + return applyTimeOrDefault(base, nextUnitMatch[2]); } - 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 nextMonthMatch = text.match(NEXT_MONTH_RE); + if (nextMonthMatch) { + const monthIdx = MONTH_MAP[nextMonthMatch[1]]; + let year = now.getFullYear(); + if (monthIdx <= now.getMonth()) year += 1; + const base = new Date(year, monthIdx, 1); + return applyTimeOrDefault(base, nextMonthMatch[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]); + // "next monday morning", "next friday midnight" — weekday + time-of-day + const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE); + if (nextTodMatch) { + const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now); + if (!date) return null; + const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]]; + return applyTimeToDate(date, hours, minutes); } - 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]); + // "monday of next week", "next week monday", "next friday" — all with optional time + const weekdayMatch = text.match(NEXT_WEEKDAY_RE); + if (weekdayMatch) { + const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3]; + const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now); + if (!date) return null; + return applyTimeOrDefault(date, weekdayMatch[4]); } return null; }; +const resolveWeekdayDate = (dayIndex, timeStr, now) => { + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + let adjusted = timeStr; + if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) { + const h = parseInt(timeStr, 10); + if (h >= 1 && h <= 7) adjusted = `${h}pm`; + } + + if (getDay(now) === dayIndex) { + const todayDate = applyTimeOrDefault(now, adjusted); + if (todayDate && isAfter(todayDate, now)) return todayDate; + } + + return applyTimeOrDefault(fn(now), adjusted); +}; + const matchWeekday = (text, now) => { - const sameTimeWeekday = text.match( - new RegExp(`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`) - ); + const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE); if (sameTimeWeekday) { const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]]; const fn = NEXT_WEEKDAY_FN[dayIndex]; @@ -498,141 +706,143 @@ const matchWeekday = (text, 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; + // "monday morning 6", "friday evening 7" — weekday + tod + bare number + const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE); + if (todTimeMatch) { + const dayIndex = WEEKDAY_MAP[todTimeMatch[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + const timeParts = todTimeMatch[3].split(':'); + const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]); + if (!time) return null; + const target = + getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now)); + const date = applyTimeToDate(target, time.hours, time.minutes); + return isAfter(date, now) + ? date + : applyTimeToDate(fn(now), time.hours, time.minutes); } - return applyTimeOrDefault(fn(now), match[2]); + // "monday morning", "friday midnight", "wednesday evening", etc. + const todMatch = text.match(WEEKDAY_TOD_RE); + if (todMatch) { + const dayIndex = WEEKDAY_MAP[todMatch[1]]; + const fn = NEXT_WEEKDAY_FN[dayIndex]; + if (!fn) return null; + const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]]; + const target = + getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now)); + const date = applyTimeToDate(target, hours, minutes); + return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes); + } + + const match = text.match(WEEKDAY_TIME_RE); + if (!match) return null; + + return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now); }; 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})$/); + const match = + text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE); 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]; + return ensureFutureOrNextDay( + applyTimeToDate(now, time.hours, time.minutes), + now + ); }; 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\\.?))$` - ) - ); + const todWithTime = text.match(TOD_WITH_TIME_RE); if (todWithTime) { - const todLabel = todWithTime[1]; - const time = parseTimeString(todWithTime[2]); + const rawTime = todWithTime[2].trim(); + const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime); + let time; + if (hasMeridiem) { + time = parseTimeString(rawTime); + const range = TOD_HOUR_RANGE[todWithTime[1]]; + if (!time) return null; + if (range) { + const h = time.hours === 0 ? 24 : time.hours; + if (h < range[0] || h >= range[1]) return null; + } + } else { + const parts = rawTime.split(':'); + time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]); + } 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; + return ensureFutureOrNextDay( + applyTimeToDate(now, time.hours, time.minutes), + now + ); } - const match = text.match( - new RegExp( - `^(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$` - ) - ); + const match = text.match(TOD_PLAIN_RE); if (!match) return null; const key = text .replace(/^(?:later|in)\s+/, '') .replace(/^(?:this|the)\s+/, '') .trim(); - return resolveTimeOfDay(key, now); + const tod = TIME_OF_DAY_MAP[key]; + if (!tod) return null; + return ensureFutureOrNextDay( + applyTimeToDate(now, tod.hours, tod.minutes), + 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); +const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => { 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 (yearStr && /next\s+year/i.test(yearStr)) { + year += 1; + } else if (yearStr) { + year = parseInt(yearStr, 10); } - - if (match[3]) { + if (yearStr) { 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; + const date = applyTimeOrDefault(base, timeStr); + return date && isAfter(date, now) ? date : null; } - return futureOrNextYear(year, month, day, match[4], now); + return futureOrNextYear(year, month, day, timeStr, 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); +const matchNamedDate = (text, now) => { + const abs = text.match(ABSOLUTE_DATE_RE); + if (abs) { + return resolveAbsoluteDate( + MONTH_MAP[abs[1]], + parseInt(abs[2], 10), + abs[3], + abs[4], + now + ); } - 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; + const rev = text.match(ABSOLUTE_DATE_REVERSED_RE); + if (rev) { + return resolveAbsoluteDate( + MONTH_MAP[rev[2]], + parseInt(rev[1], 10), + rev[3], + rev[4], + now + ); } - 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 my = text.match(MONTH_YEAR_RE); + if (my) { + const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1); + if (!isValid(date)) return null; + const result = applyTimeToDate(date, 9, 0); + return isAfter(result, now) ? result : 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); + return null; }; const buildDateWithOptionalTime = (year, month, day, timeStr) => { @@ -641,22 +851,7 @@ const buildDateWithOptionalTime = (year, month, day, timeStr) => { 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 DOT_DATE_RE = new RegExp( - `^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX.source}` -); - +// When both values are ≤ 12 (ambiguous), defaults to M/D (US format). const disambiguateDayMonth = (a, b) => { if (a > 12) return { day: a, month: b - 1 }; if (b > 12) return { month: a - 1, day: b }; @@ -664,202 +859,200 @@ const disambiguateDayMonth = (a, b) => { }; const matchFormalDate = (text, now) => { - const ensureFuture = date => { - if (!date || !isAfter(date, now)) return null; - return date; - }; + const ensureFuture = date => (date && isAfter(date, now) ? date : null); - let match = text.match(ISO_DATE_RE); - if (match) { + const isoMatch = text.match(ISO_DATE_RE); + if (isoMatch) { return ensureFuture( buildDateWithOptionalTime( - parseInt(match[1], 10), - parseInt(match[2], 10) - 1, - parseInt(match[3], 10), - match[4] + parseInt(isoMatch[1], 10), + parseInt(isoMatch[2], 10) - 1, + parseInt(isoMatch[3], 10), + isoMatch[4] ) ); } - const parseAmbiguous = m => { + let result = null; + AMBIGUOUS_DATE_RES.some(re => { + const m = text.match(re); + if (!m) return false; const { month, day } = disambiguateDayMonth( parseInt(m[1], 10), parseInt(m[2], 10) ); - return ensureFuture( + result = ensureFuture( buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4]) ); - }; + return true; + }); + return result; +}; - match = text.match(SLASH_DATE_RE); - if (match) return parseAmbiguous(match); +const matchSpecial = (text, now) => { + const dat = text.match(DAY_AFTER_TOMORROW_RE); + if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]); - match = text.match(DASH_DATE_RE); - if (match) return parseAmbiguous(match); + const eof = text.match(END_OF_RE); + if (eof) { + if (eof[1] === 'day') return applyTimeToDate(now, 17, 0); + if (eof[1] === 'week') { + const fri = applyTimeToDate(now, 17, 0); + if (getDay(now) === 5 && isAfter(fri, now)) return fri; + return applyTimeToDate(nextFriday(now), 17, 0); + } + if (eof[1] === 'month') return applyTimeToDate(endOfMonth(now), 17, 0); + } - match = text.match(DOT_DATE_RE); - if (match) return parseAmbiguous(match); + if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 }); + + const weekendMatch = text.match( + /^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/ + ); + if (weekendMatch) { + const isNext = weekendMatch[1] === 'next weekend'; + const timeStr = weekendMatch[2]; + + if (isNext) { + const sat = nextSaturday(now); + const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 }); + return applyTimeOrDefault(d, timeStr); + } + + if (isSaturday(now)) { + if (!timeStr) { + 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); + } + const today = applyTimeOrDefault(now, timeStr); + if (today && isAfter(today, now)) return today; + return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr); + } + if (isSunday(now)) { + if (!timeStr) { + if (now.getHours() < 10) return applyTimeToDate(now, 10, 0); + return add(now, { hours: 2 }); + } + const today = applyTimeOrDefault(now, timeStr); + if (today && isAfter(today, now)) return today; + } + return applyTimeOrDefault(nextSaturday(now), timeStr); + } 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, + matchDuration, + matchSpecial, matchRelativeDay, matchNextPattern, - matchThisWeekend, - matchNextWeekend, matchTimeOfDay, matchWeekday, matchTimeOnly, - matchAbsoluteDate, - matchAbsoluteDateReversed, - matchMonthYear, + matchNamedDate, 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. + * Parse a natural language date/time string into a future Date. + * Returns { date, unix } or null. All results are strictly future. + * Uses runtime local timezone. 999-year max cap. * * @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 + * @param {Date} [referenceDate=new Date()] - Reference date + * @returns {{ date: Date, unix: number } | null} */ export const parseDateFromText = (text, referenceDate = new Date()) => { if (!text || typeof text !== 'string') return null; - const normalized = normalize(text); + const normalized = stripNoise(sanitize(text)); if (!normalized) return null; const maxDate = add(referenceDate, { years: 999 }); - let parsed = null; + const isValidFuture = d => + d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d); + + let result = 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) }; + const d = matcher(normalized, referenceDate); + if (isValidFuture(d)) { + result = { date: d, unix: getUnixTime(d) }; return true; } return false; }); - return parsed; + return result; }; -// ─── Suggestion Candidates (uses maps already defined above) ───────────────── +// ─── Smart Suggestion Engine ───────────────────────────────────────────────── const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter( u => u !== 'seconds' ); +const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3); +const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' ')); const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3); -const PHRASE_CANDIDATES = [ +const ALL_SUGGESTION_PHRASES = [ ...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', + ...FULL_WEEKDAYS, + ...TOD_NAMES, 'next week', 'next month', - 'this friday', - 'this saturday', - 'this sunday', 'this weekend', 'next weekend', 'day after tomorrow', + 'later today', + 'end of day', + 'end of week', + 'end of month', + ...TOD_NAMES.map(tod => `tomorrow ${tod}`), + ...TOD_NAMES.map(tod => `tomorrow at ${tod}`), + ...FULL_WEEKDAYS.map(wd => `next ${wd}`), + ...FULL_WEEKDAYS.map(wd => `this ${wd}`), + ...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)), + ...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)), + ...MONTH_NAMES_LONG.map(m => `${m} 1`), ]; -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 prefixMatchScore = (candidate, input) => { + if (candidate === input) return -1; + if (candidate.startsWith(input)) return 0; + const inputWords = input.split(' '); + const candidateWords = candidate.split(' '); + const lastIdx = inputWords.reduce((prev, iw) => { + if (prev === -2) return -2; + const idx = candidateWords.findIndex( + (cw, ci) => ci > prev && cw.startsWith(iw) + ); + return idx === -1 ? -2 : idx; + }, -1); + if (lastIdx === -2) return -1; + return candidateWords.length - inputWords.length; }; +const MAX_SUGGESTIONS = 5; + const buildSuggestionCandidates = text => { + if (!text) return []; + if (/^\d/.test(text)) { const num = text.match(/^\d+(?:\.5)?/)[0]; - return SUGGESTION_UNITS.map(u => `${num} ${u}`); + const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`); + const trimmed = text.replace(/\s+/g, ' ').trim(); + return trimmed.length > num.length + ? candidates.filter(c => c.startsWith(trimmed)) + : candidates; } - if ('half'.startsWith(text)) { + if (text.length >= 2 && 'half'.startsWith(text)) { return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`); } @@ -868,47 +1061,347 @@ const buildSuggestionCandidates = text => { return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`); } - return PHRASE_CANDIDATES.filter(c => matchesPrefix(c, text)); + const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => { + const score = prefixMatchScore(candidate, text); + if (score >= 0) acc.push({ candidate, score }); + return acc; + }, []); + scored.sort((a, b) => a.score - b.score); + const seen = new Set(); + return scored.reduce((acc, { candidate }) => { + if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) { + seen.add(candidate); + acc.push(candidate); + } + return acc; + }, []); }; -const MAX_SUGGESTIONS = 5; +// ─── Localized Input Support ───────────────────────────────────────────────── + +const EN_WEEKDAYS_LIST = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; +const EN_MONTHS_LIST = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +const EN_DEFAULTS = { + UNITS: { + MINUTE: 'minute', + MINUTES: 'minutes', + HOUR: 'hour', + HOURS: 'hours', + DAY: 'day', + DAYS: 'days', + WEEK: 'week', + WEEKS: 'weeks', + MONTH: 'month', + MONTHS: 'months', + YEAR: 'year', + YEARS: 'years', + }, + RELATIVE: { + TOMORROW: 'tomorrow', + DAY_AFTER_TOMORROW: 'day after tomorrow', + NEXT_WEEK: 'next week', + NEXT_MONTH: 'next month', + THIS_WEEKEND: 'this weekend', + NEXT_WEEKEND: 'next weekend', + }, + TIME_OF_DAY: { + MORNING: 'morning', + AFTERNOON: 'afternoon', + EVENING: 'evening', + NIGHT: 'night', + NOON: 'noon', + MIDNIGHT: 'midnight', + }, + WORD_NUMBERS: { + ONE: 'one', + TWO: 'two', + THREE: 'three', + FOUR: 'four', + FIVE: 'five', + SIX: 'six', + SEVEN: 'seven', + EIGHT: 'eight', + NINE: 'nine', + TEN: 'ten', + TWELVE: 'twelve', + FIFTEEN: 'fifteen', + TWENTY: 'twenty', + THIRTY: 'thirty', + }, + MERIDIEM: { AM: 'am', PM: 'pm' }, + HALF: 'half', + NEXT: 'next', + THIS: 'this', + AT: 'at', + IN: 'in', + FROM_NOW: 'from now', + NEXT_YEAR: 'next year', +}; + +const STRUCTURAL_WORDS = [ + 'at', + 'in', + 'next', + 'this', + 'from', + 'now', + 'after', + 'half', + 'same', + 'time', + 'weekend', + 'end', + 'of', + 'the', + 'eod', + 'am', + 'pm', +]; + +const ENGLISH_VOCAB = new Set([ + ...Object.keys(WEEKDAY_MAP), + ...Object.keys(MONTH_MAP), + ...Object.keys(UNIT_MAP), + ...Object.keys(WORD_NUMBER_MAP), + ...Object.keys(RELATIVE_DAY_MAP), + ...Object.keys(TIME_OF_DAY_MAP), + ...EN_WEEKDAYS_LIST, + ...EN_MONTHS_LIST, + ...STRUCTURAL_WORDS, +]); +const ENGLISH_VOCAB_LIST = [...ENGLISH_VOCAB]; +const hasVocabPrefix = w => ENGLISH_VOCAB_LIST.some(v => v.startsWith(w)); + +const safeString = v => (v == null ? '' : String(v)); + +const MAX_PAIRS_CACHE = 20; +const pairsCache = new Map(); +const CACHE_SECTIONS = [ + 'UNITS', + 'RELATIVE', + 'TIME_OF_DAY', + 'WORD_NUMBERS', + 'MERIDIEM', +]; +const SINGLE_KEYS = [ + 'HALF', + 'NEXT', + 'THIS', + 'AT', + 'IN', + 'FROM_NOW', + 'NEXT_YEAR', +]; + +const translationSignature = translations => { + if (!translations || typeof translations !== 'object') return 'none'; + return [ + ...CACHE_SECTIONS.flatMap(section => { + const values = translations[section] || {}; + return Object.keys(values) + .sort() + .map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`); + }), + ...SINGLE_KEYS.map( + k => `${k}:${safeString(translations[k]).toLowerCase()}` + ), + ].join('|'); +}; + +const buildReplacementPairsUncached = (translations, locale) => { + const pairs = []; + const seen = new Set(); + const t = translations || {}; + + const addPair = (local, en) => { + const l = sanitize(safeString(local)); + const e = safeString(en).toLowerCase(); + if (l && e && l !== e && !seen.has(l)) { + seen.add(l); + pairs.push([l, e]); + } + }; + + CACHE_SECTIONS.forEach(section => { + const localSection = t[section] || {}; + const enSection = EN_DEFAULTS[section] || {}; + Object.keys(enSection).forEach(key => { + addPair(localSection[key], enSection[key]); + }); + }); + + SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key])); + + try { + const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' }); + // Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday' + EN_WEEKDAYS_LIST.forEach((en, i) => { + addPair(wdFmt.format(new Date(2024, 0, i + 1)), en); + }); + } catch { + /* locale not supported */ + } + + try { + const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' }); + EN_MONTHS_LIST.forEach((en, i) => { + addPair(moFmt.format(new Date(2024, i, 1)), en); + }); + } catch { + /* locale not supported */ + } + + pairs.sort((a, b) => b[0].length - a[0].length); + return pairs; +}; + +const buildReplacementPairs = (translations, locale) => { + const cacheKey = `${locale || ''}:${translationSignature(translations)}`; + if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey); + const pairs = buildReplacementPairsUncached(translations, locale); + if (pairsCache.size >= MAX_PAIRS_CACHE) + pairsCache.delete(pairsCache.keys().next().value); + pairsCache.set(cacheKey, pairs); + return pairs; +}; + +const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const substituteLocalTokens = (text, pairs) => { + let r = text; + pairs.forEach(([local, en]) => { + const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g'); + r = r.replace(re, en); + }); + return r; +}; + +const filterToEnglishVocab = text => + normalizeDigits(text) + .replace(/(\d+)h\b/g, '$1:00') + .split(/\s+/) + .filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase())) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + +const repositionNextYear = text => { + if (!MONTH_NAME_RE.test(text)) return text; + let r = text.replace(/\b(?:next\s+)?year\b/i, m => + /next/i.test(m) ? m : 'next year' + ); + if (!/\bnext\s+year\b/i.test(r)) return r; + const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim(); + const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i; + const timePart = withoutNY.match(timeRe); + if (timePart) { + const beforeTime = withoutNY.slice(0, timePart.index).trim(); + r = `${beforeTime} next year ${timePart[0].trim()}`; + } else { + r = `${withoutNY} next year`; + } + return r; +}; + +const replaceTokens = (text, pairs) => { + const substituted = substituteLocalTokens(text, pairs); + const filtered = filterToEnglishVocab(substituted); + const fixed = filtered.replace( + NUM_TOD_RE, + (_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}` + ); + return stripNoise(repositionNextYear(fixed)); +}; + +const reverseTokens = (text, pairs) => + pairs.reduce( + (r, [local, en]) => + r.replace( + new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'), + local + ), + text + ); /** - * 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. + * Generate smart snooze suggestions for a search prefix. + * Builds compositional candidates (weekday+tod, next+weekday, etc.) + * with multi-word fuzzy prefix matching. Supports multilingual input. + * Deduped by unix timestamp, capped at 5. * * @param {string} text - Search prefix * @param {Date} [referenceDate=new Date()] - Reference date + * @param {{ translations?: object, locale?: string }} [options={}] - i18n * @returns {Array<{ label: string, date: Date, unix: number }>} */ -export const generateDateSuggestions = (text, referenceDate = new Date()) => { +export const generateDateSuggestions = ( + text, + referenceDate = new Date(), + { translations, locale } = {} +) => { if (!text || typeof text !== 'string') return []; - const normalized = text.trim().toLowerCase(); + const normalized = sanitize(text); if (!normalized) return []; + const stripped = stripNoise(normalized); + const pairs = + locale && locale !== 'en' + ? buildReplacementPairs(translations, locale) + : []; + + // Try English first — if user types English in a non-English locale, skip translation + const directParse = parseDateFromText(stripped, referenceDate); + const looksEnglish = + directParse || + stripped + .split(/\s+/) + .filter(w => !/^\d/.test(w)) + .some(w => ENGLISH_VOCAB.has(w) || (w.length >= 2 && hasVocabPrefix(w))); + const useEnglish = !pairs.length || looksEnglish; + + const englishInput = useEnglish ? stripped : replaceTokens(normalized, pairs); + const seen = new Set(); const results = []; - const exact = parseDateFromText(normalized, referenceDate); + const exact = directParse || parseDateFromText(englishInput, referenceDate); if (exact) { seen.add(exact.unix); results.push({ label: normalized, ...exact }); } - buildSuggestionCandidates(normalized).some(candidate => { + buildSuggestionCandidates(englishInput).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 }); + const label = + !useEnglish && pairs.length + ? reverseTokens(candidate, pairs) + : candidate; + results.push({ label, ...result }); } return false; }); diff --git a/app/javascript/dashboard/helper/snoozeHelpers.js b/app/javascript/dashboard/helper/snoozeHelpers.js index b09642844..e4623fcc2 100644 --- a/app/javascript/dashboard/helper/snoozeHelpers.js +++ b/app/javascript/dashboard/helper/snoozeHelpers.js @@ -35,89 +35,93 @@ export const findStartOfNextMonth = currentDate => { }); }; -export const findNextDay = currentDate => { - return add(currentDate, { days: 1 }); -}; +export const findNextDay = currentDate => add(currentDate, { days: 1 }); -export const setHoursToNine = date => { - return setSeconds(setMinutes(setHours(date, 9), 0), 0); +export const setHoursToNine = date => + setSeconds(setMinutes(setHours(date, 9), 0), 0); + +const SNOOZE_RESOLVERS = { + [SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }), + [SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)), + [SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)), + [SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d => + setHoursToNine(findStartOfNextMonth(d)), }; export const findSnoozeTime = (snoozeType, currentDate = new Date()) => { - let parsedDate = null; - if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) { - parsedDate = add(currentDate, { hours: 1 }); - } else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) { - parsedDate = setHoursToNine(findNextDay(currentDate)); - } else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) { - parsedDate = setHoursToNine(findStartOfNextWeek(currentDate)); - } else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) { - parsedDate = setHoursToNine(findStartOfNextMonth(currentDate)); - } - - return parsedDate ? getUnixTime(parsedDate) : null; + const resolve = SNOOZE_RESOLVERS[snoozeType]; + return resolve ? getUnixTime(resolve(currentDate)) : null; }; + export const snoozedReopenTime = snoozedUntil => { - if (!snoozedUntil) { - return null; - } + if (!snoozedUntil) return null; const date = new Date(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; + if (isToday(date)) return format(date, 'h.mmaaa'); + if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa'); + return format(date, 'd MMM, h.mmaaa'); }; -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'); +export const snoozedReopenTimeToTimestamp = snoozedUntil => + snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null; + +const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => { + const sameYear = isSameYear(snoozeDate, currentDate); + try { + const opts = { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + ...(sameYear ? {} : { year: 'numeric' }), + }; + return new Intl.DateTimeFormat(locale, opts).format(snoozeDate); + } catch { + return sameYear + ? 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() + currentDate = new Date(), + { translations, locale } = {} ) => { - const suggestions = generateDateSuggestions(searchText, currentDate); + const suggestions = generateDateSuggestions(searchText, currentDate, { + translations, + locale, + }); return suggestions.map(s => ({ date: s.date, unixTime: s.unix, label: capitalizeLabel(s.label), - formattedDate: formatSnoozeDate(s.date, currentDate), + formattedDate: formatSnoozeDate(s.date, currentDate, locale), })); }; +const UNIT_SHORT = { + minute: 'm', + minutes: 'm', + hour: 'h', + hours: 'h', + day: 'd', + days: 'd', + month: 'mo', + months: 'mo', + year: 'y', + years: 'y', +}; + export const shortenSnoozeTime = snoozedUntil => { - if (!snoozedUntil) { - return null; - } - const unitMap = { - minutes: 'm', - minute: 'm', - hours: 'h', - hour: 'h', - days: 'd', - day: 'd', - months: 'mo', - month: 'mo', - years: 'y', - year: 'y', - }; - const shortenTime = snoozedUntil + if (!snoozedUntil) return null; + return snoozedUntil .replace(/^in\s+/i, '') .replace( /\s(minute|hour|day|month|year)s?\b/gi, - (match, unit) => unitMap[unit.toLowerCase()] || match + (match, unit) => UNIT_SHORT[unit.toLowerCase()] || match ); - - return shortenTime; }; diff --git a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js index f17f88e16..81d9c62af 100644 --- a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -816,6 +816,378 @@ describe('generateDateSuggestions', () => { expect(results.length).toBeLessThanOrEqual(5); }); }); + + describe('smart compositional suggestions', () => { + it('"mon" suggests monday + time-of-day variants (noon, afternoon, evening, night)', () => { + const results = generateDateSuggestions('mon', now); + const labels = results.map(r => r.label); + // "monday morning" (9am) is deduped with "monday" (default 9am), so noon+ appear + expect(labels.some(l => /monday\s+afternoon/.test(l))).toBe(true); + expect(labels.some(l => /monday\s+evening/.test(l))).toBe(true); + }); + + it('"monday" suggests multiple time-of-day variants', () => { + const results = generateDateSuggestions('monday', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('monday afternoon'))).toBe(true); + expect(labels.some(l => l.includes('monday evening'))).toBe(true); + expect(results.length).toBeGreaterThanOrEqual(3); + }); + + it('"fri" suggests friday + time-of-day variants', () => { + const results = generateDateSuggestions('fri', now); + const labels = results.map(r => r.label); + expect(labels.some(l => /friday/.test(l))).toBe(true); + expect(results.length).toBeGreaterThanOrEqual(3); + }); + + it('"tomorrow m" suggests tomorrow morning', () => { + const results = generateDateSuggestions('tomorrow m', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('tomorrow morning'))).toBe(true); + }); + + it('"tomorrow a" suggests tomorrow afternoon', () => { + const results = generateDateSuggestions('tomorrow a', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('tomorrow afternoon'))).toBe(true); + }); + + it('"next mon" suggests next monday and next month', () => { + const results = generateDateSuggestions('next mon', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('next mon'))).toBe(true); + }); + + it('"next monday m" suggests next monday morning', () => { + const results = generateDateSuggestions('next monday m', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('next monday morning'))).toBe(true); + }); + + it('"t" suggests today, tonight, tomorrow', () => { + const results = generateDateSuggestions('t', now); + const labels = results.map(r => r.label); + expect( + labels.some(l => l === 'today' || l === 'tonight' || l === 'tomorrow') + ).toBe(true); + }); + + it('"n" suggests next week, next month, next weekdays', () => { + const results = generateDateSuggestions('n', now); + const labels = results.map(r => r.label); + expect(labels.some(l => l.includes('next'))).toBe(true); + }); + + it('all suggestions parse to valid future dates', () => { + const inputs = ['mon', 'monday', 'fri', 'tomorrow m', 'next mon', 't']; + inputs.forEach(input => { + const results = generateDateSuggestions(input, now); + results.forEach(r => { + expect(r.date).toBeInstanceOf(Date); + expect(r.date > now).toBe(true); + expect(typeof r.unix).toBe('number'); + }); + }); + }); + }); +}); + +describe('bare number + time-of-day context inference', () => { + it('"morning 6" parses to 6am', () => { + const result = parseDateFromText('morning 6', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(6); + }); + + it('"evening 7" parses to 7pm (19:00)', () => { + const result = parseDateFromText('evening 7', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(19); + }); + + it('"afternoon 3" parses to 3pm (15:00)', () => { + const result = parseDateFromText('afternoon 3', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(15); + }); + + it('"night 9" parses to 9pm (21:00)', () => { + const result = parseDateFromText('night 9', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(21); + }); + + it('"tomorrow morning 6" parses to tomorrow 6am', () => { + const result = parseDateFromText('tomorrow morning 6', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toBe(17); + expect(result.date.getHours()).toBe(6); + }); + + it('"tomorrow evening 7" parses to tomorrow 7pm', () => { + const result = parseDateFromText('tomorrow evening 7', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toBe(17); + expect(result.date.getHours()).toBe(19); + }); + + it('"monday morning 6" parses to next monday 6am', () => { + const result = parseDateFromText('monday morning 6', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(6); + }); + + it('"friday evening 8" parses to friday 8pm', () => { + const result = parseDateFromText('friday evening 8', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(20); + }); + + it('explicit meridiem still works: "morning 6am" → 6am', () => { + const result = parseDateFromText('morning 6am', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(6); + }); + + it('contradictory meridiem still rejected: "morning 7pm" → null', () => { + expect(parseDateFromText('morning 7pm', now)).toBeNull(); + }); +}); + +describe('localized suggestions with Malayalam translations', () => { + const mlTranslations = { + UNITS: { + MINUTE: 'മിനിറ്റ്', + MINUTES: 'മിനിറ്റ്', + HOUR: 'മണിക്കൂർ', + HOURS: 'മണിക്കൂർ', + DAY: 'ദിവസം', + DAYS: 'ദിവസം', + WEEK: 'ആഴ്ച', + WEEKS: 'ആഴ്ച', + MONTH: 'മാസം', + MONTHS: 'മാസം', + YEAR: 'വർഷം', + YEARS: 'വർഷം', + }, + HALF: 'അര', + NEXT: 'അടുത്ത', + THIS: 'ഈ', + AT: 'സമയം', + IN: 'കഴിഞ്ഞ്', + FROM_NOW: 'ഇപ്പോൾ മുതൽ', + NEXT_YEAR: 'അടുത്ത വർഷം', + MERIDIEM: { AM: 'രാവിലെ', PM: 'വൈകുന്നേരം' }, + RELATIVE: { + TOMORROW: 'നാളെ', + DAY_AFTER_TOMORROW: 'മറ്റന്നാൾ', + NEXT_WEEK: 'അടുത്ത ആഴ്ച', + NEXT_MONTH: 'അടുത്ത മാസം', + THIS_WEEKEND: 'ഈ വാരാന്ത്യം', + NEXT_WEEKEND: 'അടുത്ത വാരാന്ത്യം', + }, + TIME_OF_DAY: { + MORNING: 'രാവിലെ', + AFTERNOON: 'ഉച്ചയ്ക്ക്', + EVENING: 'വൈകുന്നേരം', + NIGHT: 'രാത്രി', + NOON: 'ഉച്ച', + MIDNIGHT: 'അർദ്ധരാത്രി', + }, + WORD_NUMBERS: { + ONE: 'ഒന്ന്', + TWO: 'രണ്ട്', + THREE: 'മൂന്ന്', + FOUR: 'നാല്', + FIVE: 'അഞ്ച്', + SIX: 'ആറ്', + SEVEN: 'ഏഴ്', + EIGHT: 'എട്ട്', + NINE: 'ഒൻപത്', + TEN: 'പത്ത്', + TWELVE: 'പന്ത്രണ്ട്', + FIFTEEN: 'പതിനഞ്ച്', + TWENTY: 'ഇരുപത്', + THIRTY: 'മുപ്പത്', + }, + }; + + it('Malayalam "നാളെ രാവിലെ 6" parses to tomorrow 6am', () => { + const results = generateDateSuggestions('നാളെ രാവിലെ 6', now, { + translations: mlTranslations, + locale: 'ml', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + expect(results[0].date.getHours()).toBe(6); + }); + + it('Malayalam "നാളെ" (tomorrow) generates multiple suggestions', () => { + const results = generateDateSuggestions('നാളെ', now, { + translations: mlTranslations, + locale: 'ml', + }); + expect(results.length).toBeGreaterThanOrEqual(3); + expect(results[0].date.getDate()).toBe(17); + }); + + it('Malayalam suggestion labels are in Malayalam, not English', () => { + const results = generateDateSuggestions('നാളെ', now, { + translations: mlTranslations, + locale: 'ml', + }); + const labels = results.map(r => r.label); + expect(labels.some(l => /നാളെ/.test(l))).toBe(true); + expect(labels.every(l => !/\btomorrow\b/.test(l))).toBe(true); + }); +}); + +describe('chrono-level patterns', () => { + describe('tomorrow at TOD', () => { + it('"tomorrow at noon" parses to tomorrow 12pm', () => { + const result = parseDateFromText('tomorrow at noon', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toBe(17); + expect(result.date.getHours()).toBe(12); + }); + + it('"tomorrow at midnight" parses to tomorrow 0am', () => { + const result = parseDateFromText('tomorrow at midnight', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(0); + }); + + it('"tomorrow at evening" parses to tomorrow 6pm', () => { + const result = parseDateFromText('tomorrow at evening', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(18); + }); + }); + + describe('duration at time', () => { + it('"in 2 days at 3pm" parses correctly', () => { + const result = parseDateFromText('in 2 days at 3pm', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toBe(18); + expect(result.date.getHours()).toBe(15); + }); + + it('"in 1 week at 9am" parses correctly', () => { + const result = parseDateFromText('in 1 week at 9am', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(9); + }); + }); + + describe('end of period', () => { + it('"end of day" parses to today 5pm', () => { + const result = parseDateFromText('end of day', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(17); + }); + + it('"end of the week" parses to next friday 5pm', () => { + const result = parseDateFromText('end of the week', now); + expect(result).not.toBeNull(); + expect(result.date.getDay()).toBe(5); + expect(result.date.getHours()).toBe(17); + }); + + it('"end of month" parses to last day of month 5pm', () => { + const result = parseDateFromText('end of month', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toBe(30); + expect(result.date.getHours()).toBe(17); + }); + }); + + describe('later today', () => { + it('"later today" parses to +3 hours from now', () => { + const result = parseDateFromText('later today', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(13); + }); + }); + + describe('compound durations', () => { + it('"1 hour 30 minutes" parses correctly', () => { + const result = parseDateFromText('1 hour 30 minutes', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(11); + expect(result.date.getMinutes()).toBe(30); + }); + + it('"1h30m" parses correctly', () => { + const result = parseDateFromText('1h30m', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(11); + expect(result.date.getMinutes()).toBe(30); + }); + + it('"2 hours and 30 minutes" parses correctly', () => { + const result = parseDateFromText('2 hours and 30 minutes', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(12); + expect(result.date.getMinutes()).toBe(30); + }); + }); + + describe('aliases and shortcuts', () => { + it('"tonite" parses to tonight (8pm)', () => { + const result = parseDateFromText('tonite', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(20); + }); + + it('"couple hours" parses to +2 hours', () => { + const result = parseDateFromText('couple hours', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(12); + }); + + it('"couple of hours" parses to +2 hours', () => { + const result = parseDateFromText('couple of hours', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(12); + }); + + it('"few hours" parses to +3 hours', () => { + const result = parseDateFromText('few hours', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(13); + }); + + it('"nxt week" parses like "next week"', () => { + const result = parseDateFromText('nxt week', now); + expect(result).not.toBeNull(); + }); + + it('"nxt monday" parses like "next monday"', () => { + const result = parseDateFromText('nxt monday', now); + expect(result).not.toBeNull(); + }); + }); + + describe('weekday bare hour defaults to PM', () => { + it('"monday at 3" parses to 3pm', () => { + const result = parseDateFromText('monday at 3', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(15); + }); + + it('"friday at 5" parses to 5pm', () => { + const result = parseDateFromText('friday at 5', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(17); + }); + + it('"monday at 9" stays 9am (hour >= 8)', () => { + const result = parseDateFromText('monday at 9', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toBe(9); + }); + }); }); describe('dot-delimited dates', () => { @@ -918,3 +1290,217 @@ describe('decimal duration parsing (only .5 allowed)', () => { expect(parseDateFromText('2.7 days', now)).toBeNull(); }); }); + +// ─── Multilingual / Localized Input Regressions ───────────────────────────── + +describe('generateDateSuggestions — localized input regressions', () => { + const arTranslations = { + UNITS: { + MINUTE: 'دقيقة', + MINUTES: 'دقائق', + HOUR: 'ساعة', + HOURS: 'ساعات', + DAY: 'يوم', + DAYS: 'أيام', + WEEK: 'أسبوع', + WEEKS: 'أسابيع', + MONTH: 'شهر', + MONTHS: 'أشهر', + YEAR: 'سنة', + YEARS: 'سنوات', + }, + HALF: 'نصف', + NEXT: 'القادم', + THIS: 'هذا', + AT: 'الساعة', + IN: 'في', + FROM_NOW: 'من الآن', + NEXT_YEAR: 'العام المقبل', + MERIDIEM: { AM: 'صباحاً', PM: 'مساءً' }, + RELATIVE: { + TOMORROW: 'غداً', + DAY_AFTER_TOMORROW: 'بعد غد', + NEXT_WEEK: 'الأسبوع القادم', + NEXT_MONTH: 'الشهر القادم', + THIS_WEEKEND: 'نهاية هذا الأسبوع', + NEXT_WEEKEND: 'نهاية الأسبوع القادم', + }, + TIME_OF_DAY: { + MORNING: 'صباحاً', + AFTERNOON: 'بعد الظهر', + EVENING: 'مساءً', + NIGHT: 'ليلاً', + NOON: 'ظهراً', + MIDNIGHT: 'منتصف الليل', + }, + WORD_NUMBERS: { + ONE: 'واحد', + TWO: 'اثنان', + THREE: 'ثلاثة', + FOUR: 'أربعة', + FIVE: 'خمسة', + SIX: 'ستة', + SEVEN: 'سبعة', + EIGHT: 'ثمانية', + NINE: 'تسعة', + TEN: 'عشرة', + TWELVE: 'اثنا عشر', + FIFTEEN: 'خمسة عشر', + TWENTY: 'عشرون', + THIRTY: 'ثلاثون', + }, + }; + + const hiTranslations = { + UNITS: { + MINUTE: 'मिनट', + MINUTES: 'मिनट', + HOUR: 'घंटा', + HOURS: 'घंटे', + DAY: 'दिन', + DAYS: 'दिन', + WEEK: 'सप्ताह', + WEEKS: 'सप्ताह', + MONTH: 'महीना', + MONTHS: 'महीने', + YEAR: 'साल', + YEARS: 'साल', + }, + HALF: 'आधा', + NEXT: 'अगला', + THIS: 'यह', + AT: 'बजे', + IN: 'में', + FROM_NOW: 'अब से', + NEXT_YEAR: 'अगले साल', + MERIDIEM: { AM: 'सुबह', PM: 'शाम' }, + RELATIVE: { + TOMORROW: 'कल', + DAY_AFTER_TOMORROW: 'परसों', + NEXT_WEEK: 'अगले सप्ताह', + NEXT_MONTH: 'अगले महीने', + THIS_WEEKEND: 'इस सप्ताहांत', + NEXT_WEEKEND: 'अगले सप्ताहांत', + }, + TIME_OF_DAY: { + MORNING: 'सुबह', + AFTERNOON: 'दोपहर', + EVENING: 'शाम', + NIGHT: 'रात', + NOON: 'दोपहर', + MIDNIGHT: 'आधी रात', + }, + WORD_NUMBERS: { + ONE: 'एक', + TWO: 'दो', + THREE: 'तीन', + FOUR: 'चार', + FIVE: 'पाँच', + SIX: 'छह', + SEVEN: 'सात', + EIGHT: 'आठ', + NINE: 'नौ', + TEN: 'दस', + TWELVE: 'बारह', + FIFTEEN: 'पंद्रह', + TWENTY: 'बीस', + THIRTY: 'तीस', + }, + }; + + describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => { + it('Arabic "غد" does not produce half-duration suggestions', () => { + const results = generateDateSuggestions('غد', now, { + translations: arTranslations, + locale: 'ar', + }); + const halfLabels = results.filter(r => /half/i.test(r.label)); + expect(halfLabels).toHaveLength(0); + }); + + it('Hindi "सु" does not produce half-duration suggestions', () => { + const results = generateDateSuggestions('सु', now, { + translations: hiTranslations, + locale: 'hi', + }); + const halfLabels = results.filter(r => /half/i.test(r.label)); + expect(halfLabels).toHaveLength(0); + }); + }); + + describe('P1: MERIDIEM vs TIME_OF_DAY — "tomorrow morning" must parse in locales where AM = morning', () => { + it('Arabic "غداً صباحاً" (tomorrow morning) parses correctly', () => { + const results = generateDateSuggestions('غداً صباحاً', now, { + translations: arTranslations, + locale: 'ar', + }); + expect(results.length).toBeGreaterThan(0); + const first = results[0]; + expect(first.date.getDate()).toBe(17); + expect(first.date.getHours()).toBe(9); + }); + + it('Hindi "कल सुबह" (tomorrow morning) parses correctly', () => { + const results = generateDateSuggestions('कल सुबह', now, { + translations: hiTranslations, + locale: 'hi', + }); + expect(results.length).toBeGreaterThan(0); + const first = results[0]; + expect(first.date.getDate()).toBe(17); + expect(first.date.getHours()).toBe(9); + }); + }); + + describe('basic localized parsing still works', () => { + it('Arabic "غداً" (tomorrow) parses to tomorrow 9am', () => { + const results = generateDateSuggestions('غداً', now, { + translations: arTranslations, + locale: 'ar', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + }); + + it('Hindi "कल" (tomorrow) parses to tomorrow', () => { + const results = generateDateSuggestions('कल', now, { + translations: hiTranslations, + locale: 'hi', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + }); + + it('Arabic "غداً،" (tomorrow with attached Arabic comma) parses correctly', () => { + const results = generateDateSuggestions('غداً،', now, { + translations: arTranslations, + locale: 'ar', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + }); + }); + + describe('localized Unicode digits', () => { + it('Arabic-Indic digits parse in time expressions', () => { + const results = generateDateSuggestions('غداً الساعة ١٢:٣٠', now, { + translations: arTranslations, + locale: 'ar', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + expect(results[0].date.getHours()).toBe(12); + expect(results[0].date.getMinutes()).toBe(30); + }); + + it('Devanagari digits parse in time-of-day expressions', () => { + const results = generateDateSuggestions('कल सुबह ६', now, { + translations: hiTranslations, + locale: 'hi', + }); + expect(results.length).toBeGreaterThan(0); + expect(results[0].date.getDate()).toBe(17); + expect(results[0].date.getHours()).toBe(6); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/ar/index.js b/app/javascript/dashboard/i18n/locale/ar/index.js index 213387d0c..a89b17bd1 100644 --- a/app/javascript/dashboard/i18n/locale/ar/index.js +++ b/app/javascript/dashboard/i18n/locale/ar/index.js @@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -72,6 +73,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/ar/snooze.json b/app/javascript/dashboard/i18n/locale/ar/snooze.json new file mode 100644 index 000000000..ce038aa13 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/ar/snooze.json @@ -0,0 +1,61 @@ +{ + "SNOOZE_PARSER": { + "UNITS": { + "MINUTE": "دقيقة", + "MINUTES": "دقائق", + "HOUR": "ساعة", + "HOURS": "ساعات", + "DAY": "يوم", + "DAYS": "أيام", + "WEEK": "أسبوع", + "WEEKS": "أسابيع", + "MONTH": "شهر", + "MONTHS": "أشهر", + "YEAR": "سنة", + "YEARS": "سنوات" + }, + "HALF": "نصف", + "NEXT": "القادم", + "THIS": "هذا", + "AT": "الساعة", + "IN": "في", + "FROM_NOW": "من الآن", + "NEXT_YEAR": "العام المقبل", + "MERIDIEM": { + "AM": "صباحاً", + "PM": "مساءً" + }, + "RELATIVE": { + "TOMORROW": "غداً", + "DAY_AFTER_TOMORROW": "بعد غد", + "NEXT_WEEK": "الأسبوع القادم", + "NEXT_MONTH": "الشهر القادم", + "THIS_WEEKEND": "نهاية هذا الأسبوع", + "NEXT_WEEKEND": "نهاية الأسبوع القادم" + }, + "TIME_OF_DAY": { + "MORNING": "صباحاً", + "AFTERNOON": "بعد الظهر", + "EVENING": "مساءً", + "NIGHT": "ليلاً", + "NOON": "ظهراً", + "MIDNIGHT": "منتصف الليل" + }, + "WORD_NUMBERS": { + "ONE": "واحد", + "TWO": "اثنان", + "THREE": "ثلاثة", + "FOUR": "أربعة", + "FIVE": "خمسة", + "SIX": "ستة", + "SEVEN": "سبعة", + "EIGHT": "ثمانية", + "NINE": "تسعة", + "TEN": "عشرة", + "TWELVE": "اثنا عشر", + "FIFTEEN": "خمسة عشر", + "TWENTY": "عشرون", + "THIRTY": "ثلاثون" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index a55f54165..261ca85e6 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; import contentTemplates from './contentTemplates.json'; @@ -77,6 +78,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, ...contentTemplates, diff --git a/app/javascript/dashboard/i18n/locale/en/snooze.json b/app/javascript/dashboard/i18n/locale/en/snooze.json new file mode 100644 index 000000000..7585e6ea0 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/snooze.json @@ -0,0 +1,61 @@ +{ + "SNOOZE_PARSER": { + "UNITS": { + "MINUTE": "minute", + "MINUTES": "minutes", + "HOUR": "hour", + "HOURS": "hours", + "DAY": "day", + "DAYS": "days", + "WEEK": "week", + "WEEKS": "weeks", + "MONTH": "month", + "MONTHS": "months", + "YEAR": "year", + "YEARS": "years" + }, + "HALF": "half", + "NEXT": "next", + "THIS": "this", + "AT": "at", + "IN": "in", + "FROM_NOW": "from now", + "NEXT_YEAR": "next year", + "MERIDIEM": { + "AM": "am", + "PM": "pm" + }, + "RELATIVE": { + "TOMORROW": "tomorrow", + "DAY_AFTER_TOMORROW": "day after tomorrow", + "NEXT_WEEK": "next week", + "NEXT_MONTH": "next month", + "THIS_WEEKEND": "this weekend", + "NEXT_WEEKEND": "next weekend" + }, + "TIME_OF_DAY": { + "MORNING": "morning", + "AFTERNOON": "afternoon", + "EVENING": "evening", + "NIGHT": "night", + "NOON": "noon", + "MIDNIGHT": "midnight" + }, + "WORD_NUMBERS": { + "ONE": "one", + "TWO": "two", + "THREE": "three", + "FOUR": "four", + "FIVE": "five", + "SIX": "six", + "SEVEN": "seven", + "EIGHT": "eight", + "NINE": "nine", + "TEN": "ten", + "TWELVE": "twelve", + "FIFTEEN": "fifteen", + "TWENTY": "twenty", + "THIRTY": "thirty" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/es/index.js b/app/javascript/dashboard/i18n/locale/es/index.js index 213387d0c..a89b17bd1 100644 --- a/app/javascript/dashboard/i18n/locale/es/index.js +++ b/app/javascript/dashboard/i18n/locale/es/index.js @@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -72,6 +73,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/es/snooze.json b/app/javascript/dashboard/i18n/locale/es/snooze.json new file mode 100644 index 000000000..4f3f6ab11 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/es/snooze.json @@ -0,0 +1,61 @@ +{ + "SNOOZE_PARSER": { + "UNITS": { + "MINUTE": "minuto", + "MINUTES": "minutos", + "HOUR": "hora", + "HOURS": "horas", + "DAY": "día", + "DAYS": "días", + "WEEK": "semana", + "WEEKS": "semanas", + "MONTH": "mes", + "MONTHS": "meses", + "YEAR": "año", + "YEARS": "años" + }, + "HALF": "media", + "NEXT": "próximo", + "THIS": "este", + "AT": "a las", + "IN": "en", + "FROM_NOW": "a partir de ahora", + "NEXT_YEAR": "el próximo año", + "MERIDIEM": { + "AM": "am", + "PM": "pm" + }, + "RELATIVE": { + "TOMORROW": "mañana", + "DAY_AFTER_TOMORROW": "pasado mañana", + "NEXT_WEEK": "la próxima semana", + "NEXT_MONTH": "el próximo mes", + "THIS_WEEKEND": "este fin de semana", + "NEXT_WEEKEND": "el próximo fin de semana" + }, + "TIME_OF_DAY": { + "MORNING": "mañana", + "AFTERNOON": "tarde", + "EVENING": "noche", + "NIGHT": "noche", + "NOON": "mediodía", + "MIDNIGHT": "medianoche" + }, + "WORD_NUMBERS": { + "ONE": "uno", + "TWO": "dos", + "THREE": "tres", + "FOUR": "cuatro", + "FIVE": "cinco", + "SIX": "seis", + "SEVEN": "siete", + "EIGHT": "ocho", + "NINE": "nueve", + "TEN": "diez", + "TWELVE": "doce", + "FIFTEEN": "quince", + "TWENTY": "veinte", + "THIRTY": "treinta" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/ml/index.js b/app/javascript/dashboard/i18n/locale/ml/index.js index 213387d0c..a89b17bd1 100644 --- a/app/javascript/dashboard/i18n/locale/ml/index.js +++ b/app/javascript/dashboard/i18n/locale/ml/index.js @@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -72,6 +73,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/ml/snooze.json b/app/javascript/dashboard/i18n/locale/ml/snooze.json new file mode 100644 index 000000000..07778a272 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/ml/snooze.json @@ -0,0 +1,58 @@ +{ + "SNOOZE_PARSER": { + "UNITS": { + "MINUTE": "മിനിറ്റ്", + "MINUTES": "മിനിറ്റ്", + "HOUR": "മണിക്കൂർ", + "HOURS": "മണിക്കൂർ", + "DAY": "ദിവസം", + "DAYS": "ദിവസം", + "WEEK": "ആഴ്ച", + "WEEKS": "ആഴ്ച", + "MONTH": "മാസം", + "MONTHS": "മാസം", + "YEAR": "വർഷം", + "YEARS": "വർഷം" + }, + "HALF": "അര", + "NEXT": "അടുത്ത", + "THIS": "ഈ", + "AT": "സമയം", + "IN": "കഴിഞ്ഞ്", + "FROM_NOW": "ഇപ്പോൾ മുതൽ", + "NEXT_YEAR": "അടുത്ത വർഷം", + "MERIDIEM": { "AM": "രാവിലെ", "PM": "വൈകുന്നേരം" }, + "RELATIVE": { + "TOMORROW": "നാളെ", + "DAY_AFTER_TOMORROW": "മറ്റന്നാൾ", + "NEXT_WEEK": "അടുത്ത ആഴ്ച", + "NEXT_MONTH": "അടുത്ത മാസം", + "THIS_WEEKEND": "ഈ വാരാന്ത്യം", + "NEXT_WEEKEND": "അടുത്ത വാരാന്ത്യം" + }, + "TIME_OF_DAY": { + "MORNING": "രാവിലെ", + "AFTERNOON": "ഉച്ചയ്ക്ക്", + "EVENING": "വൈകുന്നേരം", + "NIGHT": "രാത്രി", + "NOON": "ഉച്ച", + "MIDNIGHT": "അർദ്ധരാത്രി" + }, + "WORD_NUMBERS": { + "ONE": "ഒന്ന്", + "TWO": "രണ്ട്", + "THREE": "മൂന്ന്", + "FOUR": "നാല്", + "FIVE": "അഞ്ച്", + "SIX": "ആറ്", + "SEVEN": "ഏഴ്", + "EIGHT": "എട്ട്", + "NINE": "ഒൻപത്", + "TEN": "പത്ത്", + "TWELVE": "പന്ത്രണ്ട്", + "FIFTEEN": "പതിനഞ്ച്", + "TWENTY": "ഇരുപത്", + "THIRTY": "മുപ്പത്" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/pt/index.js b/app/javascript/dashboard/i18n/locale/pt/index.js index 213387d0c..a89b17bd1 100644 --- a/app/javascript/dashboard/i18n/locale/pt/index.js +++ b/app/javascript/dashboard/i18n/locale/pt/index.js @@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json'; import settings from './settings.json'; import signup from './signup.json'; import sla from './sla.json'; +import snooze from './snooze.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; @@ -72,6 +73,7 @@ export default { ...settings, ...signup, ...sla, + ...snooze, ...teamsSettings, ...whatsappTemplates, }; diff --git a/app/javascript/dashboard/i18n/locale/pt/snooze.json b/app/javascript/dashboard/i18n/locale/pt/snooze.json new file mode 100644 index 000000000..34f4426aa --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/pt/snooze.json @@ -0,0 +1,61 @@ +{ + "SNOOZE_PARSER": { + "UNITS": { + "MINUTE": "minuto", + "MINUTES": "minutos", + "HOUR": "hora", + "HOURS": "horas", + "DAY": "dia", + "DAYS": "dias", + "WEEK": "semana", + "WEEKS": "semanas", + "MONTH": "mês", + "MONTHS": "meses", + "YEAR": "ano", + "YEARS": "anos" + }, + "HALF": "meia", + "NEXT": "próximo", + "THIS": "este", + "AT": "às", + "IN": "em", + "FROM_NOW": "a partir de agora", + "NEXT_YEAR": "próximo ano", + "MERIDIEM": { + "AM": "am", + "PM": "pm" + }, + "RELATIVE": { + "TOMORROW": "amanhã", + "DAY_AFTER_TOMORROW": "depois de amanhã", + "NEXT_WEEK": "próxima semana", + "NEXT_MONTH": "próximo mês", + "THIS_WEEKEND": "este fim de semana", + "NEXT_WEEKEND": "próximo fim de semana" + }, + "TIME_OF_DAY": { + "MORNING": "manhã", + "AFTERNOON": "tarde", + "EVENING": "noite", + "NIGHT": "noite", + "NOON": "meio-dia", + "MIDNIGHT": "meia-noite" + }, + "WORD_NUMBERS": { + "ONE": "um", + "TWO": "dois", + "THREE": "três", + "FOUR": "quatro", + "FIVE": "cinco", + "SIX": "seis", + "SEVEN": "sete", + "EIGHT": "oito", + "NINE": "nove", + "TEN": "dez", + "TWELVE": "doze", + "FIFTEEN": "quinze", + "TWENTY": "vinte", + "THIRTY": "trinta" + } + } +} diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue index 11d5a5a32..65d98e65c 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue @@ -4,6 +4,7 @@ import { ref, computed, watchEffect, onMounted } from 'vue'; import { useStore } from 'dashboard/composables/store'; import { useTrack } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; +import { useLocale } from 'shared/composables/useLocale'; import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys'; import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys'; import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys'; @@ -21,7 +22,8 @@ import { import { emitter } from 'shared/helpers/mitt'; const store = useStore(); -const { t } = useI18n(); +const { t, tm } = useI18n(); +const { resolvedLocale } = useLocale(); const ninjakeys = ref(null); @@ -43,6 +45,8 @@ const SNOOZE_PARENT_IDS = [ ]; const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_'; +const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME; + const dynamicSnoozeActions = ref([]); const currentCommandRoot = ref(null); @@ -77,8 +81,17 @@ const SNOOZE_SECTION_MAP = { bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS', }; +const snoozeTranslations = computed(() => { + const raw = tm('SNOOZE_PARSER'); + if (!raw || typeof raw !== 'object') return {}; + return JSON.parse(JSON.stringify(raw)); +}); + const buildDynamicSnoozeActions = (search, parentId) => { - const suggestions = generateSnoozeSuggestions(search); + const suggestions = generateSnoozeSuggestions(search, new Date(), { + translations: snoozeTranslations.value, + locale: resolvedLocale.value, + }); if (!suggestions.length) return []; const busEvent = SNOOZE_EVENT_MAP[parentId]; @@ -98,6 +111,11 @@ const buildDynamicSnoozeActions = (search, parentId) => { })); }; +const resetSnoozeState = () => { + currentCommandRoot.value = null; + dynamicSnoozeActions.value = []; +}; + const patchNinjaKeysOpenClose = el => { if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') { return; @@ -114,8 +132,7 @@ const patchNinjaKeysOpenClose = el => { }; el.close = (...args) => { - currentCommandRoot.value = null; - dynamicSnoozeActions.value = []; + resetSnoozeState(); return originalClose(...args); }; }; @@ -126,23 +143,14 @@ const onSelected = item => { 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) - if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { - selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME; - } else { - selectedSnoozeType.value = null; - } + + selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null; if (Array.isArray(children) && children.length) { currentCommandRoot.value = id; } - useTrack(GENERAL_EVENTS.COMMAND_BAR, { - section, - action: title, - }); - + useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title }); setCommandBarData(); }; @@ -176,15 +184,10 @@ const onCommandBarChange = item => { }; 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 - if ( - selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME - ) { + if (selectedSnoozeType.value !== CUSTOM_SNOOZE) { store.dispatch('setContextMenuChatId', null); } - currentCommandRoot.value = null; - dynamicSnoozeActions.value = []; + resetSnoozeState(); }; watchEffect(() => {