feat: Add natural language date parser for snooze functionality (#13587)
# Pull Request Template ## Description This PR introduces a custom, lightweight natural-language date parser (dependency-free except for date-fns) to power snooze actions via the command bar (e.g., “Remind me tomorrow at 6am”). It also adds support for multi-language searches. <details> <summary>Supported Formats</summary> ## Snooze Date Parser — Supported Input Formats ## 1. Durations Specify an amount of time from now. ### Basic - `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten year` - `in 2 hours` · `in 30 minutes` · `in a week` · `in a month` - `5 minutes from now` · `a week from now` · `two weeks from now` ### Half / fractional - `half hour` · `half day` · `half week` · `half month` - `in half a day` · `in half an hour` · `in half a week` - `one and a half hours` · `in one and a half hours` - `1.5 hours` · `2.5 days` ### Compound - `1 hour and 30 minutes` · `2 hours and 15 minutes` - `2 days at 3pm` · `1 week at 9am` ### Shorthand (no spaces) - `2h` · `30m` · `1h30m` · `2h15m` - `1h30minutes` · `2hr15min` · `1hour30min` ### Informal quantities - `couple hours` · `a couple of days` · `in a couple hours` - `a few minutes` · `in a few hours` · `in a few days` - `fortnight` · `in a fortnight` _(= 2 weeks)_ ### Trailing "later" - `2 days later` · `a week later` · `month later` ### Prefix words (`after` / `within`) - `after 2 hours` · `after 3 days` · `after ten year` - `within a week` · `within 2 hours` ### Recognised word-numbers `a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` · `sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3) --- ## 2. Relative Days - `today` · `tonight` · `tomorrow` - `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` · `tomorrow night` - `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm` - `tomorrow same time` · `same time tomorrow` - `day after tomorrow` · `the day after tomorrow` · `day after tomorrow at 2pm` - `later today` · `later this afternoon` · `later this evening` --- ## 3. Weekdays - `monday` · `friday` · `wed` · `thu` - `friday at 3pm` · `monday 9am` · `wednesday 14:30` - `monday morning` · `friday afternoon` · `wednesday evening` - `monday morning 6` · `friday evening 7` - `this friday` · `upcoming monday` · `coming friday` - `same time friday` · `same time wednesday` --- ## 4. "Next" Patterns - `next hour` · `next week` · `next month` · `next year` - `next week at 2pm` · `next month at 9am` - `next monday` · `next friday` · `next friday at 3pm` - `next monday morning` · `next friday evening` - `monday of next week` · `next week monday` - `next january` · `next december` - `next business day` · `next working day` --- ## 5. Time of Day - `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight` - `this morning` · `this afternoon` · `this evening` - `early morning` · `late evening` · `late night` - `morning at 8am` · `evening 6pm` · `afternoon 2pm` - `eod` · `end of day` · `end of the day` --- ## 6. Standalone Time - **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am` - **24-hour:** `14:30` · `at 14:30` --- ## 7. Named Dates (Month + Day) - `jan 15` · `january 15` · `march 20` · `dec 25` - `jan 1st` · `march 3rd` · `april 2nd` · `december 31st` - `15 march` · `25 dec` _(reversed order)_ - `jan 15 2025` · `dec 25 2025` · `march 20 next year` - `jan 15 at 2pm` · `march 5 at 2pm` - `december 2025` · `january 2024` _(month + year only)_ --- ## 8. Month + Ordinal Patterns Target a specific week or day within a month. ### Week of month - `april first week` · `july 2nd week` · `feb 3rd week` - `first week of april` · `2nd week of july` ### Day of month - `april first day` · `march second day` · `march 5th day` - `third day of march` · `5th day of jan at 2pm` ### Supported ordinals - **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for weeks) - **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh` `eighth` `ninth` `tenth` --- ## 9. Formal / Numeric Dates - **ISO:** `2025-01-15` - **Slash (M/D/Y):** `01/15/2025` - **Dash (D-M-Y):** `15-01-2025` - **Dot (D.M.Y):** `15.01.2025` - Any of the above **+ time:** `2025-01-15 at 3pm` --- ## 10. Special Phrases - `this weekend` · `weekend` · `next weekend` - `end of week` · `end of month` - `end of next week` · `end of next month` - `beginning of next week` · `start of next week` - `beginning of next month` --- ## 11. Noise / Filler Stripping The parser silently removes conversational prefixes so all of these work exactly the same as the bare expression: ``` snooze for 2 hours → 2 hours remind me tomorrow → tomorrow please snooze until friday → friday can you set a reminder for next week → next week schedule this for jan 15 → jan 15 postpone to next monday → next monday defer for 2 days → 2 days delay it by 1 hour → 1 hour ``` ### Recognised filler verbs / prefixes `snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder` · `schedule` · `postpone` · `defer` · `delay` · `push` ### Recognised prepositions (stripped) `on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after` · `within` ### Typo corrections `tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next` --- ## 12. Multi-Language Support The parser supports localised input via translations in `snooze.json`. ### Translatable token categories - **Units:** minute, hour, day, week, month, year _(singular + plural)_ - **Relative days:** tomorrow, day after tomorrow, next week / month, this / next weekend - **Time of day:** morning, afternoon, evening, night, noon, midnight - **Word numbers:** one through ten, twelve, fifteen, twenty, thirty - **Ordinals:** first through fifth - **Structural words:** at, in, of, after, week, day, from now, next year - **Meridiem:** am, pm ### Auto-detected from locale Weekday names and month names are resolved automatically via `Intl.DateTimeFormat` for the user's locale — no manual translation needed. </details> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screenshots** <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -4,16 +4,29 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLocale } from 'shared/composables/useLocale';
|
||||
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
|
||||
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
|
||||
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
|
||||
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
|
||||
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
GENERAL_EVENTS,
|
||||
SNOOZE_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
|
||||
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
|
||||
import {
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { t, tm } = useI18n();
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
const ninjakeys = ref(null);
|
||||
|
||||
@@ -28,48 +41,168 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
|
||||
const SNOOZE_PARENT_IDS = [
|
||||
'snooze_conversation',
|
||||
'snooze_notification',
|
||||
'bulk_action_snooze_conversation',
|
||||
];
|
||||
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
|
||||
|
||||
const hotKeys = computed(() => [
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
]);
|
||||
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
|
||||
const dynamicSnoozeActions = ref([]);
|
||||
const currentCommandRoot = ref(null);
|
||||
|
||||
const placeholder = computed(() =>
|
||||
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
|
||||
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
|
||||
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
|
||||
);
|
||||
|
||||
const SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS));
|
||||
|
||||
const hotKeys = computed(() => {
|
||||
const allActions = [
|
||||
...dynamicSnoozeActions.value,
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
];
|
||||
// When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication
|
||||
if (!dynamicSnoozeActions.value.length) return allActions;
|
||||
return allActions.filter(
|
||||
a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent)
|
||||
);
|
||||
});
|
||||
|
||||
const setCommandBarData = () => {
|
||||
ninjakeys.value.data = hotKeys.value;
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: { action: { title = null, section = null, id = null } = {} } = {},
|
||||
} = item;
|
||||
// Added this condition to prevent setting the selectedSnoozeType to null
|
||||
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
|
||||
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
} else {
|
||||
selectedSnoozeType.value = null;
|
||||
const SNOOZE_EVENT_MAP = {
|
||||
snooze_conversation: CMD_SNOOZE_CONVERSATION,
|
||||
snooze_notification: CMD_SNOOZE_NOTIFICATION,
|
||||
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
};
|
||||
|
||||
const SNOOZE_SECTION_MAP = {
|
||||
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
|
||||
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
};
|
||||
|
||||
const snoozeTranslations = computed(() => {
|
||||
const raw = tm('SNOOZE_PARSER');
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
});
|
||||
|
||||
const buildDynamicSnoozeActions = (search, parentId) => {
|
||||
const suggestions = generateSnoozeSuggestions(search, new Date(), {
|
||||
translations: snoozeTranslations.value,
|
||||
locale: resolvedLocale.value,
|
||||
});
|
||||
if (!suggestions.length) return [];
|
||||
|
||||
const busEvent = SNOOZE_EVENT_MAP[parentId];
|
||||
const section = t(SNOOZE_SECTION_MAP[parentId]);
|
||||
|
||||
return suggestions.map((parsed, index) => ({
|
||||
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
|
||||
title:
|
||||
parsed.label !== parsed.formattedDate
|
||||
? `${parsed.label} - ${parsed.formattedDate}`
|
||||
: parsed.formattedDate,
|
||||
parent: parentId,
|
||||
section,
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
keywords: search,
|
||||
handler: () => {
|
||||
emitter.emit(busEvent, parsed.resolve());
|
||||
useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label });
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetSnoozeState = () => {
|
||||
currentCommandRoot.value = null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
};
|
||||
|
||||
const patchNinjaKeysOpenClose = el => {
|
||||
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
|
||||
section,
|
||||
action: title,
|
||||
});
|
||||
const originalOpen = el.open.bind(el);
|
||||
const originalClose = el.close.bind(el);
|
||||
|
||||
el.open = (...args) => {
|
||||
const [options = {}] = args;
|
||||
currentCommandRoot.value = options.parent || null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
return originalOpen(...args);
|
||||
};
|
||||
|
||||
el.close = (...args) => {
|
||||
resetSnoozeState();
|
||||
return originalClose(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: {
|
||||
action: { title = null, section = null, id = null, children = null } = {},
|
||||
} = {},
|
||||
} = item;
|
||||
|
||||
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
|
||||
|
||||
if (Array.isArray(children) && children.length) {
|
||||
currentCommandRoot.value = id;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
|
||||
setCommandBarData();
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
|
||||
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
|
||||
const onCommandBarChange = item => {
|
||||
const { detail: { search = '', actions = [] } = {} } = item;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
if (actions.length > 0) {
|
||||
const uniqueParents = [
|
||||
...new Set(actions.map(action => action.parent).filter(Boolean)),
|
||||
];
|
||||
if (uniqueParents.length === 1) {
|
||||
currentCommandRoot.value = uniqueParents[0];
|
||||
} else {
|
||||
currentCommandRoot.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
|
||||
!normalizedSearch ||
|
||||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
|
||||
) {
|
||||
dynamicSnoozeActions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
|
||||
normalizedSearch,
|
||||
currentCommandRoot.value
|
||||
);
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
|
||||
store.dispatch('setContextMenuChatId', null);
|
||||
}
|
||||
resetSnoozeState();
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -78,7 +211,10 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(setCommandBarData);
|
||||
onMounted(() => {
|
||||
setCommandBarData();
|
||||
patchNinjaKeysOpenClose(ninjakeys.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||
@@ -88,6 +224,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