# 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. <details> <summary>Supported Formats</summary> ## 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. </details> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screenshots** <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500" /> ## 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
807 lines
26 KiB
JavaScript
807 lines
26 KiB
JavaScript
/**
|
|
* 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;
|
|
};
|