From 88587b1ccbfc8e957c035598b213ed46d8f6308a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:50:22 +0530 Subject: [PATCH] feat: Add natural language date parser for snooze functionality (#13587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR introduces a custom, lightweight natural-language date parser (dependency-free except for date-fns) to power snooze actions via the command bar (e.g., “Remind me tomorrow at 6am”). It also adds support for multi-language searches.
Supported Formats ## Snooze Date Parser — Supported Input Formats ## 1. Durations Specify an amount of time from now. ### Basic - `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten year` - `in 2 hours` · `in 30 minutes` · `in a week` · `in a month` - `5 minutes from now` · `a week from now` · `two weeks from now` ### Half / fractional - `half hour` · `half day` · `half week` · `half month` - `in half a day` · `in half an hour` · `in half a week` - `one and a half hours` · `in one and a half hours` - `1.5 hours` · `2.5 days` ### Compound - `1 hour and 30 minutes` · `2 hours and 15 minutes` - `2 days at 3pm` · `1 week at 9am` ### Shorthand (no spaces) - `2h` · `30m` · `1h30m` · `2h15m` - `1h30minutes` · `2hr15min` · `1hour30min` ### Informal quantities - `couple hours` · `a couple of days` · `in a couple hours` - `a few minutes` · `in a few hours` · `in a few days` - `fortnight` · `in a fortnight` _(= 2 weeks)_ ### Trailing "later" - `2 days later` · `a week later` · `month later` ### Prefix words (`after` / `within`) - `after 2 hours` · `after 3 days` · `after ten year` - `within a week` · `within 2 hours` ### Recognised word-numbers `a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` · `sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3) --- ## 2. Relative Days - `today` · `tonight` · `tomorrow` - `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` · `tomorrow night` - `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm` - `tomorrow same time` · `same time tomorrow` - `day after tomorrow` · `the day after tomorrow` · `day after tomorrow at 2pm` - `later today` · `later this afternoon` · `later this evening` --- ## 3. Weekdays - `monday` · `friday` · `wed` · `thu` - `friday at 3pm` · `monday 9am` · `wednesday 14:30` - `monday morning` · `friday afternoon` · `wednesday evening` - `monday morning 6` · `friday evening 7` - `this friday` · `upcoming monday` · `coming friday` - `same time friday` · `same time wednesday` --- ## 4. "Next" Patterns - `next hour` · `next week` · `next month` · `next year` - `next week at 2pm` · `next month at 9am` - `next monday` · `next friday` · `next friday at 3pm` - `next monday morning` · `next friday evening` - `monday of next week` · `next week monday` - `next january` · `next december` - `next business day` · `next working day` --- ## 5. Time of Day - `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight` - `this morning` · `this afternoon` · `this evening` - `early morning` · `late evening` · `late night` - `morning at 8am` · `evening 6pm` · `afternoon 2pm` - `eod` · `end of day` · `end of the day` --- ## 6. Standalone Time - **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am` - **24-hour:** `14:30` · `at 14:30` --- ## 7. Named Dates (Month + Day) - `jan 15` · `january 15` · `march 20` · `dec 25` - `jan 1st` · `march 3rd` · `april 2nd` · `december 31st` - `15 march` · `25 dec` _(reversed order)_ - `jan 15 2025` · `dec 25 2025` · `march 20 next year` - `jan 15 at 2pm` · `march 5 at 2pm` - `december 2025` · `january 2024` _(month + year only)_ --- ## 8. Month + Ordinal Patterns Target a specific week or day within a month. ### Week of month - `april first week` · `july 2nd week` · `feb 3rd week` - `first week of april` · `2nd week of july` ### Day of month - `april first day` · `march second day` · `march 5th day` - `third day of march` · `5th day of jan at 2pm` ### Supported ordinals - **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for weeks) - **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh` `eighth` `ninth` `tenth` --- ## 9. Formal / Numeric Dates - **ISO:** `2025-01-15` - **Slash (M/D/Y):** `01/15/2025` - **Dash (D-M-Y):** `15-01-2025` - **Dot (D.M.Y):** `15.01.2025` - Any of the above **+ time:** `2025-01-15 at 3pm` --- ## 10. Special Phrases - `this weekend` · `weekend` · `next weekend` - `end of week` · `end of month` - `end of next week` · `end of next month` - `beginning of next week` · `start of next week` - `beginning of next month` --- ## 11. Noise / Filler Stripping The parser silently removes conversational prefixes so all of these work exactly the same as the bare expression: ``` snooze for 2 hours → 2 hours remind me tomorrow → tomorrow please snooze until friday → friday can you set a reminder for next week → next week schedule this for jan 15 → jan 15 postpone to next monday → next monday defer for 2 days → 2 days delay it by 1 hour → 1 hour ``` ### Recognised filler verbs / prefixes `snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder` · `schedule` · `postpone` · `defer` · `delay` · `push` ### Recognised prepositions (stripped) `on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after` · `within` ### Typo corrections `tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next` --- ## 12. Multi-Language Support The parser supports localised input via translations in `snooze.json`. ### Translatable token categories - **Units:** minute, hour, day, week, month, year _(singular + plural)_ - **Relative days:** tomorrow, day after tomorrow, next week / month, this / next weekend - **Time of day:** morning, afternoon, evening, night, noon, midnight - **Word numbers:** one through ten, twelve, fifteen, twenty, thirty - **Ordinals:** first through fifth - **Structural words:** at, in, of, after, week, day, from now, next year - **Meridiem:** am, pm ### Auto-detected from locale Weekday names and month names are resolved automatically via `Intl.DateTimeFormat` for the user's locale — no manual translation needed.
## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screenshots** image image image image image image image image image image image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../conversationBulkActions/Index.vue | 2 + .../helper/AnalyticsHelper/events.js | 4 + .../helper/snoozeDateParser/index.js | 12 + .../helper/snoozeDateParser/localization.js | 408 ++++ .../helper/snoozeDateParser/parser.js | 806 ++++++++ .../helper/snoozeDateParser/suggestions.js | 101 + .../helper/snoozeDateParser/tokenMaps.js | 395 ++++ .../dashboard/helper/snoozeHelpers.js | 142 +- .../helper/specs/snoozeDateParser.spec.js | 1761 +++++++++++++++++ .../helper/specs/snoozeHelpers.spec.js | 73 +- .../i18n/locale/en/generalSettings.json | 1 + .../dashboard/i18n/locale/en/index.js | 2 + .../dashboard/i18n/locale/en/snooze.json | 72 + .../commands/CmdBarConversationSnooze.vue | 2 + .../routes/dashboard/commands/commandbar.vue | 195 +- .../inbox/components/InboxItemHeader.vue | 2 + 16 files changed, 3902 insertions(+), 76 deletions(-) create mode 100644 app/javascript/dashboard/helper/snoozeDateParser/index.js create mode 100644 app/javascript/dashboard/helper/snoozeDateParser/localization.js create mode 100644 app/javascript/dashboard/helper/snoozeDateParser/parser.js create mode 100644 app/javascript/dashboard/helper/snoozeDateParser/suggestions.js create mode 100644 app/javascript/dashboard/helper/snoozeDateParser/tokenMaps.js create mode 100644 app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js create mode 100644 app/javascript/dashboard/i18n/locale/en/snooze.json 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/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index c9fefb129..97b7931fb 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({ USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response', }); +export const SNOOZE_EVENTS = Object.freeze({ + NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input', +}); + export const GENERAL_EVENTS = Object.freeze({ COMMAND_BAR: 'Used commandbar', }); diff --git a/app/javascript/dashboard/helper/snoozeDateParser/index.js b/app/javascript/dashboard/helper/snoozeDateParser/index.js new file mode 100644 index 000000000..bbdd01326 --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser/index.js @@ -0,0 +1,12 @@ +/** + * snoozeDateParser — Natural language date/time parser for snooze. + * + * Barrel re-export from submodules: + * - parser.js: core parsing engine (parseDateFromText) + * - localization.js: multilingual suggestion generator (generateDateSuggestions) + * - suggestions.js: compositional suggestion engine + * - tokenMaps.js: shared token maps and utility functions + */ + +export { parseDateFromText } from './parser'; +export { generateDateSuggestions } from './localization'; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/localization.js b/app/javascript/dashboard/helper/snoozeDateParser/localization.js new file mode 100644 index 000000000..461fc96e9 --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser/localization.js @@ -0,0 +1,408 @@ +/** + * Handles non-English input and generates the final suggestion list. + * Translates localized words to English before parsing, then converts + * suggestion labels back to the user's language for display. + */ + +import { + WEEKDAY_MAP, + MONTH_MAP, + UNIT_MAP, + WORD_NUMBER_MAP, + RELATIVE_DAY_MAP, + TIME_OF_DAY_MAP, + sanitize, + stripNoise, + normalizeDigits, +} from './tokenMaps'; + +import { parseDateFromText } from './parser'; +import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions'; + +// ─── English Reference Data ───────────────────────────────────────────────── + +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', + }, + ORDINALS: { + FIRST: 'first', + SECOND: 'second', + THIRD: 'third', + FOURTH: 'fourth', + FIFTH: 'fifth', + }, + MERIDIEM: { AM: 'am', PM: 'pm' }, + HALF: 'half', + NEXT: 'next', + THIS: 'this', + AT: 'at', + IN: 'in', + OF: 'of', + AFTER: 'after', + WEEK: 'week', + DAY: 'day', + 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', + 'week', + 'day', + 'first', + 'second', + 'third', + 'fourth', + 'fifth', +]; + +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, +]); + +// ─── Regex for token replacement ──────────────────────────────────────────── + +const MONTH_NAMES = Object.keys(MONTH_MAP).join('|'); +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', +}; + +// ─── Translation Cache ────────────────────────────────────────────────────── + +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', + 'ORDINALS', + 'MERIDIEM', +]; +const SINGLE_KEYS = [ + 'HALF', + 'NEXT', + 'THIS', + 'AT', + 'IN', + 'OF', + 'AFTER', + 'WEEK', + 'DAY', + 'FROM_NOW', + 'NEXT_YEAR', +]; + +/** Create a string key from translations so we can cache results. */ +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('|'); +}; + +/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */ +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(); + const key = `${l}\0${e}`; + if (l && e && l !== e && !seen.has(key)) { + seen.add(key); + 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; +}; + +/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */ +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; +}; + +// ─── Token Replacement ────────────────────────────────────────────────────── + +const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** Swap localized words for their English versions in the text. */ +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; +}; + +/** Drop any words the parser wouldn't understand (keeps English words and numbers). */ +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(); + +/** Move "next year" to the right spot so the parser can read it (after the month, before time). */ +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; +}; + +/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */ +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)); +}; + +/** Convert English words back to the user's language for display. */ +const reverseTokens = (text, pairs) => + pairs.reduce( + (r, [local, en]) => + r.replace( + new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'), + local + ), + text + ); + +// ─── Main Suggestion Generator ────────────────────────────────────────────── + +/** + * Generate snooze suggestions from what the user has typed so far. + * Works with any language if translations are provided. Returns up to 5 + * unique results, each with a label, date, and unix timestamp. + * + * @param {string} text - what the user typed + * @param {Date} [referenceDate] - treat as "now" (defaults to current time) + * @param {{ translations?: object, locale?: string }} [options] - i18n config + * @returns {Array<{ label: string, date: Date, unix: number }>} + */ +export const generateDateSuggestions = ( + text, + referenceDate = new Date(), + { translations, locale } = {} +) => { + if (!text || typeof text !== 'string') return []; + const normalized = sanitize(text); + if (!normalized) return []; + + const stripped = stripNoise(normalized); + const pairs = + locale && locale !== 'en' + ? buildReplacementPairs(translations, locale) + : []; + + // Try English parse first, then translated parse if we have locale pairs. + // This avoids the problem where a single overlapping word (e.g. "in" in German) + // would skip token translation entirely. + const directParse = parseDateFromText(stripped, referenceDate); + + const translated = pairs.length ? replaceTokens(normalized, pairs) : null; + const translatedParse = + translated && translated !== stripped + ? parseDateFromText(translated, referenceDate) + : null; + + // Prefer direct English parse; fall back to translated parse + const useTranslated = !directParse && !!translatedParse; + const englishInput = useTranslated ? translated : stripped; + + const seen = new Set(); + const results = []; + + const exact = directParse || translatedParse; + if (exact) { + seen.add(exact.unix); + const exactLabel = + useTranslated && pairs.length + ? reverseTokens(englishInput, pairs) + : englishInput; + results.push({ label: exactLabel, query: englishInput, ...exact }); + } + + 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); + const label = + useTranslated && pairs.length + ? reverseTokens(candidate, pairs) + : candidate; + results.push({ label, query: candidate, ...result }); + } + return false; + }); + + return results; +}; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/parser.js b/app/javascript/dashboard/helper/snoozeDateParser/parser.js new file mode 100644 index 000000000..43d401cca --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser/parser.js @@ -0,0 +1,806 @@ +/** + * Parses natural language text into a future date. + * + * Flow: clean the input → try each matcher in order → return the first future date. + * The MATCHERS order matters — see the comment above the array. + */ + +import { + add, + startOfDay, + getDay, + isSaturday, + isSunday, + nextFriday, + nextSaturday, + getUnixTime, + isValid, + startOfWeek, + addWeeks, + isAfter, + isBefore, + endOfMonth, +} from 'date-fns'; + +import { + WEEKDAY_MAP, + MONTH_MAP, + RELATIVE_DAY_MAP, + UNIT_MAP, + WORD_NUMBER_MAP, + NEXT_WEEKDAY_FN, + TIME_OF_DAY_MAP, + TOD_HOUR_RANGE, + HALF_UNIT_DURATIONS, + sanitize, + stripNoise, + parseNumber, + parseTimeString, + applyTimeToDate, + applyTimeOrDefault, + strictDate, + futureOrNextYear, + ensureFutureOrNextDay, + inferHoursFromTOD, + addFractionalSafe, +} from './tokenMaps'; + +// ─── Regex Fragments (derived from maps) ──────────────────────────────────── + +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+(?:\\.5)?|${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}))?'; + +const ORDINAL_MAP = { + first: 1, + second: 2, + third: 3, + fourth: 4, + fifth: 5, + sixth: 6, + seventh: 7, + eighth: 8, + ninth: 9, + tenth: 10, +}; +const parseOrdinal = str => { + if (ORDINAL_MAP[str]) return ORDINAL_MAP[str]; + return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null; +}; +const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|'); +const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`; + +// ─── Pre-compiled Regexes ─────────────────────────────────────────────────── + +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)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$` +); +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})$`); +// "april first week", "first week of april", "march 2nd day", "5th day of jan" +const MONTH_ORDINAL_RE = new RegExp( + `^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$` +); +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 END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/; +const START_OF_NEXT_RE = + /^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/; +const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/; +const EARLY_LATE_TOD_RE = new RegExp( + `^(early|late)\\s+(${TIME_OF_DAY_NAMES})$` +); +const ONE_AND_HALF_RE = new RegExp( + `^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$` +); +const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/; + +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}` +); + +// ─── Pattern Matchers ─────────────────────────────────────────────────────── + +/** Read amount and unit from a regex match, then add to now. */ +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); +}; + +/** Handle "in 2 hours", "half day", "3h30m", "5 min from 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; + } + + // "one and a half hours", "an hour and a half" + const oneHalf = text.match(ONE_AND_HALF_RE); + if (oneHalf) { + const unit = UNIT_MAP[oneHalf[1]] || 'hours'; + return addFractionalSafe(now, unit, 1.5); + } + + 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) + ); +}; + +/** Set time on a day offset. If the result is already past, move to the next day. */ +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); +}; + +/** Handle "today", "tonight", "tomorrow" with optional time. */ +const matchRelativeDay = (text, now) => { + const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE); + if (dayOnlyMatch) { + const key = dayOnlyMatch[1]; + const offset = RELATIVE_DAY_MAP[key]; + if (key === 'tonight' || key === 'tonite') { + return ensureFutureOrNextDay( + applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0), + now + ); + } + if (offset === 1) { + return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0); + } + return add(now, { hours: 1 }); + } + + 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 { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]]; + return applyTimeWithRollover( + RELATIVE_DAY_MAP[dayTodMatch[1]], + hours, + minutes, + now + ); + } + + const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE); + if (dayAtTimeMatch) { + const [, dayKey, timeRaw] = dayAtTimeMatch; + const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw); + const time = bare + ? inferHoursFromTOD('tonight', ...timeRaw.split(':')) + : parseTimeString(timeRaw); + if (!time) return null; + return applyTimeWithRollover( + RELATIVE_DAY_MAP[dayKey], + time.hours, + time.minutes, + now + ); + } + + const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE); + if (sameTimeMatch) { + const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]]; + if (offset <= 0) return null; + return applyTimeToDate( + add(startOfDay(now), { days: offset }), + now.getHours(), + now.getMinutes() + ); + } + + return null; +}; + +/** Find the given weekday in next week (not this week). */ +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; +}; + +/** Handle "next friday", "next week", "next month", "next january", etc. */ +const matchNextPattern = (text, now) => { + 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 applyTimeOrDefault(base, nextUnitMatch[2]); + } + const base = add(startOfDay(now), { [`${unit}s`]: 1 }); + return applyTimeOrDefault(base, nextUnitMatch[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]); + } + + // "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); + } + + // "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; +}; + +/** Find the next occurrence of a weekday, with optional time. */ +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); +}; + +/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */ +const matchWeekday = (text, now) => { + const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE); + 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()); + } + + // "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); + } + + // "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); +}; + +/** Handle a standalone time like "3pm", "14:30", "at 9am". */ +const matchTimeOnly = (text, now) => { + 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; + return ensureFutureOrNextDay( + applyTimeToDate(now, time.hours, time.minutes), + now + ); +}; + +/** Handle "morning", "evening 6pm", "eod", "this afternoon". */ +const matchTimeOfDay = (text, now) => { + const todWithTime = text.match(TOD_WITH_TIME_RE); + if (todWithTime) { + 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; + return ensureFutureOrNextDay( + applyTimeToDate(now, time.hours, time.minutes), + now + ); + } + + // "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00 + const earlyLate = text.match(EARLY_LATE_TOD_RE); + if (earlyLate) { + const tod = TIME_OF_DAY_MAP[earlyLate[2]]; + if (!tod) return null; + const shift = earlyLate[1] === 'early' ? -1 : 2; + return ensureFutureOrNextDay( + applyTimeToDate(now, tod.hours + shift, 0), + now + ); + } + + const match = text.match(TOD_PLAIN_RE); + if (!match) return null; + + const key = text + .replace(/^(?:later|in)\s+/, '') + .replace(/^(?:this|the)\s+/, '') + .trim(); + const tod = TIME_OF_DAY_MAP[key]; + if (!tod) return null; + return ensureFutureOrNextDay( + applyTimeToDate(now, tod.hours, tod.minutes), + now + ); +}; + +/** Turn month + day + optional year into a future date. */ +const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => { + let year = now.getFullYear(); + if (yearStr && /next\s+year/i.test(yearStr)) { + year += 1; + } else if (yearStr) { + year = parseInt(yearStr, 10); + } + if (yearStr) { + const base = strictDate(year, month, day); + if (!base) return null; + const date = applyTimeOrDefault(base, timeStr); + return date && isAfter(date, now) ? date : null; + } + return futureOrNextYear(year, month, day, timeStr, now); +}; + +/** Handle "jan 15", "15 march", "december 2025". */ +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 + ); + } + + 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 + ); + } + + 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; + } + + // "april first week", "first week of april", "march 2nd day", etc. + const mo = text.match(MONTH_ORDINAL_RE); + if (mo) { + // Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time + const monthIdx = MONTH_MAP[mo[1] || mo[6]]; + const num = parseOrdinal(mo[2] || mo[4]); + const unit = mo[3] || mo[5]; + const timeStr = mo[7]; + + if (!num || num < 1) return null; + + if (unit === 'day') { + if (num > 31) return null; + return resolveAbsoluteDate(monthIdx, num, null, timeStr, now); + } + + // unit === 'week' + if (num > 5) return null; + const weekStartDay = (num - 1) * 7 + 1; + let year = now.getFullYear(); + if ( + monthIdx < now.getMonth() || + (monthIdx === now.getMonth() && now.getDate() > weekStartDay) + ) { + year += 1; + } + // Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap) + const daysInMonth = new Date(year, monthIdx + 1, 0).getDate(); + if (weekStartDay > daysInMonth) return null; + const d = new Date(year, monthIdx, weekStartDay); + if (!isValid(d)) return null; + const result = applyTimeOrDefault(d, timeStr); + return result && isAfter(result, now) ? result : null; + } + + return null; +}; + +/** Build a date from year/month/day numbers, with optional time. */ +const buildDateWithOptionalTime = (year, month, day, timeStr) => { + const date = strictDate(year, month, day); + if (!date) return null; + return applyTimeOrDefault(date, timeStr); +}; + +// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback: +// dayFirst=false (slash M/D/Y) → month first +// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first +const disambiguateDayMonth = (a, b, dayFirst = false) => { + if (a > 12) return { day: a, month: b - 1 }; + if (b > 12) return { month: a - 1, day: b }; + return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b }; +}; + +/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */ +const matchFormalDate = (text, now) => { + const ensureFuture = date => (date && isAfter(date, now) ? date : null); + + const isoMatch = text.match(ISO_DATE_RE); + if (isoMatch) { + return ensureFuture( + buildDateWithOptionalTime( + parseInt(isoMatch[1], 10), + parseInt(isoMatch[2], 10) - 1, + parseInt(isoMatch[3], 10), + isoMatch[4] + ) + ); + } + + // Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European) + const formats = [ + { re: SLASH_DATE_RE, dayFirst: false }, + { re: DASH_DATE_RE, dayFirst: true }, + { re: DOT_DATE_RE, dayFirst: true }, + ]; + let result = null; + formats.some(({ re, dayFirst }) => { + const m = text.match(re); + if (!m) return false; + const { month, day } = disambiguateDayMonth( + parseInt(m[1], 10), + parseInt(m[2], 10), + dayFirst + ); + result = ensureFuture( + buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4]) + ); + return true; + }); + return result; +}; + +/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */ +const matchSpecial = (text, now) => { + const dat = text.match(DAY_AFTER_TOMORROW_RE); + if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]); + + 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') { + const eom = applyTimeToDate(endOfMonth(now), 17, 0); + if (isAfter(eom, now)) return eom; + return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0); + } + } + + // "end of next week", "end of next month" + const eofNext = text.match(END_OF_NEXT_RE); + if (eofNext) { + if (eofNext[1] === 'week') { + const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }); + return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0); + } + if (eofNext[1] === 'month') { + return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0); + } + } + + // "beginning of next week", "start of next month" + const sofNext = text.match(START_OF_NEXT_RE); + if (sofNext) { + if (sofNext[1] === 'week') { + return applyTimeToDate( + startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }), + 9, + 0 + ); + } + if (sofNext[1] === 'month') { + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + return applyTimeToDate(nextMonth, 9, 0); + } + } + + // "next business day", "next working day" + if (NEXT_BUSINESS_DAY_RE.test(text)) { + let d = add(startOfDay(now), { days: 1 }); + while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 }); + return applyTimeToDate(d, 9, 0); + } + + 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; +}; + +// ─── Main Parser ──────────────────────────────────────────────────────────── + +// Order matters — first match wins. Common patterns go first. +// Do not reorder without running the spec. +const MATCHERS = [ + matchDuration, // "in 2 hours", "half day", "3h30m" + matchSpecial, // "end of week", "later today", "this weekend" + matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning" + matchNextPattern, // "next friday", "next week", "next month" + matchTimeOfDay, // "morning", "evening 6pm", "eod" + matchWeekday, // "friday", "monday 3pm", "wed morning" + matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts) + matchNamedDate, // "jan 15", "march 20 next year" + matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last) +]; + +/** + * Parse free-form text into a future date. + * Returns { date, unix } or null. Only returns dates after referenceDate. + * + * @param {string} text - user input like "in 2 hours" or "next friday 3pm" + * @param {Date} [referenceDate] - treat as "now" (defaults to current time) + * @returns {{ date: Date, unix: number } | null} + */ +export const parseDateFromText = (text, referenceDate = new Date()) => { + if (!text || typeof text !== 'string') return null; + + const normalized = stripNoise(sanitize(text)); + if (!normalized) return null; + + const maxDate = add(referenceDate, { years: 999 }); + + const isValidFuture = d => + d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d); + + let result = null; + MATCHERS.some(matcher => { + const d = matcher(normalized, referenceDate); + if (isValidFuture(d)) { + result = { date: d, unix: getUnixTime(d) }; + return true; + } + return false; + }); + + return result; +}; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/suggestions.js b/app/javascript/dashboard/helper/snoozeDateParser/suggestions.js new file mode 100644 index 000000000..2efd77d74 --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser/suggestions.js @@ -0,0 +1,101 @@ +/** + * Builds autocomplete suggestions as the user types a snooze date. + * Matches partial input against known phrases and ranks them by closeness. + */ + +import { + UNIT_MAP, + WEEKDAY_MAP, + TIME_OF_DAY_MAP, + RELATIVE_DAY_MAP, + WORD_NUMBER_MAP, + MONTH_MAP, + HALF_UNIT_DURATIONS, +} from './tokenMaps'; + +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 ALL_SUGGESTION_PHRASES = [ + ...Object.keys(RELATIVE_DAY_MAP), + ...FULL_WEEKDAYS, + ...TOD_NAMES, + 'next week', + 'next month', + 'this weekend', + 'next weekend', + 'day after tomorrow', + 'later today', + 'end of day', + 'end of week', + 'end of month', + ...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${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`), +]; + +/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */ +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; +}; + +export const MAX_SUGGESTIONS = 5; + +/** Turn user input into a ranked list of suggestion strings to try parsing. */ +export const buildSuggestionCandidates = text => { + if (!text) return []; + + if (/^\d/.test(text)) { + const num = text.match(/^\d+(?:\.5)?/)[0]; + const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`); + const trimmed = text.replace(/\s+/g, ' ').trim(); + const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2'); + return spaced.length > num.length + ? candidates.filter(c => c.startsWith(spaced)) + : candidates; + } + + if (text.length >= 2 && 'half'.startsWith(text)) { + return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`); + } + + const wordNum = WORD_NUMBER_MAP[text]; + if (wordNum != null && wordNum >= 1) { + return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`); + } + + 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; + }, []); +}; diff --git a/app/javascript/dashboard/helper/snoozeDateParser/tokenMaps.js b/app/javascript/dashboard/helper/snoozeDateParser/tokenMaps.js new file mode 100644 index 000000000..0397a9483 --- /dev/null +++ b/app/javascript/dashboard/helper/snoozeDateParser/tokenMaps.js @@ -0,0 +1,395 @@ +/** + * Shared lookup tables and helper functions used by the parser, + * suggestions, and localization modules. + */ + +import { + add, + set, + isValid, + isAfter, + nextMonday, + nextTuesday, + nextWednesday, + nextThursday, + nextFriday, + nextSaturday, + nextSunday, +} from 'date-fns'; + +// ─── Token Maps ────────────────────────────────────────────────────────────── +// All keys are lowercase. Short forms and full names both work. + +/** Weekday name or short form → day index (0 = Sunday). */ +export 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, +}; + +/** Month name or short form → month index (0 = January). */ +export 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, +}; + +/** Words like "today" or "tomorrow" → how many days from now. */ +export const RELATIVE_DAY_MAP = { + today: 0, + tonight: 0, + tonite: 0, + tomorrow: 1, + tmr: 1, + tmrw: 1, +}; + +/** Unit shorthand → full unit name used by date-fns. */ +export const UNIT_MAP = { + 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', +}; + +/** English number words → their numeric value. */ +export const WORD_NUMBER_MAP = { + a: 1, + an: 1, + one: 1, + couple: 2, + few: 3, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + 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, + fifty: 50, + sixty: 60, + ninety: 90, + half: 0.5, +}; + +/** Day index → the date-fns function that finds the next occurrence. */ +export const NEXT_WEEKDAY_FN = { + 0: nextSunday, + 1: nextMonday, + 2: nextTuesday, + 3: nextWednesday, + 4: nextThursday, + 5: nextFriday, + 6: nextSaturday, +}; + +/** Time-of-day label → default hour and minute. */ +export 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 }, +}; + +/** Allowed hour range per label — used to pick am or pm when not specified. */ +export 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], +}; + +/** What "half hour", "half day", etc. actually mean in date-fns terms. */ +export const HALF_UNIT_DURATIONS = { + hour: { minutes: 30 }, + day: { hours: 12 }, + week: { days: 3, hours: 12 }, + month: { days: 15 }, + year: { months: 6 }, +}; + +const FRACTIONAL_CONVERT = { + hours: { unit: 'minutes', factor: 60 }, + days: { unit: 'hours', factor: 24 }, + weeks: { unit: 'days', factor: 7 }, + months: { unit: 'days', factor: 30 }, + years: { unit: 'months', factor: 12 }, +}; + +// ─── Unicode / Normalization ──────────────────────────────────────────────── +// Turn non-ASCII digits and punctuation into plain ASCII so the +// parser only has to deal with standard characters. + +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]); +}; + +/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */ +export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit); + +const ARABIC_PUNCT_MAP = { + '\u061f': '?', + '\u060c': ',', + '\u061b': ';', + '\u066b': '.', +}; + +const NOISE_RE = + /^(?:(?: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|after|within)\s+)?/; + +const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/; + +/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */ +export 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(); + +/** Strip filler words like "please snooze for" and fix typos like "tommorow". */ +export const stripNoise = text => { + let r = text + .replace(/\ba\s+fortnight\b/g, '2 weeks') + .replace(/\bfortnight\b/g, '2 weeks') + .replace(NOISE_RE, '') + .replace(APPROX_RE, '') + .replace(/^the\s+/, '') + .replace(/\bnxt\b/g, 'next') + .replace(/\ba\s+couple\s+of\b/g, 'couple') + .replace(/\bcouple\s+of\b/g, 'couple') + .replace(/\ba\s+couple\b/g, 'couple') + .replace(/\ba\s+few\b/g, 'few') + .replace( + /\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g, + (_, h, m) => + `${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}` + ) + .replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`) + .replace( + /\b(\d+)m\b/g, + (_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}` + ) + .replace(/\btomm?orow\b/g, 'tomorrow') + .replace(/\s+later$/, '') + .trim(); + // bare unit without number: "month later" → "1 month", "week" stays + r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1'); + return r; +}; + +// ─── Utility Functions ────────────────────────────────────────────────────── + +/** Turn a string into a number. Works with digits ("5") and words ("five"). */ +export const parseNumber = str => { + if (!str) return null; + 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; +}; + +/** Set the time on a date, clearing seconds and milliseconds. */ +export const applyTimeToDate = (date, hours, minutes = 0) => + set(date, { hours, minutes, seconds: 0, milliseconds: 0 }); + +/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */ +export const parseTimeString = timeStr => { + if (!timeStr) return null; + const match = timeStr + .toLowerCase() + .replace(/\s+/g, '') + .match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/); + if (!match) return null; + + 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; + + 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 }; +}; + +/** Apply a time string to a date. Falls back to 9 AM if no time is given. */ +export 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); +}; + +/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */ +export 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; +}; + +/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */ +export const futureOrNextYear = (year, month, day, timeStr, now) => { + 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 null; +}; + +/** If the date is already past, push it to the next day. */ +export const ensureFutureOrNextDay = (date, now) => + isAfter(date, now) ? date : add(date, { days: 1 }); + +/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */ +export 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, + }; +}; + +/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */ +export const addFractionalSafe = (date, unit, amount) => { + if (Number.isInteger(amount)) return add(date, { [unit]: amount }); + if (amount % 1 !== 0.5) return null; + const conv = FRACTIONAL_CONVERT[unit]; + if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) }); + return add(date, { [unit]: Math.round(amount) }); +}; diff --git a/app/javascript/dashboard/helper/snoozeHelpers.js b/app/javascript/dashboard/helper/snoozeHelpers.js index efe526562..73f18e58f 100644 --- a/app/javascript/dashboard/helper/snoozeHelpers.js +++ b/app/javascript/dashboard/helper/snoozeHelpers.js @@ -7,11 +7,17 @@ import { startOfMonth, isMonday, isToday, + isSameYear, setHours, setMinutes, setSeconds, } from 'date-fns'; import wootConstants from 'dashboard/constants/globals'; +import { + generateDateSuggestions, + parseDateFromText, +} from 'dashboard/helper/snoozeDateParser'; +import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps'; const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS; @@ -33,65 +39,113 @@ 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 format(date, 'd MMM, h.mmaaa'); +}; - if (isToday(date)) { - return format(date, 'h.mmaaa'); +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'); } - return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null; }; -export const snoozedReopenTimeToTimestamp = snoozedUntil => { - return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null; +const expandUnit = (num, abbr) => { + const full = UNIT_MAP[abbr]; + if (!full) return `${num} ${abbr}`; + return parseFloat(num) === 1 + ? `${num} ${full.replace(/s$/, '')}` + : `${num} ${full}`; }; + +const capitalizeLabel = text => { + const expanded = text + .replace( + /^(\d+)h(\d+)m(?:in)?$/i, + (_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}` + ) + .replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) => + UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}` + ); + return expanded.replace(/^\w/, c => c.toUpperCase()); +}; + +export const generateSnoozeSuggestions = ( + searchText, + currentDate = new Date(), + { translations, locale } = {} +) => { + const suggestions = generateDateSuggestions(searchText, currentDate, { + translations, + locale, + }); + return suggestions.map(s => ({ + date: s.date, + unixTime: s.unix, + query: s.query, + label: capitalizeLabel(s.label), + formattedDate: formatSnoozeDate(s.date, currentDate, locale), + resolve: () => parseDateFromText(s.query)?.unix ?? s.unix, + })); +}; + +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 new file mode 100644 index 000000000..1ddde6fed --- /dev/null +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -0,0 +1,1761 @@ +import { + parseDateFromText, + generateDateSuggestions, +} 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); + }); + + it('"05-04-2027" ambiguous dash → day-first (April 5)', () => { + const result = parseDateFromText('05-04-2027', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(5); + expect(result.date.getMonth()).toEqual(3); + }); + + it('"05.04.2027" ambiguous dot → day-first (April 5)', () => { + const result = parseDateFromText('05.04.2027', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(5); + expect(result.date.getMonth()).toEqual(3); + }); + + it('"05/04/2027" ambiguous slash → month-first (May 4)', () => { + const result = parseDateFromText('05/04/2027', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(4); + expect(result.date.getDate()).toEqual(4); + }); +}); + +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); + }); +}); + +describe('generateDateSuggestions', () => { + describe('half suggestions', () => { + it('"half" returns half hour/day/week/month/year suggestions', () => { + const results = generateDateSuggestions('half', now); + const labels = results.map(r => r.label); + expect(labels).toContain('half hour'); + expect(labels).toContain('half day'); + expect(labels).toContain('half week'); + expect(labels).toContain('half month'); + expect(labels).toContain('half year'); + }); + + it('"ha" returns half suggestions (partial match)', () => { + const results = generateDateSuggestions('ha', now); + const labels = results.map(r => r.label); + expect(labels).toContain('half hour'); + expect(labels).toContain('half day'); + }); + + it('"hal" returns half suggestions (partial match)', () => { + const results = generateDateSuggestions('hal', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toMatch(/^half /); + }); + }); + + describe('word number suggestions', () => { + it('"two" returns duration suggestions', () => { + const results = generateDateSuggestions('two', now); + const labels = results.map(r => r.label); + expect(labels).toContain('2 minutes'); + expect(labels).toContain('2 hours'); + expect(labels).toContain('2 days'); + }); + + it('"ten" returns duration suggestions', () => { + const results = generateDateSuggestions('ten', now); + const labels = results.map(r => r.label); + expect(labels).toContain('10 minutes'); + expect(labels).toContain('10 hours'); + }); + + it('"five" returns duration suggestions', () => { + const results = generateDateSuggestions('five', now); + const labels = results.map(r => r.label); + expect(labels).toContain('5 minutes'); + expect(labels).toContain('5 hours'); + expect(labels).toContain('5 days'); + }); + }); + + describe('no seconds in suggestions', () => { + it('"2" does not suggest seconds', () => { + const results = generateDateSuggestions('2', now); + const labels = results.map(r => r.label); + expect(labels).not.toContain('2 seconds'); + expect(labels).toContain('2 minutes'); + }); + + it('"100" does not suggest seconds', () => { + const results = generateDateSuggestions('100', now); + const labels = results.map(r => r.label); + const hasSeconds = labels.some(l => l.includes('seconds')); + expect(hasSeconds).toBe(false); + }); + }); + + describe('decimal number suggestions', () => { + it('"1.5" returns duration suggestions', () => { + const results = generateDateSuggestions('1.5', now); + const labels = results.map(r => r.label); + expect(labels).toContain('1.5 hours'); + expect(labels).toContain('1.5 days'); + }); + }); + + describe('caps at MAX_SUGGESTIONS', () => { + it('returns at most 5 results', () => { + const results = generateDateSuggestions('2', now); + 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(); + }); +}); + +// Pin exact output for ~35 common phrases so any matcher reorder or refactor +// that changes behavior will fail loudly. Reference: 2023-06-16T10:00:00 (Fri). + +describe('golden tests: pinned phrase → exact date/time', () => { + // [input, expectedYear, expectedMonth(0-based), expectedDay, expectedHour, expectedMinute] + const golden = [ + // ── Durations ── + ['in 30 minutes', 2023, 5, 16, 10, 30], + ['in 2 hours', 2023, 5, 16, 12, 0], + ['in 3 days', 2023, 5, 19, 10, 0], + ['a week', 2023, 5, 23, 10, 0], + ['two months', 2023, 7, 16, 10, 0], + ['half hour', 2023, 5, 16, 10, 30], + ['half day', 2023, 5, 16, 22, 0], + ['1.5 hours', 2023, 5, 16, 11, 30], + ['1h30m', 2023, 5, 16, 11, 30], + ['couple hours', 2023, 5, 16, 12, 0], + ['few hours', 2023, 5, 16, 13, 0], + + // ── Relative days ── + ['tomorrow', 2023, 5, 17, 9, 0], + ['tomorrow at 3pm', 2023, 5, 17, 15, 0], + ['tomorrow at 14:30', 2023, 5, 17, 14, 30], + ['tonight', 2023, 5, 16, 20, 0], + ['today at 3pm', 2023, 5, 16, 15, 0], + ['tomorrow morning', 2023, 5, 17, 9, 0], + ['tomorrow evening', 2023, 5, 17, 18, 0], + ['day after tomorrow', 2023, 5, 18, 9, 0], + + // ── Time-of-day ── + ['morning', 2023, 5, 17, 9, 0], + ['this afternoon', 2023, 5, 16, 14, 0], + ['eod', 2023, 5, 16, 17, 0], + ['later today', 2023, 5, 16, 13, 0], + + // ── Standalone time ── + ['at 3pm', 2023, 5, 16, 15, 0], + + // ── Next patterns ── + ['next hour', 2023, 5, 16, 11, 0], + ['next week', 2023, 5, 19, 9, 0], + ['next month', 2023, 6, 16, 9, 0], + + // ── Weekdays ── + ['friday', 2023, 5, 23, 9, 0], + ['monday 3pm', 2023, 5, 19, 15, 0], + + // ── Named dates ── + ['jan 15', 2024, 0, 15, 9, 0], + ['march 5 at 2pm', 2024, 2, 5, 14, 0], + ['dec 25 2025', 2025, 11, 25, 9, 0], + + // ── Month ordinal week ── + ['july 1st week', 2023, 6, 1, 9, 0], // July 1st week = July 1 + ['july 2nd week', 2023, 6, 8, 9, 0], // July 2nd week = July 8 + ['july 3rd week', 2023, 6, 15, 9, 0], // July 3rd week = July 15 + ['aug 1st week', 2023, 7, 1, 9, 0], // August 1st week = Aug 1 + ['feb 2nd week at 3pm', 2024, 1, 8, 15, 0], // Feb 2nd week with time + ['march first week', 2024, 2, 1, 9, 0], // Ordinal: first + ['march second week', 2024, 2, 8, 9, 0], // Ordinal: second + ['april third week', 2024, 3, 15, 9, 0], // Ordinal: third + ['may fourth week', 2024, 4, 22, 9, 0], // Ordinal: fourth + ['june fifth week', 2023, 5, 29, 9, 0], // Ordinal: fifth (same year since we're before week 5) + + // ── Month ordinal day ── + ['april first day', 2024, 3, 1, 9, 0], + ['april second day', 2024, 3, 2, 9, 0], + ['july third day', 2023, 6, 3, 9, 0], + ['march 5th day', 2024, 2, 5, 9, 0], + ['jan tenth day at 2pm', 2024, 0, 10, 14, 0], + + // ── Reversed order: ordinal unit of month ── + ['first week of april', 2024, 3, 1, 9, 0], + ['2nd week of july', 2023, 6, 8, 9, 0], + ['third day of march', 2024, 2, 3, 9, 0], + ['5th day of jan at 2pm', 2024, 0, 5, 14, 0], + ['second week of feb at 3pm', 2024, 1, 8, 15, 0], + + // ── Formal dates ── + ['2025-01-15', 2025, 0, 15, 9, 0], + ['01/15/2025', 2025, 0, 15, 9, 0], + + // ── Tonight bare-hour (must infer PM, not AM) ── + ['tonight 8', 2023, 5, 16, 20, 0], + ['tonite 7', 2023, 5, 16, 19, 0], + ['tonight 11', 2023, 5, 16, 23, 0], + ['today 8', 2023, 5, 17, 8, 0], // 8am is past → rolls to next day + + // ── Shorthand durations ── + ['2h', 2023, 5, 16, 12, 0], + ['30m', 2023, 5, 16, 10, 30], + ['1h30minutes', 2023, 5, 16, 11, 30], + ['2hr15min', 2023, 5, 16, 12, 15], + + // ── Couple / few ── + ['couple hours', 2023, 5, 16, 12, 0], + ['a couple of days', 2023, 5, 18, 10, 0], + ['a few minutes', 2023, 5, 16, 10, 3], + ['in a few hours', 2023, 5, 16, 13, 0], + + // ── Fortnight ── + ['fortnight', 2023, 5, 30, 10, 0], + ['in a fortnight', 2023, 5, 30, 10, 0], + + // ── X later ── + ['2 days later', 2023, 5, 18, 10, 0], + ['a week later', 2023, 5, 23, 10, 0], + ['month later', 2023, 6, 16, 10, 0], + + // ── Same time reversed ── + ['same time tomorrow', 2023, 5, 17, 10, 0], + + // ── Early / late time of day ── + ['early morning', 2023, 5, 17, 8, 0], + ['late evening', 2023, 5, 16, 20, 0], + ['late night', 2023, 5, 16, 22, 0], + + // ── Beginning / end of next ── + ['beginning of next week', 2023, 5, 19, 9, 0], + ['start of next week', 2023, 5, 19, 9, 0], + ['end of next week', 2023, 5, 23, 17, 0], + ['end of next month', 2023, 6, 31, 17, 0], + ['beginning of next month', 2023, 6, 1, 9, 0], + + // ── Next business day ── + ['next business day', 2023, 5, 19, 9, 0], + ['next working day', 2023, 5, 19, 9, 0], + + // ── One and a half ── + ['one and a half hours', 2023, 5, 16, 11, 30], + ['an hour and a half', 2023, 5, 16, 11, 30], + + // ── Noise prefix: after / within ── + ['after 2 hours', 2023, 5, 16, 12, 0], + ['within a week', 2023, 5, 23, 10, 0], + + // ── The day after tomorrow ── + ['the day after tomorrow', 2023, 5, 18, 9, 0], + + // ── Special ── + ['this weekend', 2023, 5, 17, 9, 0], + ['end of month', 2023, 5, 30, 17, 0], + ]; + + golden.forEach(([input, yr, mo, day, hr, min]) => { + it(`"${input}" → ${yr}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hr).padStart(2, '0')}:${String(min).padStart(2, '0')}`, () => { + const result = parseDateFromText(input, now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toBe(yr); + expect(result.date.getMonth()).toBe(mo); + expect(result.date.getDate()).toBe(day); + expect(result.date.getHours()).toBe(hr); + expect(result.date.getMinutes()).toBe(min); + }); + }); +}); + +describe('regression: month-ordinal week overflow (P1)', () => { + it('"feb fifth week" returns null in non-leap year (would overflow into March)', () => { + const ref = new Date(2023, 0, 10, 10, 0, 0); + expect(parseDateFromText('feb fifth week', ref)).toBeNull(); + }); + + it('"feb fourth week" is still valid', () => { + const ref = new Date(2023, 0, 10, 10, 0, 0); + const result = parseDateFromText('feb fourth week', ref); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toBe(1); + }); +}); + +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); + }); + + it('"end of month" on last day after 5pm rolls to next month-end', () => { + const lastDayLate = new Date('2025-06-30T18:00:00'); + const result = parseDateFromText('end of month', lastDayLate); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toBe(6); + expect(result.date.getDate()).toBe(31); + 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', () => { + it('"12.12.2034" parses to Dec 12 2034', () => { + const result = parseDateFromText('12.12.2034', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2034); + expect(result.date.getMonth()).toEqual(11); + expect(result.date.getDate()).toEqual(12); + }); + + it('"01.06.2025" parses correctly', () => { + const result = parseDateFromText('01.06.2025', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2025); + }); +}); + +describe('noise word stripping', () => { + it('"snooze this for 5 minutes" parses', () => { + const result = parseDateFromText('snooze this for 5 minutes', now); + expect(result).not.toBeNull(); + }); + + it('"please snooze this for half a day" parses', () => { + const result = parseDateFromText('please snooze this for half a day', now); + expect(result).not.toBeNull(); + }); + + it('"snooze this until tomorrow" parses', () => { + const result = parseDateFromText('snooze this until tomorrow', now); + expect(result).not.toBeNull(); + }); + + it('"after ten year" strips "after" and parses as duration', () => { + const result = parseDateFromText('after ten year', now); + expect(result).not.toBeNull(); + }); + + it('"after 2 hours" strips "after" and parses as duration', () => { + const result = parseDateFromText('after 2 hours', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(12); + }); + + it('"after 3 days" strips "after" and parses as duration', () => { + const result = parseDateFromText('after 3 days', now); + expect(result).not.toBeNull(); + expect(result.date.getDate()).toEqual(19); + }); + + it('"schedule this for 2025-01-15" parses', () => { + const result = parseDateFromText('schedule this for 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); + }); +}); + +describe('half unit parsing', () => { + it('"half hour" adds 30 minutes', () => { + const result = parseDateFromText('half hour', now); + expect(result).not.toBeNull(); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"half day" adds 12 hours', () => { + const result = parseDateFromText('half day', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(22); + }); + + it('"half week" parses to a future date', () => { + const result = parseDateFromText('half week', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"half month" parses to a future date', () => { + const result = parseDateFromText('half month', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"half year" parses to ~6 months ahead', () => { + const result = parseDateFromText('half year', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(11); + }); +}); + +describe('decimal duration parsing (only .5 allowed)', () => { + it('"1.5 hours" parses correctly', () => { + const result = parseDateFromText('1.5 hours', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"1.5 days" parses correctly', () => { + const result = parseDateFromText('1.5 days', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"0.5 hours" parses correctly', () => { + const result = parseDateFromText('0.5 hours', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"1.3 hours" returns null (only .5 allowed)', () => { + expect(parseDateFromText('1.3 hours', now)).toBeNull(); + }); + + it('"2.7 days" returns null (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); + }); + }); +}); + +describe('no-space duration suggestions', () => { + it('"1d" generates day suggestions', () => { + const results = generateDateSuggestions('1d', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 days'); + }); + + it('"2min" generates minute suggestions', () => { + const results = generateDateSuggestions('2min', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('2 minutes'); + }); + + it('"1h" generates hour suggestions', () => { + const results = generateDateSuggestions('1h', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 hour'); + }); + + it('"2ho" generates hour suggestions (partial match)', () => { + const results = generateDateSuggestions('2ho', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('2 hours'); + }); + + it('"3w" generates week suggestions', () => { + const results = generateDateSuggestions('3w', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('3 weeks'); + }); + + it('"1h30m" generates compound suggestion', () => { + const results = generateDateSuggestions('1h30m', now); + expect(results.length).toBeGreaterThan(0); + }); +}); diff --git a/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js index 0fb87eb41..5c0b9db59 100644 --- a/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeHelpers.spec.js @@ -7,6 +7,7 @@ import { setHoursToNine, snoozedReopenTimeToTimestamp, shortenSnoozeTime, + generateSnoozeSuggestions, } from '../snoozeHelpers'; describe('#Snooze Helpers', () => { @@ -91,12 +92,26 @@ describe('#Snooze Helpers', () => { }); describe('snoozedReopenTime', () => { - it('should return nil if snoozedUntil is nil', () => { - expect(snoozedReopenTime(null)).toEqual(null); + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); }); - it('should return formatted date if snoozedUntil is not nil', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return formatted date with year if snoozedUntil is not in current year', () => { + // Input is 09:00 UTC. + // If your environment is UTC, this will be 9.00am. expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual( + '7 Jun 2023, 9.00am' + ); + }); + + it('should return formatted date without year if snoozedUntil is in current year', () => { + // This uses 2024 because we mocked the system time above + expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual( '7 Jun, 9.00am' ); }); @@ -150,4 +165,56 @@ describe('#Snooze Helpers', () => { expect(shortenSnoozeTime(null)).toEqual(null); }); }); + + describe('generateSnoozeSuggestions label expansion', () => { + const now = new Date('2023-06-16T10:00:00'); + + it('expands abbreviated units: "1d" → "1 Day"', () => { + const results = generateSnoozeSuggestions('1d', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 day'); + }); + + it('expands abbreviated units: "2 d" → "2 Days"', () => { + const results = generateSnoozeSuggestions('2 d', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('2 days'); + }); + + it('expands abbreviated units: "1h" → "1 Hour"', () => { + const results = generateSnoozeSuggestions('1h', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 hour'); + }); + + it('expands abbreviated units: "2min" → "2 Minutes"', () => { + const results = generateSnoozeSuggestions('2min', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('2 minutes'); + }); + + it('handles singular: "1 hours" → "1 Hour"', () => { + const results = generateSnoozeSuggestions('1 hours', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 hour'); + }); + + it('handles singular: "1 minutes" → "1 Minute"', () => { + const results = generateSnoozeSuggestions('1 minutes', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 minute'); + }); + + it('keeps plural for non-1: "2 days" → "2 Days"', () => { + const results = generateSnoozeSuggestions('2 days', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('2 days'); + }); + + it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => { + const results = generateSnoozeSuggestions('1h30m', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toBe('1 hour 30 minutes'); + }); + }); }); 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/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..2d9a876aa --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/snooze.json @@ -0,0 +1,72 @@ +{ + "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" + }, + "ORDINALS": { + "FIRST": "first", + "SECOND": "second", + "THIRD": "third", + "FOURTH": "fourth", + "FIFTH": "fifth" + }, + "OF": "of", + "AFTER": "after", + "WEEK": "week", + "DAY": "day" + } +} 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..11943f61c 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue @@ -4,16 +4,29 @@ 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'; import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys'; import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys'; import wootConstants from 'dashboard/constants/globals'; -import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; +import { + GENERAL_EVENTS, + SNOOZE_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(); +const { t, tm } = useI18n(); +const { resolvedLocale } = useLocale(); const ninjakeys = ref(null); @@ -28,48 +41,168 @@ 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 hotKeys = computed(() => [ - ...inboxHotKeys.value, - ...goToCommandHotKeys.value, - ...goToAppearanceHotKeys.value, - ...bulkActionsHotKeys.value, - ...conversationHotKeys.value, -]); +const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME; + +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 SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS)); + +const hotKeys = computed(() => { + const allActions = [ + ...dynamicSnoozeActions.value, + ...inboxHotKeys.value, + ...goToCommandHotKeys.value, + ...goToAppearanceHotKeys.value, + ...bulkActionsHotKeys.value, + ...conversationHotKeys.value, + ]; + // When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication + if (!dynamicSnoozeActions.value.length) return allActions; + return allActions.filter( + a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent) + ); +}); const setCommandBarData = () => { ninjakeys.value.data = hotKeys.value; }; -const onSelected = item => { - const { - detail: { action: { title = null, section = null, id = 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; +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 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, new Date(), { + translations: snoozeTranslations.value, + locale: resolvedLocale.value, + }); + 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.resolve()); + useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label }); + }, + })); +}; + +const resetSnoozeState = () => { + currentCommandRoot.value = null; + dynamicSnoozeActions.value = []; +}; + +const patchNinjaKeysOpenClose = el => { + if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') { + return; } - useTrack(GENERAL_EVENTS.COMMAND_BAR, { - section, - action: title, - }); + 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) => { + resetSnoozeState(); + return originalClose(...args); + }; +}; + +const onSelected = item => { + const { + detail: { + action: { title = null, section = null, id = null, children = null } = {}, + } = {}, + } = item; + + selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null; + + if (Array.isArray(children) && children.length) { + currentCommandRoot.value = id; + } + + useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title }); setCommandBarData(); }; -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 +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 { + currentCommandRoot.value = null; + } + } + if ( - selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME + !normalizedSearch || + !SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '') ) { + dynamicSnoozeActions.value = []; + return; + } + + dynamicSnoozeActions.value = buildDynamicSnoozeActions( + normalizedSearch, + currentCommandRoot.value + ); +}; + +const onClosed = () => { + if (selectedSnoozeType.value !== CUSTOM_SNOOZE) { store.dispatch('setContextMenuChatId', null); } + resetSnoozeState(); }; watchEffect(() => { @@ -78,7 +211,10 @@ watchEffect(() => { } }); -onMounted(setCommandBarData); +onMounted(() => { + setCommandBarData(); + patchNinjaKeysOpenClose(ninjakeys.value); +}); @@ -88,6 +224,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);