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**
## 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);