feat: Add natural language date parser for snooze functionality

This commit is contained in:
iamsivin
2026-02-19 16:41:23 +05:30
parent 6902969a09
commit ef79c36f78
9 changed files with 1769 additions and 5 deletions
@@ -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);
}
@@ -0,0 +1,873 @@
/**
* 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+)?(?:(?:on|to|for|at|until|till|by)\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 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);
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;
};
@@ -7,11 +7,13 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import { generateDateSuggestions } from 'dashboard/helper/snoozeDateParser';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -64,12 +66,36 @@ export const snoozedReopenTime = snoozedUntil => {
if (isToday(date)) {
return format(date, 'h.mmaaa');
}
if (!isSameYear(date, new Date())) {
return format(date, 'd MMM yyyy, h.mmaaa');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
};
const formatSnoozeDate = (snoozeDate, currentDate) => {
return isSameYear(snoozeDate, currentDate)
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
};
const capitalizeLabel = text => text.replace(/^\w/, c => c.toUpperCase());
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date()
) => {
const suggestions = generateDateSuggestions(searchText, currentDate);
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate),
}));
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
@@ -0,0 +1,732 @@
import { parseDateFromText } 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);
});
});
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);
});
});
@@ -95,9 +95,16 @@ describe('#Snooze Helpers', () => {
expect(snoozedReopenTime(null)).toEqual(null);
});
it('should return formatted date if snoozedUntil is not nil', () => {
it('should return formatted date with year if snoozedUntil is not in current year', () => {
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
'7 Jun 2023, 2.30pm'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
const currentYear = new Date().getFullYear();
expect(snoozedReopenTime(`${currentYear}-06-07T09:00:00.000Z`)).toEqual(
'7 Jun, 2.30pm'
);
});
});
@@ -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",
@@ -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,
@@ -11,6 +11,14 @@ import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkAct
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_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();
@@ -28,7 +36,21 @@ 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 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 hotKeys = computed(() => [
...inboxHotKeys.value,
@@ -36,15 +58,73 @@ const hotKeys = computed(() => [
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
...dynamicSnoozeActions.value,
]);
const setCommandBarData = () => {
ninjakeys.value.data = hotKeys.value;
};
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 buildDynamicSnoozeActions = (search, parentId) => {
const suggestions = generateSnoozeSuggestions(search);
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.unixTime),
}));
};
const patchNinjaKeysOpenClose = el => {
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
return;
}
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) => {
currentCommandRoot.value = null;
dynamicSnoozeActions.value = [];
return originalClose(...args);
};
};
const onSelected = item => {
const {
detail: { action: { title = null, section = null, id = null } = {} } = {},
detail: {
action: { title = null, section = null, id = null, children = null } = {},
} = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
@@ -54,6 +134,10 @@ const onSelected = item => {
selectedSnoozeType.value = null;
}
if (Array.isArray(children) && children.length) {
currentCommandRoot.value = id;
}
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
@@ -62,6 +146,35 @@ const onSelected = item => {
setCommandBarData();
};
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 if (uniqueParents.length > 1) {
currentCommandRoot.value = null;
}
}
if (
!normalizedSearch ||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
) {
dynamicSnoozeActions.value = [];
return;
}
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
normalizedSearch,
currentCommandRoot.value
);
};
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
@@ -70,6 +183,8 @@ const onClosed = () => {
) {
store.dispatch('setContextMenuChatId', null);
}
currentCommandRoot.value = null;
dynamicSnoozeActions.value = [];
};
watchEffect(() => {
@@ -78,7 +193,10 @@ watchEffect(() => {
}
});
onMounted(setCommandBarData);
onMounted(() => {
setCommandBarData();
patchNinjaKeysOpenClose(ninjakeys.value);
});
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -88,6 +206,7 @@ onMounted(setCommandBarData);
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@change="onCommandBarChange"
@selected="onSelected"
@closed="onClosed"
/>
@@ -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);