Files
chatwoot/app/javascript/dashboard/helper/snoozeDateParser.js
T

880 lines
25 KiB
JavaScript

/**
* snoozeDateParser — Natural language date/time parser for snooze.
*
* Converts free-form English text into a future Date object.
* Handles relative durations ("in 2 hours", "half day"), named days
* ("tomorrow at 3pm", "next friday"), absolute dates ("jan 15", "2025-01-15"),
* time-of-day phrases ("morning", "eod"), and various noise/prefix stripping.
*
* All results are guaranteed to be strictly in the future relative to the
* reference date, with a 999-year maximum cap.
*/
import {
add,
set,
startOfDay,
getDay,
isSaturday,
isSunday,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isBefore,
isAfter,
} from 'date-fns';
// ─── Token Definitions ───────────────────────────────────────────────────────
const WEEKDAY_MAP = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
tues: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
thur: 4,
thurs: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};
const MONTH_MAP = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11,
};
const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
const UNIT_MAP = {
s: 'seconds',
sec: 'seconds',
secs: 'seconds',
second: 'seconds',
seconds: 'seconds',
m: 'minutes',
min: 'minutes',
mins: 'minutes',
minute: 'minutes',
minutes: 'minutes',
h: 'hours',
hr: 'hours',
hrs: 'hours',
hour: 'hours',
hours: 'hours',
d: 'days',
day: 'days',
days: 'days',
w: 'weeks',
wk: 'weeks',
wks: 'weeks',
week: 'weeks',
weeks: 'weeks',
mo: 'months',
month: 'months',
months: 'months',
y: 'years',
yr: 'years',
yrs: 'years',
year: 'years',
years: 'years',
};
const WORD_NUMBER_MAP = {
a: 1,
an: 1,
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
fifteen: 15,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
};
const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
const TIME_OF_DAY_MAP = {
morning: { hours: 9, minutes: 0 },
noon: { hours: 12, minutes: 0 },
afternoon: { hours: 14, minutes: 0 },
evening: { hours: 18, minutes: 0 },
night: { hours: 20, minutes: 0 },
tonight: { hours: 20, minutes: 0 },
midnight: { hours: 0, minutes: 0 },
eod: { hours: 17, minutes: 0 },
'end of day': { hours: 17, minutes: 0 },
'end of the day': { hours: 17, minutes: 0 },
};
const TOD_HOUR_RANGE = {
morning: [4, 12],
noon: [11, 13],
afternoon: [12, 18],
evening: [16, 22],
night: [18, 24],
tonight: [18, 24],
midnight: [23, 25],
};
// ─── Generated Regex Fragments (from maps, not hand-duplicated) ──────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+|${WORD_NUMBERS})`;
const UNIT_RE = `(${UNIT_NAMES})`;
const TIME_SUFFIX_RE =
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const NOISE_RE =
/^(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push(?:\s+(?:it|this))?)\s+)?(?:(?:it|this)\s+(?=(?:on|to|for|at|until|till|by|from)\s))?(?:(?:on|to|for|at|until|till|by|from)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
const normalize = text => {
let t = text
.toLowerCase()
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
t = t.replace(NOISE_RE, '').trim();
t = t.replace(APPROX_RE, '').trim();
return t;
};
const parseNumber = str => {
if (!str) return null;
const lower = str.toLowerCase().trim();
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
const parseTimeString = timeStr => {
if (!timeStr) return null;
const cleaned = timeStr.toLowerCase().replace(/\s+/g, '').trim();
const match = cleaned.match(
/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/
);
if (!match) return null;
let hours = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem) {
if (hours < 1 || hours > 12) return null;
}
if (meridiem === 'pm' && hours < 12) hours += 12;
if (meridiem === 'am' && hours === 12) hours = 0;
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
return { hours, minutes };
};
const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
if (timeStr) {
const time = parseTimeString(timeStr);
if (!time) return null;
return applyTimeToDate(date, time.hours, time.minutes);
}
return applyTimeToDate(date, defaultHours, 0);
};
const strictDate = (year, month, day) => {
const date = new Date(year, month, day);
if (
!isValid(date) ||
date.getFullYear() !== year ||
date.getMonth() !== month ||
date.getDate() !== day
)
return null;
return date;
};
const futureOrNextYear = (year, month, day, timeStr, now) => {
// Scan up to 8 years ahead to find the next valid future date.
// Handles feb 29 in non-leap years (leap years repeat every 4 years).
const years = Array.from({ length: 9 }, (_, i) => year + i);
let result = null;
years.some(y => {
const base = strictDate(y, month, day);
if (!base) return false;
const date = applyTimeOrDefault(base, timeStr);
if (date && isAfter(date, now)) {
result = date;
return true;
}
return !date;
});
return result;
};
const resolveTimeOfDay = (text, now) => {
const tod = TIME_OF_DAY_MAP[text];
if (!tod) return null;
let date = applyTimeToDate(now, tod.hours, tod.minutes);
if (!isAfter(date, now)) date = add(date, { days: 1 });
return date;
};
// ─── Pattern Matchers ────────────────────────────────────────────────────────
const matchRelativeDuration = (text, now) => {
if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?hour$/)) {
return add(now, { minutes: 30 });
}
if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?day$/)) {
return add(now, { hours: 12 });
}
const match = text.match(new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`));
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return add(now, { [unit]: amount });
};
const matchDurationFromNow = (text, now) => {
const match = text.match(
new RegExp(`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`)
);
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return add(now, { [unit]: amount });
};
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(new RegExp(`^(${RELATIVE_DAYS})$`));
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
let date = add(startOfDay(now), { days: offset });
if (key === 'tonight') {
date = applyTimeToDate(date, 20, 0);
if (!isAfter(date, now)) date = add(date, { days: 1 });
} else if (offset === 1) {
date = applyTimeToDate(date, 9, 0);
} else {
date = add(now, { hours: 1 });
}
return date;
}
const dayTodMatch = text.match(
new RegExp(`^(${RELATIVE_DAYS})\\s+(${TIME_OF_DAY_NAMES})$`)
);
if (dayTodMatch) {
const offset = RELATIVE_DAY_MAP[dayTodMatch[1]];
const tod = TIME_OF_DAY_MAP[dayTodMatch[2]];
let base = add(startOfDay(now), { days: offset });
let date = applyTimeToDate(base, tod.hours, tod.minutes);
if (!isAfter(date, now)) {
base = add(base, { days: 1 });
date = applyTimeToDate(base, tod.hours, tod.minutes);
}
return date;
}
const dayAtTimeMatch = text.match(
new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$`
)
);
if (dayAtTimeMatch) {
const offset = RELATIVE_DAY_MAP[dayAtTimeMatch[1]];
const time = parseTimeString(dayAtTimeMatch[2]);
if (!time) return null;
let base = add(startOfDay(now), { days: offset });
let date = applyTimeToDate(base, time.hours, time.minutes);
if (!isAfter(date, now)) {
base = add(base, { days: 1 });
date = applyTimeToDate(base, time.hours, time.minutes);
}
return date;
}
const sameTimeMatch = text.match(
new RegExp(`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`)
);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]];
if (offset <= 0) return null;
const base = add(startOfDay(now), { days: offset });
return applyTimeToDate(base, now.getHours(), now.getMinutes());
}
return null;
};
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(/^next\s+(hour|minute|week|month|year)$/);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeToDate(base, 9, 0);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeToDate(base, 9, 0);
}
const weekdayOfNextMatch = text.match(
new RegExp(
`^(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week${TIME_SUFFIX_RE}$`
)
);
if (weekdayOfNextMatch) {
const dayIndex = WEEKDAY_MAP[weekdayOfNextMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let date = fn(now);
const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 });
const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 });
if (nowWeekStart.getTime() === dateWeekStart.getTime()) {
date = fn(date);
}
return applyTimeOrDefault(date, weekdayOfNextMatch[2]);
}
const nextWeekDayMatch = text.match(
new RegExp(`^next\\s+week\\s+(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`)
);
if (nextWeekDayMatch) {
const dayIndex = WEEKDAY_MAP[nextWeekDayMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let date = fn(now);
const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 });
const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 });
if (nowWeekStart.getTime() === dateWeekStart.getTime()) {
date = fn(date);
}
return applyTimeOrDefault(date, nextWeekDayMatch[2]);
}
const nextDayMatch = text.match(
new RegExp(`^next\\s+(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`)
);
if (nextDayMatch) {
const dayIndex = WEEKDAY_MAP[nextDayMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let date = fn(now);
const nowWeekStart = startOfWeek(now, { weekStartsOn: 1 });
const dateWeekStart = startOfWeek(date, { weekStartsOn: 1 });
if (nowWeekStart.getTime() === dateWeekStart.getTime()) {
date = fn(date);
}
return applyTimeOrDefault(date, nextDayMatch[2]);
}
return null;
};
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(
new RegExp(`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`)
);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
const match = text.match(
new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
)
);
if (!match) return null;
const dayIndex = WEEKDAY_MAP[match[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, match[2]);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), match[2]);
};
const matchTimeOnly = (text, now) => {
let match = text.match(
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/
);
if (!match) match = text.match(/^(?:at\s+)?(\d{1,2}:\d{2})$/);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
let date = applyTimeToDate(now, time.hours, time.minutes);
if (!isAfter(date, now)) date = add(date, { days: 1 });
return date;
};
const isTimeConsistentWithTOD = (todLabel, hours) => {
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return true;
const h = hours === 0 ? 24 : hours;
return h >= range[0] && h < range[1];
};
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(
new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?))$`
)
);
if (todWithTime) {
const todLabel = todWithTime[1];
const time = parseTimeString(todWithTime[2]);
if (!time) return null;
if (!isTimeConsistentWithTOD(todLabel, time.hours)) return null;
let date = applyTimeToDate(now, time.hours, time.minutes);
if (!isAfter(date, now)) date = add(date, { days: 1 });
return date;
}
const match = text.match(
new RegExp(
`^(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
)
);
if (!match) return null;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
return resolveTimeOfDay(key, now);
};
const matchAbsoluteDate = (text, now) => {
const match = text.match(
new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
)
);
if (!match) return null;
const month = MONTH_MAP[match[1]];
const day = parseInt(match[2], 10);
let year = now.getFullYear();
if (match[3] && /next\s+year/i.test(match[3])) {
year = now.getFullYear() + 1;
} else if (match[3]) {
year = parseInt(match[3], 10);
}
if (match[3]) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, match[4]);
if (!date || !isAfter(date, now)) return null;
return date;
}
return futureOrNextYear(year, month, day, match[4], now);
};
const matchAbsoluteDateReversed = (text, now) => {
const match = text.match(
new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
)
);
if (!match) return null;
const day = parseInt(match[1], 10);
const month = MONTH_MAP[match[2]];
let year = now.getFullYear();
if (match[3] && /next\s+year/i.test(match[3])) {
year = now.getFullYear() + 1;
} else if (match[3]) {
year = parseInt(match[3], 10);
}
if (match[3]) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, match[4]);
if (!date || !isAfter(date, now)) return null;
return date;
}
return futureOrNextYear(year, month, day, match[4], now);
};
const matchMonthYear = (text, now) => {
const match = text.match(new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`));
if (!match) return null;
const month = MONTH_MAP[match[1]];
const year = parseInt(match[2], 10);
const date = new Date(year, month, 1);
if (!isValid(date)) return null;
if (!isAfter(applyTimeToDate(date, 9, 0), now)) return null;
return applyTimeToDate(date, 9, 0);
};
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
return applyTimeOrDefault(date, timeStr);
};
const TIME_SUFFIX =
/(?:\s+(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?)?))?$/;
const ISO_DATE_RE = new RegExp(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX.source}`
);
const disambiguateDayMonth = (a, b) => {
if (a > 12) return { day: a, month: b - 1 };
if (b > 12) return { month: a - 1, day: b };
return { month: a - 1, day: b };
};
const matchFormalDate = (text, now) => {
const ensureFuture = date => {
if (!date || !isAfter(date, now)) return null;
return date;
};
let match = text.match(ISO_DATE_RE);
if (match) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(match[1], 10),
parseInt(match[2], 10) - 1,
parseInt(match[3], 10),
match[4]
)
);
}
const parseAmbiguous = m => {
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10)
);
return ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
};
match = text.match(SLASH_DATE_RE);
if (match) return parseAmbiguous(match);
match = text.match(DASH_DATE_RE);
if (match) return parseAmbiguous(match);
match = text.match(DOT_DATE_RE);
if (match) return parseAmbiguous(match);
return null;
};
const matchDayAfterTomorrow = (text, now) => {
const match = text.match(
new RegExp(`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`)
);
if (!match) return null;
return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), match[1]);
};
const matchThisWeekend = (text, now) => {
if (text !== 'this weekend' && text !== 'weekend') return null;
if (isSaturday(now)) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
if (isSunday(now)) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
return applyTimeToDate(nextSaturday(now), 10, 0);
};
const matchNextWeekend = (text, now) => {
if (text !== 'next weekend') return null;
const sat = nextSaturday(now);
const date = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeToDate(date, 10, 0);
};
// ─── Main Parser ─────────────────────────────────────────────────────────────
const MATCHERS = [
matchRelativeDuration,
matchDurationFromNow,
matchDayAfterTomorrow,
matchRelativeDay,
matchNextPattern,
matchThisWeekend,
matchNextWeekend,
matchTimeOfDay,
matchWeekday,
matchTimeOnly,
matchAbsoluteDate,
matchAbsoluteDateReversed,
matchMonthYear,
matchFormalDate,
];
/**
* Parse a natural language English date/time string into a Date object.
*
* Supported patterns:
* - Relative durations: "in 30 minutes", "in 2 hours", "in half an hour"
* - Duration from now: "5 minutes from now", "a week from now"
* - Relative days: "today", "tomorrow", "tomorrow morning", "tomorrow at 3pm"
* - Day after tomorrow: "day after tomorrow", "day after tomorrow at 9am"
* - Next patterns: "next monday", "next week", "next friday at 2pm"
* - Weekdays: "monday", "friday at 3pm", "this wednesday"
* - Time of day: "morning", "this afternoon", "later this evening", "eod"
* - Time only: "at 3pm", "9:30am", "at 14:00"
* - Weekend: "this weekend", "next weekend"
* - Absolute dates: "jan 15", "march 5 2025", "dec 25 at 9am"
* - Reversed dates: "15 jan", "5th march 2025"
* - Formal dates: "01/15/2025", "2025-01-15"
* - Month + year: "jan 2028", "december 2025"
* - Same time: "tomorrow same time", "same time friday"
* - Time-of-day + time: "morning 6am", "evening 7pm"
* - Noise stripping: "remind me tomorrow", "snooze for 3 days", "approx 2 hours"
*
* Future-only: All matchers return dates strictly after referenceDate.
* If a relative expression like "today at 3pm" is already past, it rolls
* forward to the next valid occurrence (tomorrow at 3pm). This is intentional
* for snooze/reminder use cases where past times are meaningless.
* Zero durations ("0 days", "0 minutes") are also rejected.
* Invalid time expressions ("at 99", "at 25", "13pm") return null.
*
* Timezone: All operations use the runtime's local timezone via native Date.
* "tomorrow at 9am" means 9am in the user's browser timezone. The returned
* unix timestamp (via getUnixTime) is UTC-correct regardless of timezone.
*
* @param {string} text - Natural language date/time string
* @param {Date} [referenceDate=new Date()] - Reference date for relative calculations
* @returns {{ date: Date, unix: number } | null} Parsed date or null if unrecognized
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = normalize(text);
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
let parsed = null;
MATCHERS.some(matcher => {
const result = matcher(normalized, referenceDate);
if (
result &&
isValid(result) &&
isAfter(result, referenceDate) &&
!isBefore(maxDate, result)
) {
parsed = { date: result, unix: getUnixTime(result) };
return true;
}
return false;
});
return parsed;
};
// ─── Suggestion Candidates (uses maps already defined above) ─────────────────
const CANONICAL_UNITS = [...new Set(Object.values(UNIT_MAP))];
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const PHRASE_CANDIDATES = [
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(WEEKDAY_MAP).filter(k => k.length > 3),
...Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' ')),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
'next monday',
'next tuesday',
'next wednesday',
'next thursday',
'next friday',
'next saturday',
'next sunday',
'next week',
'next month',
'this friday',
'this saturday',
'this sunday',
'this weekend',
'next weekend',
'day after tomorrow',
];
const matchesPrefix = (candidate, text) => {
if (candidate === text) return false;
if (candidate.startsWith(text)) return true;
const words = candidate.split(' ');
return words.length > 1 && words.some(w => w.startsWith(text) && w !== text);
};
const buildSuggestionCandidates = text => {
if (/^\d/.test(text)) {
const num = text.match(/^\d+/)[0];
return CANONICAL_UNITS.map(u => `${num} ${u}`);
}
return PHRASE_CANDIDATES.filter(c => matchesPrefix(c, text));
};
const MAX_SUGGESTIONS = 5;
/**
* Generate multiple snooze date suggestions for a search prefix.
*
* For numeric input (e.g. "100", "2 h"), generates duration candidates
* by appending units (minutes, hours, days, weeks, months).
* For text input (e.g. "t", "next"), matches known phrases from the
* parser's own maps (weekdays, relative days, time-of-day, next patterns).
*
* Each candidate is validated through parseDateFromText. Results are
* deduplicated by unix timestamp and capped at 5.
*
* @param {string} text - Search prefix
* @param {Date} [referenceDate=new Date()] - Reference date
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return [];
const normalized = text.trim().toLowerCase();
if (!normalized) return [];
const seen = new Set();
const results = [];
const exact = parseDateFromText(normalized, referenceDate);
if (exact) {
seen.add(exact.unix);
results.push({ label: normalized, ...exact });
}
buildSuggestionCandidates(normalized).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
results.push({ label: candidate, ...result });
}
return false;
});
return results;
};