feat: Add natural language date parser for snooze functionality
This commit is contained in:
+2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user