Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub b3c208dde0 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-03-02 16:49:11 +05:30
Sivin VargheseandGitHub f018bae231 Merge branch 'develop' into chore/improve-conversation-snooze 2026-03-02 16:39:52 +05:30
Sivin VargheseandGitHub 17306e7862 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-28 12:22:05 +05:30
Sivin VargheseandGitHub a7f5003350 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-27 13:44:58 +05:30
Sivin VargheseandGitHub 5fdf96e8d7 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-27 13:44:40 +05:30
Sivin VargheseandGitHub 30443ca132 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-26 21:43:42 +05:30
Sivin VargheseandGitHub 193933be46 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-26 00:15:29 +05:30
Sivin VargheseandGitHub a87f07619c Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-25 19:27:28 +05:30
Sivin VargheseandGitHub f7fc2264c2 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-24 20:38:33 +05:30
Sivin VargheseandGitHub 288a1103de Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-24 16:26:32 +05:30
Sivin VargheseandGitHub 71e5276cad Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-24 16:25:48 +05:30
Sivin VargheseandGitHub 5f16fbf590 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-23 23:37:58 +05:30
iamsivin 1180c9e8b6 chore: Review fix 2026-02-23 23:28:52 +05:30
iamsivin ac060b9cb2 chore: Review fix 2026-02-23 22:42:56 +05:30
Sivin VargheseandGitHub cca473a27e Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-23 22:17:22 +05:30
iamsivin 40005b96cd chore: Remove old snoozeDateParser in favor of multi-lang natural language parser 2026-02-23 22:15:23 +05:30
Sivin VargheseandGitHub 459ee0cd15 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-23 18:25:06 +05:30
iamsivin ffa39aead8 chore: Minor fix 2026-02-23 18:24:34 +05:30
Sivin VargheseandGitHub 79e0b2e18f Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-23 18:11:21 +05:30
Sivin VargheseandGitHub cd0c9b8b04 feat: Add natural language date parser with multi-lang support (#13615) 2026-02-23 18:11:07 +05:30
Sivin VargheseandGitHub 710698155f Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-23 17:04:23 +05:30
Sivin VargheseandGitHub 9e88be1e86 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-23 17:04:11 +05:30
iamsivin acfa318e2a chore: Minor fix 2026-02-23 00:14:27 +05:30
Sivin VargheseandGitHub c65004f794 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-22 23:30:14 +05:30
iamsivin 6f4ad3b81c chore: Review fix 2026-02-22 23:30:00 +05:30
Sivin VargheseandGitHub 99d55326cf Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-22 22:50:59 +05:30
Sivin VargheseandGitHub 09b22394b6 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-20 20:31:30 +05:30
Sivin VargheseandGitHub e376f998fc Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-20 20:31:24 +05:30
Sivin VargheseandGitHub 829014e7e0 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-20 13:41:09 +05:30
Sivin VargheseandGitHub 5ddebec67c Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-20 13:41:01 +05:30
Sivin VargheseandGitHub c8f5a0b2c4 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-20 11:25:30 +05:30
Sivin VargheseandGitHub 039bbf3cd3 Merge branch 'chore/improve-conversation-snooze' into chore/improve-custom-snooze 2026-02-19 21:30:50 +05:30
iamsivin 397448791e chore: Minor fix 2026-02-19 21:30:26 +05:30
iamsivin bde3368a9f chore: Minor fix 2026-02-19 21:25:20 +05:30
iamsivin 68771ce928 feat: Replace vue-datepicker-next from codebase 2026-02-19 20:27:43 +05:30
iamsivin 2f21009609 chore: Fix spec 2026-02-19 18:25:39 +05:30
iamsivin 06219eb785 feat: Enhance natural language date parser 2026-02-19 18:21:57 +05:30
iamsivin ef79c36f78 feat: Add natural language date parser for snooze functionality 2026-02-19 16:41:23 +05:30
50 changed files with 4418 additions and 311 deletions
@@ -12,9 +12,6 @@
// Base styles for elements
@import 'base';
// Plugins
@import 'plugins/date-picker';
html,
body {
font-family:
@@ -0,0 +1,181 @@
<script setup>
import { ref, computed } from 'vue';
import {
startOfMonth,
addMonths,
subMonths,
startOfDay,
isSameDay,
addHours,
addYears,
subYears,
setMonth,
setYear,
} from 'date-fns';
import { CALENDAR_PERIODS } from './helpers/DatePickerHelper';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
import TimePicker from './components/TimePicker.vue';
const props = defineProps({
minDate: {
type: Date,
default: null,
},
});
const emit = defineEmits(['apply', 'clear']);
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const currentDate = ref(new Date());
const selectedDate = ref(null);
const getCurrentTime = () => {
const now = new Date();
return {
hour: now.getHours(),
minute: now.getMinutes(),
second: now.getSeconds(),
};
};
const selectedTime = ref(getCurrentTime());
const calendarView = ref(WEEK);
const calendarDate = ref(startOfMonth(currentDate.value));
const getMinTimeForToday = () => addHours(new Date(), 1);
const effectiveMinDate = computed(() => {
const thresholdDay = startOfDay(getMinTimeForToday());
if (!props.minDate) return thresholdDay;
const propDay = startOfDay(props.minDate);
return propDay > thresholdDay ? propDay : thresholdDay;
});
const minTime = computed(() => {
if (!selectedDate.value) return null;
if (!isSameDay(selectedDate.value, effectiveMinDate.value)) return null;
return getMinTimeForToday();
});
const selectedDateTime = computed(() => {
if (!selectedDate.value) return null;
const date = new Date(selectedDate.value);
date.setHours(
selectedTime.value.hour,
selectedTime.value.minute,
selectedTime.value.second,
0
);
return date;
});
const selectDate = day => {
selectedDate.value = startOfDay(day);
if (isSameDay(day, effectiveMinDate.value)) {
const threshold = getMinTimeForToday();
selectedTime.value = {
hour: threshold.getHours(),
minute: threshold.getMinutes(),
second: 0,
};
} else {
selectedTime.value = { hour: 0, minute: 0, second: 0 };
}
};
const moveCalendar = (direction, period = MONTH) => {
const adjust =
period === YEAR
? { prev: subYears, next: addYears }
: { prev: subMonths, next: addMonths };
calendarDate.value = adjust[direction](calendarDate.value, 1);
};
const setViewMode = (_type, mode) => {
calendarView.value = mode;
};
const openCalendar = (index, _type, period = MONTH) => {
calendarDate.value =
period === MONTH
? setMonth(startOfMonth(calendarDate.value), index)
: setYear(calendarDate.value, index);
calendarView.value = period === MONTH ? WEEK : MONTH;
};
const onApply = () => {
if (selectedDateTime.value) {
emit('apply', selectedDateTime.value);
}
};
const isDefaultState = computed(
() => !selectedDate.value && calendarView.value === WEEK
);
const resetState = () => {
selectedDate.value = null;
selectedTime.value = getCurrentTime();
calendarDate.value = startOfMonth(currentDate.value);
calendarView.value = WEEK;
};
const onClear = () => {
if (isDefaultState.value) {
emit('clear');
return;
}
resetState();
};
defineExpose({ resetState });
</script>
<template>
<div class="flex flex-col select-none font-inter">
<div class="flex w-full gap-3 justify-between">
<div class="flex justify-center py-5">
<div class="flex flex-col items-center gap-2 min-w-[300px]">
<CalendarYear
v-if="calendarView === YEAR"
:start-current-date="calendarDate"
@select-year="openCalendar($event, null, YEAR)"
/>
<CalendarMonth
v-else-if="calendarView === MONTH"
:start-current-date="calendarDate"
@select-month="openCalendar($event)"
@set-view="setViewMode"
@prev="moveCalendar('prev', YEAR)"
@next="moveCalendar('next', YEAR)"
/>
<CalendarWeek
v-else
:current-date="currentDate"
:start-current-date="calendarDate"
:selected-start-date="selectedDate"
:selected-end-date="selectedDate"
:min-date="effectiveMinDate"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar('prev')"
@next="moveCalendar('next')"
/>
</div>
</div>
<div
class="flex justify-center py-2 w-full transition-opacity ltr:border-l rtl:border-r border-n-strong"
:class="selectedDate ? 'opacity-100' : 'opacity-40 pointer-events-none'"
>
<TimePicker v-model="selectedTime" :min-time="minTime" />
</div>
</div>
<div class="border-t border-n-strong">
<CalendarFooter @change="onApply" @clear="onClear" />
</div>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { startOfDay } from 'date-fns';
import {
monthName,
yearName,
@@ -28,6 +29,10 @@ const props = defineProps({
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
minDate: {
type: Date,
default: null,
},
});
const emit = defineEmits([
@@ -41,11 +46,18 @@ const emit = defineEmits([
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const isDayDisabled = day => {
if (!props.minDate) return false;
return startOfDay(day) < startOfDay(props.minDate);
};
const emitHoveredEndDate = day => {
if (isDayDisabled(day)) return;
emit('updateHoveredEndDate', day);
};
const emitSelectDate = day => {
if (isDayDisabled(day)) return;
emit('selectDate', day);
};
const onClickPrev = () => {
@@ -108,8 +120,10 @@ const isNextDayInRange = day => {
const dayClasses = day => ({
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
'text-n-slate-10 pointer-events-none opacity-40':
isInCurrentMonth(day) && isDayDisabled(day),
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
isInCurrentMonth(day),
isInCurrentMonth(day) && !isDayDisabled(day),
'bg-n-brand text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5':
@@ -0,0 +1,430 @@
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import {
TIME_COLUMNS,
TIME_PERIODS,
TIME_FORMATS,
} from '../helpers/DatePickerHelper';
const props = defineProps({
minTime: { type: Date, default: null },
});
const model = defineModel({
type: Object,
default: () => ({ hour: 9, minute: 0, second: 0 }),
});
const { t } = useI18n();
const { HOUR, HOUR_12, MINUTE, SECOND, PERIOD } = TIME_COLUMNS;
const { AM, PM } = TIME_PERIODS;
const { H24, H12 } = TIME_FORMATS;
const ITEM_HEIGHT = 40;
const CENTER_ROW = 2;
const VISIBLE_ROWS = 5;
const SNAP_DELAY = 120;
const SEPARATOR = ':';
const is24HourFormat = ref(false);
const selectedTime = reactive({
hour: model.value.hour,
minute: model.value.minute,
second: model.value.second ?? 0,
});
const activeIndex = reactive({});
const dragOffset = reactive({});
const interactionMode = reactive({});
const touchStartY = {};
const snapTimers = {};
const columnRefs = reactive({});
const focusedColumn = ref(null);
const period = computed(() => (selectedTime.hour >= 12 ? PM : AM));
const showPeriod = computed(() => !is24HourFormat.value);
const minBound = computed(() => {
if (!props.minTime) return null;
const d = props.minTime;
return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
});
const toTimeNumber = (h, m, s) => h * 3600 + m * 60 + s;
const convertTo12Hour = hour => {
if (hour === 0) return 12;
return hour > 12 ? hour - 12 : hour;
};
const convertTo24Hour = (hour12, timePeriod) => {
if (timePeriod === AM) return hour12 === 12 ? 0 : hour12;
return hour12 === 12 ? 12 : hour12 + 12;
};
const isItemDisabled = (key, val) => {
const mb = minBound.value;
if (!mb) return false;
const minVal = toTimeNumber(mb.h, mb.m, mb.s);
const { hour, minute } = selectedTime;
if (key === HOUR) return toTimeNumber(val, 0, 0) < toTimeNumber(mb.h, 0, 0);
if (key === MINUTE) return toTimeNumber(hour, val, 0) < minVal;
if (key === SECOND) return toTimeNumber(hour, minute, val) < minVal;
return false;
};
const formatLabel = value => String(value).padStart(2, '0');
const buildItems = (count, key) =>
Array.from({ length: count }, (_, i) => ({
value: i,
label: formatLabel(i),
disabled: isItemDisabled(key, i),
}));
const buildHour12Items = () =>
Array.from({ length: 12 }, (_, i) => {
const display = i === 0 ? 12 : i;
return {
value: display,
label: formatLabel(display),
disabled: isItemDisabled(HOUR, convertTo24Hour(display, period.value)),
};
});
const periodItems = computed(() => [
{ value: AM, label: AM, disabled: minBound.value?.h >= 12 },
{ value: PM, label: PM, disabled: false },
]);
const columns = computed(() => [
{
key: is24HourFormat.value ? HOUR : HOUR_12,
label: t('DATE_PICKER.HOUR'),
items: is24HourFormat.value ? buildItems(24, HOUR) : buildHour12Items(),
},
{
key: MINUTE,
label: t('DATE_PICKER.MINUTE'),
items: buildItems(60, MINUTE),
},
{
key: SECOND,
label: t('DATE_PICKER.SECOND'),
items: buildItems(60, SECOND),
},
{ key: PERIOD, label: '', items: periodItems.value },
]);
const findColumn = key => columns.value.find(c => c.key === key);
const findNearestEnabled = (items, index) => {
for (let d = 0; d < items.length; d += 1) {
if (index + d < items.length && !items[index + d].disabled)
return index + d;
if (index - d >= 0 && !items[index - d].disabled) return index - d;
}
return index;
};
const getActiveIndex = key => {
if (key === HOUR) return selectedTime.hour;
if (key === HOUR_12) {
const h = convertTo12Hour(selectedTime.hour);
return h === 12 ? 0 : h;
}
if (key === PERIOD) return period.value === AM ? 0 : 1;
return selectedTime[key];
};
const getColumnTranslateY = key => {
const idx = -(activeIndex[key] ?? 0) * ITEM_HEIGHT;
return CENTER_ROW * ITEM_HEIGHT + idx + (dragOffset[key] ?? 0);
};
const getMaxDragOffset = key => {
const column = findColumn(key);
if (!column) return { maxDown: 0, maxUp: 0 };
const currentIdx = activeIndex[key] ?? 0;
const firstEnabled = column.items.findIndex(i => !i.disabled);
const lastEnabled = column.items.findLastIndex(i => !i.disabled);
if (firstEnabled === -1) return { maxDown: 0, maxUp: 0 };
return {
maxDown: (currentIdx - firstEnabled) * ITEM_HEIGHT,
maxUp: (lastEnabled - currentIdx) * ITEM_HEIGHT,
};
};
const clampDragOffset = (key, rawOffset) => {
const { maxDown, maxUp } = getMaxDragOffset(key);
return Math.max(-maxUp, Math.min(maxDown, rawOffset));
};
let lastEmittedSignature = '';
const emitTimeValue = () => {
const sig = `${selectedTime.hour}:${selectedTime.minute}:${selectedTime.second}`;
if (sig === lastEmittedSignature) return;
lastEmittedSignature = sig;
model.value = { ...selectedTime };
};
const applySelection = (key, item) => {
if (key === HOUR_12) {
selectedTime.hour = convertTo24Hour(item.value, period.value);
} else if (key === PERIOD) {
selectedTime.hour = convertTo24Hour(
convertTo12Hour(selectedTime.hour),
item.value
);
} else {
selectedTime[key] = item.value;
}
emitTimeValue();
};
const selectIndex = (key, targetIndex) => {
const column = findColumn(key);
if (!column) return;
const clamped = Math.max(0, Math.min(targetIndex, column.items.length - 1));
const resolved = column.items[clamped]?.disabled
? findNearestEnabled(column.items, clamped)
: clamped;
activeIndex[key] = resolved;
const item = column.items[resolved];
if (item) applySelection(key, item);
};
const selectByValue = (key, value) => {
const column = findColumn(key);
if (!column) return;
const idx = column.items.findIndex(i => i.value === value && !i.disabled);
if (idx !== -1) selectIndex(key, idx);
};
const snapToNearest = key => {
const offset = dragOffset[key] ?? 0;
const steps = Math.round(-offset / ITEM_HEIGHT);
dragOffset[key] = 0;
interactionMode[key] = null;
if (steps !== 0) selectIndex(key, (activeIndex[key] ?? 0) + steps);
};
const isItemSelected = (key, val) => {
if (key === HOUR_12) return convertTo12Hour(selectedTime.hour) === val;
if (key === PERIOD) return period.value === val;
return selectedTime[key] === val;
};
const isColumnHidden = key => key === PERIOD && !showPeriod.value;
const onWheelScroll = (key, event) => {
event.preventDefault();
interactionMode[key] = 'wheel';
dragOffset[key] = clampDragOffset(key, (dragOffset[key] ?? 0) - event.deltaY);
clearTimeout(snapTimers[key]);
snapTimers[key] = setTimeout(() => snapToNearest(key), SNAP_DELAY);
};
const onTouchStart = (key, event) => {
interactionMode[key] = 'touch';
touchStartY[key] = event.touches[0].clientY;
dragOffset[key] = 0;
};
const onTouchMove = (key, event) => {
event.preventDefault();
dragOffset[key] = clampDragOffset(
key,
event.touches[0].clientY - touchStartY[key]
);
};
const onTouchEnd = key => snapToNearest(key);
const visibleColumnKeys = computed(() =>
columns.value.filter(col => !isColumnHidden(col.key)).map(col => col.key)
);
const onKeyDown = (key, event) => {
const { key: pressed, shiftKey } = event;
if (pressed === 'ArrowUp' || pressed === 'ArrowDown') {
event.preventDefault();
selectIndex(
key,
(activeIndex[key] ?? 0) + (pressed === 'ArrowDown' ? 1 : -1)
);
return;
}
if (pressed === 'Tab') {
const keys = visibleColumnKeys.value;
const next = keys.indexOf(key) + (shiftKey ? -1 : 1);
if (next >= 0 && next < keys.length) {
event.preventDefault();
columnRefs[keys[next]]?.focus();
}
}
};
const syncAllColumns = () => {
columns.value.forEach(col => {
activeIndex[col.key] = getActiveIndex(col.key);
});
};
const formatTabs = [
{ label: t('DATE_PICKER.FORMAT_24H'), value: H24 },
{ label: t('DATE_PICKER.FORMAT_12H'), value: H12 },
];
const activeFormatTab = computed(() => (is24HourFormat.value ? 0 : 1));
const onFormatChange = tab => {
is24HourFormat.value = tab.value === H24;
syncAllColumns();
};
watch(model, v => {
selectedTime.hour = v.hour;
selectedTime.minute = v.minute;
selectedTime.second = v.second ?? 0;
syncAllColumns();
});
onMounted(syncAllColumns);
</script>
<template>
<div class="relative flex flex-col items-center py-2 w-full">
<div class="mb-4">
<TabBar
:tabs="formatTabs"
:initial-active-tab="activeFormatTab"
@tab-changed="onFormatChange"
/>
</div>
<div class="flex items-center z-[1] mb-1">
<template v-for="(col, colIdx) in columns" :key="`label-${col.key}`">
<span
class="text-center text-xs font-medium text-n-slate-11 transition-all duration-300 ease-in-out overflow-hidden"
:class="
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
"
>
{{ col.label }}
</span>
<span
v-if="colIdx < columns.length - 1"
class="text-lg font-520 text-transparent transition-all duration-300 ease-in-out overflow-hidden"
:class="
isColumnHidden(columns[colIdx + 1]?.key)
? 'w-0 opacity-0'
: 'opacity-100'
"
>
{{ SEPARATOR }}
</span>
</template>
</div>
<div class="relative w-full">
<div
class="absolute inset-x-6 h-10 rounded-2xl bg-n-solid-active outline outline-1 -outline-offset-1 outline-n-weak pointer-events-none shadow-inner"
:style="{ top: `${CENTER_ROW * ITEM_HEIGHT}px` }"
/>
<div class="flex items-center justify-center z-[1] relative">
<template v-for="(col, colIdx) in columns" :key="col.key">
<div
:ref="
el => {
if (el) columnRefs[col.key] = el;
}
"
:tabindex="isColumnHidden(col.key) ? -1 : 0"
class="time-wheel relative overflow-hidden transition-all duration-300 ease-in-out outline-none"
:class="
isColumnHidden(col.key) ? 'w-0 opacity-0' : 'w-14 opacity-100'
"
:style="{ height: `${VISIBLE_ROWS * ITEM_HEIGHT}px` }"
@wheel.prevent="onWheelScroll(col.key, $event)"
@touchstart="onTouchStart(col.key, $event)"
@touchmove.prevent="onTouchMove(col.key, $event)"
@touchend="onTouchEnd(col.key)"
@keydown="onKeyDown(col.key, $event)"
@focus="focusedColumn = col.key"
@blur="focusedColumn = null"
>
<div
class="flex flex-col items-center"
:class="{
'transition-transform duration-200 ease-out':
!interactionMode[col.key],
'transition-transform duration-100 ease-out':
interactionMode[col.key] === 'wheel',
}"
:style="{
transform: `translateY(${getColumnTranslateY(col.key)}px)`,
}"
>
<button
v-for="item in col.items"
:key="item.value"
:disabled="item.disabled"
tabindex="-1"
class="flex items-center justify-center w-14 shrink-0 text-base font-semibold transition-colors outline-none"
:style="{ height: `${ITEM_HEIGHT}px` }"
:class="[
isItemSelected(col.key, item.value)
? 'text-n-slate-12'
: 'text-n-slate-9',
item.disabled
? 'opacity-20 cursor-not-allowed'
: 'cursor-pointer',
isItemSelected(col.key, item.value) &&
focusedColumn === col.key
? 'outline outline-1 outline-n-brand -outline-offset-4 rounded-xl'
: '',
]"
@click="selectByValue(col.key, item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<span
v-if="colIdx < columns.length - 1 && col.key !== SECOND"
class="text-lg font-520 text-n-slate-11"
>
{{ SEPARATOR }}
</span>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.time-wheel {
mask-image: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 15%,
rgba(0, 0, 0, 0.7) 30%,
black 40%,
black 60%,
rgba(0, 0, 0, 0.7) 70%,
rgba(0, 0, 0, 0.3) 85%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 15%,
rgba(0, 0, 0, 0.7) 30%,
black 40%,
black 60%,
rgba(0, 0, 0, 0.7) 70%,
rgba(0, 0, 0, 0.3) 85%,
transparent 100%
);
}
</style>
@@ -81,6 +81,24 @@ export const CALENDAR_PERIODS = {
YEAR: 'year',
};
export const TIME_COLUMNS = {
HOUR: 'hour',
HOUR_12: 'hour12',
MINUTE: 'minute',
SECOND: 'second',
PERIOD: 'period',
};
export const TIME_PERIODS = {
AM: 'AM',
PM: 'PM',
};
export const TIME_FORMATS = {
H24: '24h',
H12: '12h',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
@@ -107,7 +107,7 @@ defineExpose({ open, close });
<TeleportWithDirection to="body">
<dialog
ref="dialogRef"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl focus-within:outline-none focus-within:outline-0"
:class="[
maxWidthClass,
positionClass,
@@ -1,77 +1,53 @@
<script>
import DatePicker from 'vue-datepicker-next';
import NextButton from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref } from 'vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DatePicker from 'dashboard/components-next/DatePicker/DatePicker.vue';
export default {
components: {
DatePicker,
NextButton,
},
emits: ['close', 'chooseTime'],
const emit = defineEmits(['chooseTime', 'close']);
data() {
return {
snoozeTime: null,
lang: {
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
yearFormat: 'YYYY',
monthFormat: 'MMMM',
},
};
},
const dialogRef = ref(null);
const datePickerRef = ref(null);
const today = new Date();
methods: {
onClose() {
this.$emit('close');
},
chooseTime() {
this.$emit('chooseTime', this.snoozeTime);
},
disabledDate(date) {
// Disable all the previous dates
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return date < yesterday;
},
disabledTime(date) {
// Allow only time after 1 hour
const now = new Date();
now.setHours(now.getHours() + 1);
return date < now;
},
},
const onApply = dateTime => {
dialogRef.value?.close();
emit('chooseTime', dateTime);
};
const onClear = () => {
dialogRef.value?.close();
};
const onDialogClose = () => {
datePickerRef.value?.resetState();
emit('close');
};
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
<form
class="modal-content w-full pt-2 px-5 pb-6"
@submit.prevent="chooseTime"
>
<DatePicker
v-model:value="snoozeTime"
type="datetime"
inline
input-class="mx-input "
:lang="lang"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL')"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:label="$t('CONVERSATION.CUSTOM_SNOOZE.APPLY')"
/>
</div>
</form>
</div>
<Dialog
ref="dialogRef"
:title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')"
:show-confirm-button="false"
:show-cancel-button="false"
width="2xl"
@close="onDialogClose"
>
<DatePicker
ref="datePickerRef"
:min-date="today"
@apply="onApply"
@clear="onClear"
/>
</Dialog>
</template>
@@ -17,7 +17,6 @@ import Modal from './Modal.vue';
import Spinner from 'shared/components/Spinner.vue';
import Tabs from './ui/Tabs/Tabs.vue';
import TabsItem from './ui/Tabs/TabsItem.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
Code,
@@ -37,7 +36,6 @@ const WootUIKit = {
Spinner,
Tabs,
TabsItem,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -1,41 +0,0 @@
<script>
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Array,
default: () => [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
this.$emit('change', value);
},
},
};
</script>
<template>
<div class="date-picker">
<DatePicker
range
confirm
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
@change="handleChange"
/>
</div>
</template>
@@ -1,48 +0,0 @@
<script>
import addDays from 'date-fns/addDays';
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Date,
default: [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
this.$emit('change', value);
},
disableBeforeToday(date) {
const yesterdayDate = addDays(new Date(), -1);
return date < yesterdayDate;
},
},
};
</script>
<template>
<div class="date-picker">
<DatePicker
type="datetime"
confirm
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
:disabled-date="disableBeforeToday"
@change="handleChange"
/>
</div>
</template>
@@ -65,7 +65,6 @@ export default {
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
@@ -99,7 +98,9 @@ export default {
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
this.$refs.snoozeModalRef?.open();
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -111,14 +112,10 @@ export default {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
@@ -249,15 +246,7 @@ export default {
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
<CustomSnoozeModal ref="snoozeModalRef" @choose-time="customSnoozeTime" />
</div>
</template>
@@ -0,0 +1,12 @@
/**
* snoozeDateParser — Natural language date/time parser for snooze.
*
* Barrel re-export from submodules:
* - parser.js: core parsing engine (parseDateFromText)
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
* - suggestions.js: compositional suggestion engine
* - tokenMaps.js: shared token maps and utility functions
*/
export { parseDateFromText } from './parser';
export { generateDateSuggestions } from './localization';
@@ -0,0 +1,380 @@
/**
* Handles non-English input and generates the final suggestion list.
* Translates localized words to English before parsing, then converts
* suggestion labels back to the user's language for display.
*/
import {
WEEKDAY_MAP,
MONTH_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
RELATIVE_DAY_MAP,
TIME_OF_DAY_MAP,
sanitize,
stripNoise,
normalizeDigits,
} from './tokenMaps';
import { parseDateFromText } from './parser';
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
// ─── English Reference Data ─────────────────────────────────────────────────
const EN_WEEKDAYS_LIST = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const EN_MONTHS_LIST = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const EN_DEFAULTS = {
UNITS: {
MINUTE: 'minute',
MINUTES: 'minutes',
HOUR: 'hour',
HOURS: 'hours',
DAY: 'day',
DAYS: 'days',
WEEK: 'week',
WEEKS: 'weeks',
MONTH: 'month',
MONTHS: 'months',
YEAR: 'year',
YEARS: 'years',
},
RELATIVE: {
TOMORROW: 'tomorrow',
DAY_AFTER_TOMORROW: 'day after tomorrow',
NEXT_WEEK: 'next week',
NEXT_MONTH: 'next month',
THIS_WEEKEND: 'this weekend',
NEXT_WEEKEND: 'next weekend',
},
TIME_OF_DAY: {
MORNING: 'morning',
AFTERNOON: 'afternoon',
EVENING: 'evening',
NIGHT: 'night',
NOON: 'noon',
MIDNIGHT: 'midnight',
},
WORD_NUMBERS: {
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
TWELVE: 'twelve',
FIFTEEN: 'fifteen',
TWENTY: 'twenty',
THIRTY: 'thirty',
},
MERIDIEM: { AM: 'am', PM: 'pm' },
HALF: 'half',
NEXT: 'next',
THIS: 'this',
AT: 'at',
IN: 'in',
FROM_NOW: 'from now',
NEXT_YEAR: 'next year',
};
const STRUCTURAL_WORDS = [
'at',
'in',
'next',
'this',
'from',
'now',
'after',
'half',
'same',
'time',
'weekend',
'end',
'of',
'the',
'eod',
'am',
'pm',
];
const ENGLISH_VOCAB = new Set([
...Object.keys(WEEKDAY_MAP),
...Object.keys(MONTH_MAP),
...Object.keys(UNIT_MAP),
...Object.keys(WORD_NUMBER_MAP),
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(TIME_OF_DAY_MAP),
...EN_WEEKDAYS_LIST,
...EN_MONTHS_LIST,
...STRUCTURAL_WORDS,
]);
const ENGLISH_VOCAB_LIST = [...ENGLISH_VOCAB];
const hasVocabPrefix = w => ENGLISH_VOCAB_LIST.some(v => v.startsWith(w));
// ─── Regex for token replacement ────────────────────────────────────────────
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
const NUM_TOD_RE =
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
const TOD_TO_MERIDIEM = {
morning: 'am',
noon: 'pm',
afternoon: 'pm',
evening: 'pm',
night: 'pm',
};
// ─── Translation Cache ──────────────────────────────────────────────────────
const safeString = v => (v == null ? '' : String(v));
const MAX_PAIRS_CACHE = 20;
const pairsCache = new Map();
const CACHE_SECTIONS = [
'UNITS',
'RELATIVE',
'TIME_OF_DAY',
'WORD_NUMBERS',
'MERIDIEM',
];
const SINGLE_KEYS = [
'HALF',
'NEXT',
'THIS',
'AT',
'IN',
'FROM_NOW',
'NEXT_YEAR',
];
/** Create a string key from translations so we can cache results. */
const translationSignature = translations => {
if (!translations || typeof translations !== 'object') return 'none';
return [
...CACHE_SECTIONS.flatMap(section => {
const values = translations[section] || {};
return Object.keys(values)
.sort()
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
}),
...SINGLE_KEYS.map(
k => `${k}:${safeString(translations[k]).toLowerCase()}`
),
].join('|');
};
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
const buildReplacementPairsUncached = (translations, locale) => {
const pairs = [];
const seen = new Set();
const t = translations || {};
const addPair = (local, en) => {
const l = sanitize(safeString(local));
const e = safeString(en).toLowerCase();
const key = `${l}\0${e}`;
if (l && e && l !== e && !seen.has(key)) {
seen.add(key);
pairs.push([l, e]);
}
};
CACHE_SECTIONS.forEach(section => {
const localSection = t[section] || {};
const enSection = EN_DEFAULTS[section] || {};
Object.keys(enSection).forEach(key => {
addPair(localSection[key], enSection[key]);
});
});
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
try {
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
EN_WEEKDAYS_LIST.forEach((en, i) => {
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
});
} catch {
/* locale not supported */
}
try {
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
EN_MONTHS_LIST.forEach((en, i) => {
addPair(moFmt.format(new Date(2024, i, 1)), en);
});
} catch {
/* locale not supported */
}
pairs.sort((a, b) => b[0].length - a[0].length);
return pairs;
};
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
const buildReplacementPairs = (translations, locale) => {
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
const pairs = buildReplacementPairsUncached(translations, locale);
if (pairsCache.size >= MAX_PAIRS_CACHE)
pairsCache.delete(pairsCache.keys().next().value);
pairsCache.set(cacheKey, pairs);
return pairs;
};
// ─── Token Replacement ──────────────────────────────────────────────────────
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Swap localized words for their English versions in the text. */
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
const filterToEnglishVocab = text =>
normalizeDigits(text)
.replace(/(\d+)h\b/g, '$1:00')
.split(/\s+/)
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
const repositionNextYear = text => {
if (!MONTH_NAME_RE.test(text)) return text;
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
/next/i.test(m) ? m : 'next year'
);
if (!/\bnext\s+year\b/i.test(r)) return r;
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
const timePart = withoutNY.match(timeRe);
if (timePart) {
const beforeTime = withoutNY.slice(0, timePart.index).trim();
r = `${beforeTime} next year ${timePart[0].trim()}`;
} else {
r = `${withoutNY} next year`;
}
return r;
};
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
const replaceTokens = (text, pairs) => {
const substituted = substituteLocalTokens(text, pairs);
const filtered = filterToEnglishVocab(substituted);
const fixed = filtered.replace(
NUM_TOD_RE,
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
);
return stripNoise(repositionNextYear(fixed));
};
/** Convert English words back to the user's language for display. */
const reverseTokens = (text, pairs) =>
pairs.reduce(
(r, [local, en]) =>
r.replace(
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
local
),
text
);
// ─── Main Suggestion Generator ──────────────────────────────────────────────
/**
* Generate snooze suggestions from what the user has typed so far.
* Works with any language if translations are provided. Returns up to 5
* unique results, each with a label, date, and unix timestamp.
*
* @param {string} text - what the user typed
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @param {{ translations?: object, locale?: string }} [options] - i18n config
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (
text,
referenceDate = new Date(),
{ translations, locale } = {}
) => {
if (!text || typeof text !== 'string') return [];
const normalized = sanitize(text);
if (!normalized) return [];
const stripped = stripNoise(normalized);
const pairs =
locale && locale !== 'en'
? buildReplacementPairs(translations, locale)
: [];
// Try English first — if user types English in a non-English locale, skip translation
const directParse = parseDateFromText(stripped, referenceDate);
const looksEnglish =
directParse ||
stripped
.split(/\s+/)
.filter(w => !/^\d/.test(w))
.some(w => ENGLISH_VOCAB.has(w) || (w.length >= 2 && hasVocabPrefix(w)));
const useEnglish = !pairs.length || looksEnglish;
const englishInput = useEnglish ? stripped : replaceTokens(normalized, pairs);
const seen = new Set();
const results = [];
const exact = directParse || parseDateFromText(englishInput, referenceDate);
if (exact) {
seen.add(exact.unix);
results.push({ label: normalized, query: englishInput, ...exact });
}
buildSuggestionCandidates(englishInput).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
const label =
!useEnglish && pairs.length
? reverseTokens(candidate, pairs)
: candidate;
results.push({ label, query: candidate, ...result });
}
return false;
});
return results;
};
@@ -0,0 +1,676 @@
/**
* Parses natural language text into a future date.
*
* Flow: clean the input → try each matcher in order → return the first future date.
* The MATCHERS order matters — see the comment above the array.
*/
import {
add,
startOfDay,
getDay,
isSaturday,
isSunday,
nextFriday,
nextSaturday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isAfter,
isBefore,
endOfMonth,
} from 'date-fns';
import {
WEEKDAY_MAP,
MONTH_MAP,
RELATIVE_DAY_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
NEXT_WEEKDAY_FN,
TIME_OF_DAY_MAP,
TOD_HOUR_RANGE,
HALF_UNIT_DURATIONS,
sanitize,
stripNoise,
parseNumber,
parseTimeString,
applyTimeToDate,
applyTimeOrDefault,
strictDate,
futureOrNextYear,
ensureFutureOrNextDay,
inferHoursFromTOD,
addFractionalSafe,
} from './tokenMaps';
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
const UNIT_RE = `(${UNIT_NAMES})`;
const TIME_SUFFIX_RE =
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
'(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
);
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)$`
);
const NEXT_UNIT_RE = new RegExp(
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
);
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
const NEXT_WEEKDAY_TOD_RE = new RegExp(
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const NEXT_WEEKDAY_RE = new RegExp(
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
);
const SAME_TIME_WEEKDAY_RE = new RegExp(
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
);
const WEEKDAY_TOD_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const WEEKDAY_TOD_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const WEEKDAY_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
);
const TIME_ONLY_MERIDIEM_RE =
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
const TOD_WITH_TIME_RE = new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const TOD_PLAIN_RE = new RegExp(
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
);
const ABSOLUTE_DATE_RE = new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
const DAY_AFTER_TOMORROW_RE = new RegExp(
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
);
const COMPOUND_DURATION_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
);
const DURATION_AT_TIME_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
const ISO_DATE_RE = new RegExp(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const AMBIGUOUS_DATE_RES = [SLASH_DATE_RE, DASH_DATE_RE, DOT_DATE_RE];
// ─── Pattern Matchers ───────────────────────────────────────────────────────
/** Read amount and unit from a regex match, then add to now. */
const parseDuration = (match, now) => {
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return addFractionalSafe(now, unit, amount);
};
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
const matchDuration = (text, now) => {
const half = text.match(HALF_UNIT_RE);
if (half) {
return HALF_UNIT_DURATIONS[half[1]]
? add(now, HALF_UNIT_DURATIONS[half[1]])
: null;
}
const compound = text.match(COMPOUND_DURATION_RE);
if (compound) {
const a1 = parseNumber(compound[1]);
const u1 = UNIT_MAP[compound[2]];
const a2 = parseNumber(compound[3]);
const u2 = UNIT_MAP[compound[4]];
if (a1 == null || !u1 || a2 == null || !u2) {
return null;
}
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
}
const atTime = text.match(DURATION_AT_TIME_RE);
if (atTime) {
const amount = parseNumber(atTime[1]);
const unit = UNIT_MAP[atTime[2]];
const time = parseTimeString(atTime[3]);
if (amount == null || !unit || !time) {
return null;
}
return applyTimeToDate(
add(now, { [unit]: amount }),
time.hours,
time.minutes
);
}
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
/** Set time on a day offset. If the result is already past, move to the next day. */
const applyTimeWithRollover = (offset, hours, minutes, now) => {
const base = add(startOfDay(now), { days: offset });
const date = applyTimeToDate(base, hours, minutes);
if (isAfter(date, now)) return date;
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
};
/** Handle "today", "tonight", "tomorrow" with optional time. */
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
if (key === 'tonight' || key === 'tonite') {
return ensureFutureOrNextDay(
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
now
);
}
if (offset === 1) {
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
}
return add(now, { hours: 1 });
}
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
if (dayTodTimeMatch) {
const timeParts = dayTodTimeMatch[3].split(':');
const time = inferHoursFromTOD(
dayTodTimeMatch[2],
timeParts[0],
timeParts[1]
);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
time.hours,
time.minutes,
now
);
}
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
if (dayTodMatch) {
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodMatch[1]],
hours,
minutes,
now
);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
const time = bare
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
: parseTimeString(timeRaw);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayKey],
time.hours,
time.minutes,
now
);
}
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1]];
if (offset <= 0) return null;
return applyTimeToDate(
add(startOfDay(now), { days: offset }),
now.getHours(),
now.getMinutes()
);
}
return null;
};
/** Find the given weekday in next week (not this week). */
const nextWeekdayInNextWeek = (dayIndex, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const date = fn(now);
const sameWeek =
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
startOfWeek(date, { weekStartsOn: 1 }).getTime();
return sameWeek ? fn(date) : date;
};
/** Handle "next friday", "next week", "next month", "next january", etc. */
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(NEXT_UNIT_RE);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const nextMonthMatch = text.match(NEXT_MONTH_RE);
if (nextMonthMatch) {
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
let year = now.getFullYear();
if (monthIdx <= now.getMonth()) year += 1;
const base = new Date(year, monthIdx, 1);
return applyTimeOrDefault(base, nextMonthMatch[2]);
}
// "next monday morning", "next friday midnight" — weekday + time-of-day
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
if (nextTodMatch) {
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
if (!date) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
return applyTimeToDate(date, hours, minutes);
}
// "monday of next week", "next week monday", "next friday" — all with optional time
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
if (weekdayMatch) {
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
if (!date) return null;
return applyTimeOrDefault(date, weekdayMatch[4]);
}
return null;
};
/** Find the next occurrence of a weekday, with optional time. */
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let adjusted = timeStr;
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
const h = parseInt(timeStr, 10);
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
}
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, adjusted);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), adjusted);
};
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
if (todTimeMatch) {
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const timeParts = todTimeMatch[3].split(':');
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
if (!time) return null;
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, time.hours, time.minutes);
return isAfter(date, now)
? date
: applyTimeToDate(fn(now), time.hours, time.minutes);
}
// "monday morning", "friday midnight", "wednesday evening", etc.
const todMatch = text.match(WEEKDAY_TOD_RE);
if (todMatch) {
const dayIndex = WEEKDAY_MAP[todMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, hours, minutes);
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
}
const match = text.match(WEEKDAY_TIME_RE);
if (!match) return null;
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
};
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
const matchTimeOnly = (text, now) => {
const match =
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
};
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(TOD_WITH_TIME_RE);
if (todWithTime) {
const rawTime = todWithTime[2].trim();
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
let time;
if (hasMeridiem) {
time = parseTimeString(rawTime);
const range = TOD_HOUR_RANGE[todWithTime[1]];
if (!time) return null;
if (range) {
const h = time.hours === 0 ? 24 : time.hours;
if (h < range[0] || h >= range[1]) return null;
}
} else {
const parts = rawTime.split(':');
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
}
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
}
const match = text.match(TOD_PLAIN_RE);
if (!match) return null;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
const tod = TIME_OF_DAY_MAP[key];
if (!tod) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours, tod.minutes),
now
);
};
/** Turn month + day + optional year into a future date. */
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
let year = now.getFullYear();
if (yearStr && /next\s+year/i.test(yearStr)) {
year += 1;
} else if (yearStr) {
year = parseInt(yearStr, 10);
}
if (yearStr) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, timeStr);
return date && isAfter(date, now) ? date : null;
}
return futureOrNextYear(year, month, day, timeStr, now);
};
/** Handle "jan 15", "15 march", "december 2025". */
const matchNamedDate = (text, now) => {
const abs = text.match(ABSOLUTE_DATE_RE);
if (abs) {
return resolveAbsoluteDate(
MONTH_MAP[abs[1]],
parseInt(abs[2], 10),
abs[3],
abs[4],
now
);
}
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
if (rev) {
return resolveAbsoluteDate(
MONTH_MAP[rev[2]],
parseInt(rev[1], 10),
rev[3],
rev[4],
now
);
}
const my = text.match(MONTH_YEAR_RE);
if (my) {
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
if (!isValid(date)) return null;
const result = applyTimeToDate(date, 9, 0);
return isAfter(result, now) ? result : null;
}
return null;
};
/** Build a date from year/month/day numbers, with optional time. */
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
return applyTimeOrDefault(date, timeStr);
};
// When both values are ≤ 12 (ambiguous), defaults to M/D (US format).
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 };
};
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
const matchFormalDate = (text, now) => {
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
const isoMatch = text.match(ISO_DATE_RE);
if (isoMatch) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(isoMatch[1], 10),
parseInt(isoMatch[2], 10) - 1,
parseInt(isoMatch[3], 10),
isoMatch[4]
)
);
}
let result = null;
AMBIGUOUS_DATE_RES.some(re => {
const m = text.match(re);
if (!m) return false;
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10)
);
result = ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
return true;
});
return result;
};
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
const matchSpecial = (text, now) => {
const dat = text.match(DAY_AFTER_TOMORROW_RE);
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
const eof = text.match(END_OF_RE);
if (eof) {
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
if (eof[1] === 'week') {
const fri = applyTimeToDate(now, 17, 0);
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
return applyTimeToDate(nextFriday(now), 17, 0);
}
if (eof[1] === 'month') {
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
if (isAfter(eom, now)) return eom;
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
const weekendMatch = text.match(
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
);
if (weekendMatch) {
const isNext = weekendMatch[1] === 'next weekend';
const timeStr = weekendMatch[2];
if (isNext) {
const sat = nextSaturday(now);
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeOrDefault(d, timeStr);
}
if (isSaturday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
}
if (isSunday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
}
return applyTimeOrDefault(nextSaturday(now), timeStr);
}
return null;
};
// ─── Main Parser ────────────────────────────────────────────────────────────
// Order matters — first match wins. Common patterns go first.
// Do not reorder without running the spec.
const MATCHERS = [
matchDuration, // "in 2 hours", "half day", "3h30m"
matchSpecial, // "end of week", "later today", "this weekend"
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
matchNextPattern, // "next friday", "next week", "next month"
matchTimeOfDay, // "morning", "evening 6pm", "eod"
matchWeekday, // "friday", "monday 3pm", "wed morning"
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
matchNamedDate, // "jan 15", "march 20 next year"
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
];
/**
* Parse free-form text into a future date.
* Returns { date, unix } or null. Only returns dates after referenceDate.
*
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @returns {{ date: Date, unix: number } | null}
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = stripNoise(sanitize(text));
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
const isValidFuture = d =>
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
let result = null;
MATCHERS.some(matcher => {
const d = matcher(normalized, referenceDate);
if (isValidFuture(d)) {
result = { date: d, unix: getUnixTime(d) };
return true;
}
return false;
});
return result;
};
@@ -0,0 +1,101 @@
/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...TOD_NAMES.map(tod => `tomorrow ${tod}`),
...TOD_NAMES.map(tod => `tomorrow at ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(text)) {
const num = text.match(/^\d+(?:\.5)?/)[0];
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
const trimmed = text.replace(/\s+/g, ' ').trim();
return trimmed.length > num.length
? candidates.filter(c => c.startsWith(trimmed))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};
@@ -0,0 +1,375 @@
/**
* Shared lookup tables and helper functions used by the parser,
* suggestions, and localization modules.
*/
import {
add,
set,
isValid,
isAfter,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
} from 'date-fns';
// ─── Token Maps ──────────────────────────────────────────────────────────────
// All keys are lowercase. Short forms and full names both work.
/** Weekday name or short form → day index (0 = Sunday). */
export 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,
};
/** Month name or short form → month index (0 = January). */
export 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,
};
/** Words like "today" or "tomorrow" → how many days from now. */
export const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tonite: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
/** Unit shorthand → full unit name used by date-fns. */
export const UNIT_MAP = {
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',
};
/** English number words → their numeric value. */
export 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,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
couple: 2,
few: 3,
};
/** Day index → the date-fns function that finds the next occurrence. */
export const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
/** Time-of-day label → default hour and minute. */
export 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 },
};
/** Allowed hour range per label — used to pick am or pm when not specified. */
export 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],
};
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
export const HALF_UNIT_DURATIONS = {
hour: { minutes: 30 },
day: { hours: 12 },
week: { days: 3, hours: 12 },
month: { days: 15 },
year: { months: 6 },
};
const FRACTIONAL_CONVERT = {
hours: { unit: 'minutes', factor: 60 },
days: { unit: 'hours', factor: 24 },
weeks: { unit: 'days', factor: 7 },
months: { unit: 'days', factor: 30 },
years: { unit: 'months', factor: 12 },
};
// ─── Unicode / Normalization ────────────────────────────────────────────────
// Turn non-ASCII digits and punctuation into plain ASCII so the
// parser only has to deal with standard characters.
const UNICODE_DIGIT_RANGES = [
[0x30, 0x39],
[0x660, 0x669], // Arabic-Indic
[0x6f0, 0x6f9], // Eastern Arabic-Indic
[0x966, 0x96f], // Devanagari
[0x9e6, 0x9ef], // Bengali
[0xa66, 0xa6f], // Gurmukhi
[0xae6, 0xaef], // Gujarati
[0xb66, 0xb6f], // Oriya
[0xbe6, 0xbef], // Tamil
[0xc66, 0xc6f], // Telugu
[0xce6, 0xcef], // Kannada
[0xd66, 0xd6f], // Malayalam
];
const toAsciiDigit = char => {
const code = char.codePointAt(0);
const range = UNICODE_DIGIT_RANGES.find(
([start, end]) => code >= start && code <= end
);
if (!range) return char;
return String(code - range[0]);
};
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
const ARABIC_PUNCT_MAP = {
'\u061f': '?',
'\u060c': ',',
'\u061b': ';',
'\u066b': '.',
};
const NOISE_RE =
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?: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|from)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
export const sanitize = text =>
normalizeDigits(
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
.replace(/[\u064b-\u065f]/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
)
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
export const stripNoise = text =>
text
.replace(NOISE_RE, '')
.replace(APPROX_RE, '')
.replace(/\bnxt\b/g, 'next')
.replace(/\bcouple\s+of\b/g, 'couple')
.replace(/\b(\d+)h(\d+)m?\b/g, '$1 hours $2 minutes')
.replace(/\btomm?orow\b/g, 'tomorrow')
.trim();
// ─── Utility Functions ──────────────────────────────────────────────────────
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
export const parseNumber = str => {
if (!str) return null;
const lower = normalizeDigits(str.toLowerCase().trim());
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
/** Set the time on a date, clearing seconds and milliseconds. */
export const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
export const parseTimeString = timeStr => {
if (!timeStr) return null;
const match = timeStr
.toLowerCase()
.replace(/\s+/g, '')
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
if (!match) return null;
const raw = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem && (raw < 1 || raw > 12)) return null;
const toHours = (h, m) => {
if (m === 'pm' && h < 12) return h + 12;
if (m === 'am' && h === 12) return 0;
return h;
};
const hours = toHours(raw, meridiem);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
};
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
export 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);
};
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
export 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;
};
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
export const futureOrNextYear = (year, month, day, timeStr, now) => {
for (let i = 0; i < 9; i += 1) {
const base = strictDate(year + i, month, day);
if (base) {
const date = applyTimeOrDefault(base, timeStr);
if (!date) return null;
if (isAfter(date, now)) return date;
}
}
return null;
};
/** If the date is already past, push it to the next day. */
export const ensureFutureOrNextDay = (date, now) =>
isAfter(date, now) ? date : add(date, { days: 1 });
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
const h = parseInt(rawHour, 10);
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return { hours: h, minutes: m };
// Try both am and pm interpretations, pick the one in range
const am = h === 12 ? 0 : h;
const pm = h === 12 ? 12 : h + 12;
const inRange = v => v >= range[0] && v < range[1];
if (inRange(am)) return { hours: am, minutes: m };
if (inRange(pm)) return { hours: pm, minutes: m };
const mid = (range[0] + range[1]) / 2;
return {
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
minutes: m,
};
};
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
export const addFractionalSafe = (date, unit, amount) => {
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
if (amount % 1 !== 0.5) return null;
const conv = FRACTIONAL_CONVERT[unit];
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
return add(date, { [unit]: Math.round(amount) });
};
@@ -7,11 +7,16 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import {
generateDateSuggestions,
parseDateFromText,
} from 'dashboard/helper/snoozeDateParser';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -33,65 +38,95 @@ export const findStartOfNextMonth = currentDate => {
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const findNextDay = currentDate => add(currentDate, { days: 1 });
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
export const setHoursToNine = date =>
setSeconds(setMinutes(setHours(date, 9), 0), 0);
const SNOOZE_RESOLVERS = {
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
setHoursToNine(findStartOfNextMonth(d)),
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
const resolve = SNOOZE_RESOLVERS[snoozeType];
return resolve ? getUnixTime(resolve(currentDate)) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
if (!snoozedUntil) return null;
const date = new Date(snoozedUntil);
if (isToday(date)) return format(date, 'h.mmaaa');
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
return format(date, 'd MMM, h.mmaaa');
};
if (isToday(date)) {
return format(date, 'h.mmaaa');
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
const sameYear = isSameYear(snoozeDate, currentDate);
try {
const opts = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...(sameYear ? {} : { year: 'numeric' }),
};
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
} catch {
return sameYear
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const capitalizeLabel = text => text.replace(/^\w/, c => c.toUpperCase());
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date(),
{ translations, locale } = {}
) => {
const suggestions = generateDateSuggestions(searchText, currentDate, {
translations,
locale,
});
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
query: s.query,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
}));
};
const UNIT_SHORT = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
month: 'mo',
months: 'mo',
year: 'y',
years: 'y',
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
if (!snoozedUntil) return null;
return snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
);
return shortenTime;
};
File diff suppressed because it is too large Load Diff
@@ -91,12 +91,26 @@ describe('#Snooze Helpers', () => {
});
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
it('should return formatted date if snoozedUntil is not nil', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should return formatted date with year if snoozedUntil is not in current year', () => {
// Input is 09:00 UTC.
// If your environment is UTC, this will be 9.00am.
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun 2023, 9.00am'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
// This uses 2024 because we mocked the system time above
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -72,6 +73,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -0,0 +1,61 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "دقيقة",
"MINUTES": "دقائق",
"HOUR": "ساعة",
"HOURS": "ساعات",
"DAY": "يوم",
"DAYS": "أيام",
"WEEK": "أسبوع",
"WEEKS": "أسابيع",
"MONTH": "شهر",
"MONTHS": "أشهر",
"YEAR": "سنة",
"YEARS": "سنوات"
},
"HALF": "نصف",
"NEXT": "القادم",
"THIS": "هذا",
"AT": "الساعة",
"IN": "في",
"FROM_NOW": "من الآن",
"NEXT_YEAR": "العام المقبل",
"MERIDIEM": {
"AM": "صباحاً",
"PM": "مساءً"
},
"RELATIVE": {
"TOMORROW": "غداً",
"DAY_AFTER_TOMORROW": "بعد غد",
"NEXT_WEEK": "الأسبوع القادم",
"NEXT_MONTH": "الشهر القادم",
"THIS_WEEKEND": "نهاية هذا الأسبوع",
"NEXT_WEEKEND": "نهاية الأسبوع القادم"
},
"TIME_OF_DAY": {
"MORNING": "صباحاً",
"AFTERNOON": "بعد الظهر",
"EVENING": "مساءً",
"NIGHT": "ليلاً",
"NOON": "ظهراً",
"MIDNIGHT": "منتصف الليل"
},
"WORD_NUMBERS": {
"ONE": "واحد",
"TWO": "اثنان",
"THREE": "ثلاثة",
"FOUR": "أربعة",
"FIVE": "خمسة",
"SIX": "ستة",
"SEVEN": "سبعة",
"EIGHT": "ثمانية",
"NINE": "تسعة",
"TEN": "عشرة",
"TWELVE": "اثنا عشر",
"FIFTEEN": "خمسة عشر",
"TWENTY": "عشرون",
"THIRTY": "ثلاثون"
}
}
}
@@ -5,6 +5,11 @@
"WEEK_NUMBER": "Week #{weekNumber}",
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"HOUR": "Hour",
"MINUTE": "Min",
"SECOND": "Sec",
"FORMAT_12H": "12h",
"FORMAT_24H": "24h",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
@@ -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",
@@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
@@ -77,6 +78,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
@@ -0,0 +1,61 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"HOURS": "hours",
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "year",
"YEARS": "years"
},
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "tomorrow",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"NEXT_WEEK": "next week",
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
},
"TIME_OF_DAY": {
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
},
"WORD_NUMBERS": {
"ONE": "one",
"TWO": "two",
"THREE": "three",
"FOUR": "four",
"FIVE": "five",
"SIX": "six",
"SEVEN": "seven",
"EIGHT": "eight",
"NINE": "nine",
"TEN": "ten",
"TWELVE": "twelve",
"FIFTEEN": "fifteen",
"TWENTY": "twenty",
"THIRTY": "thirty"
}
}
}
@@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -72,6 +73,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -0,0 +1,61 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minuto",
"MINUTES": "minutos",
"HOUR": "hora",
"HOURS": "horas",
"DAY": "día",
"DAYS": "días",
"WEEK": "semana",
"WEEKS": "semanas",
"MONTH": "mes",
"MONTHS": "meses",
"YEAR": "año",
"YEARS": "años"
},
"HALF": "media",
"NEXT": "próximo",
"THIS": "este",
"AT": "a las",
"IN": "en",
"FROM_NOW": "a partir de ahora",
"NEXT_YEAR": "el próximo año",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "mañana",
"DAY_AFTER_TOMORROW": "pasado mañana",
"NEXT_WEEK": "la próxima semana",
"NEXT_MONTH": "el próximo mes",
"THIS_WEEKEND": "este fin de semana",
"NEXT_WEEKEND": "el próximo fin de semana"
},
"TIME_OF_DAY": {
"MORNING": "mañana",
"AFTERNOON": "tarde",
"EVENING": "noche",
"NIGHT": "noche",
"NOON": "mediodía",
"MIDNIGHT": "medianoche"
},
"WORD_NUMBERS": {
"ONE": "uno",
"TWO": "dos",
"THREE": "tres",
"FOUR": "cuatro",
"FIVE": "cinco",
"SIX": "seis",
"SEVEN": "siete",
"EIGHT": "ocho",
"NINE": "nueve",
"TEN": "diez",
"TWELVE": "doce",
"FIFTEEN": "quince",
"TWENTY": "veinte",
"THIRTY": "treinta"
}
}
}
@@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -72,6 +73,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -0,0 +1,58 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "മിനിറ്റ്",
"MINUTES": "മിനിറ്റ്",
"HOUR": "മണിക്കൂർ",
"HOURS": "മണിക്കൂർ",
"DAY": "ദിവസം",
"DAYS": "ദിവസം",
"WEEK": "ആഴ്ച",
"WEEKS": "ആഴ്ച",
"MONTH": "മാസം",
"MONTHS": "മാസം",
"YEAR": "വർഷം",
"YEARS": "വർഷം"
},
"HALF": "അര",
"NEXT": "അടുത്ത",
"THIS": "ഈ",
"AT": "സമയം",
"IN": "കഴിഞ്ഞ്",
"FROM_NOW": "ഇപ്പോൾ മുതൽ",
"NEXT_YEAR": "അടുത്ത വർഷം",
"MERIDIEM": { "AM": "രാവിലെ", "PM": "വൈകുന്നേരം" },
"RELATIVE": {
"TOMORROW": "നാളെ",
"DAY_AFTER_TOMORROW": "മറ്റന്നാൾ",
"NEXT_WEEK": "അടുത്ത ആഴ്ച",
"NEXT_MONTH": "അടുത്ത മാസം",
"THIS_WEEKEND": "ഈ വാരാന്ത്യം",
"NEXT_WEEKEND": "അടുത്ത വാരാന്ത്യം"
},
"TIME_OF_DAY": {
"MORNING": "രാവിലെ",
"AFTERNOON": "ഉച്ചയ്ക്ക്",
"EVENING": "വൈകുന്നേരം",
"NIGHT": "രാത്രി",
"NOON": "ഉച്ച",
"MIDNIGHT": "അർദ്ധരാത്രി"
},
"WORD_NUMBERS": {
"ONE": "ഒന്ന്",
"TWO": "രണ്ട്",
"THREE": "മൂന്ന്",
"FOUR": "നാല്",
"FIVE": "അഞ്ച്",
"SIX": "ആറ്",
"SEVEN": "ഏഴ്",
"EIGHT": "എട്ട്",
"NINE": "ഒൻപത്",
"TEN": "പത്ത്",
"TWELVE": "പന്ത്രണ്ട്",
"FIFTEEN": "പതിനഞ്ച്",
"TWENTY": "ഇരുപത്",
"THIRTY": "മുപ്പത്"
}
}
}
@@ -33,6 +33,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -72,6 +73,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -0,0 +1,61 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minuto",
"MINUTES": "minutos",
"HOUR": "hora",
"HOURS": "horas",
"DAY": "dia",
"DAYS": "dias",
"WEEK": "semana",
"WEEKS": "semanas",
"MONTH": "mês",
"MONTHS": "meses",
"YEAR": "ano",
"YEARS": "anos"
},
"HALF": "meia",
"NEXT": "próximo",
"THIS": "este",
"AT": "às",
"IN": "em",
"FROM_NOW": "a partir de agora",
"NEXT_YEAR": "próximo ano",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "amanhã",
"DAY_AFTER_TOMORROW": "depois de amanhã",
"NEXT_WEEK": "próxima semana",
"NEXT_MONTH": "próximo mês",
"THIS_WEEKEND": "este fim de semana",
"NEXT_WEEKEND": "próximo fim de semana"
},
"TIME_OF_DAY": {
"MORNING": "manhã",
"AFTERNOON": "tarde",
"EVENING": "noite",
"NIGHT": "noite",
"NOON": "meio-dia",
"MIDNIGHT": "meia-noite"
},
"WORD_NUMBERS": {
"ONE": "um",
"TWO": "dois",
"THREE": "três",
"FOUR": "quatro",
"FIVE": "cinco",
"SIX": "seis",
"SEVEN": "sete",
"EIGHT": "oito",
"NINE": "nove",
"TEN": "dez",
"TWELVE": "doze",
"FIFTEEN": "quinze",
"TWENTY": "vinte",
"THIRTY": "trinta"
}
}
}
@@ -13,7 +13,7 @@ import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const showCustomSnoozeModal = ref(false);
const snoozeModalRef = ref(null);
const selectedChat = computed(() => getters.getSelectedChat.value);
const contextMenuChatId = computed(() => getters.getContextMenuChatId.value);
@@ -30,7 +30,9 @@ const toggleStatus = async (status, snoozedUntil) => {
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
snoozeModalRef.value?.open();
} else if (typeof snoozeType === 'number') {
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -40,7 +42,6 @@ const onCmdSnoozeConversation = snoozeType => {
};
const chooseSnoozeTime = customSnoozeTime => {
showCustomSnoozeModal.value = false;
if (customSnoozeTime) {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -49,24 +50,17 @@ const chooseSnoozeTime = customSnoozeTime => {
}
};
const hideCustomSnoozeModal = () => {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
const clearContextMenu = () => {
store.dispatch('setContextMenuChatId', null);
showCustomSnoozeModal.value = false;
};
useEmitter(CMD_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
</script>
<template>
<woot-modal
v-model:show="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
<CustomSnoozeModal
ref="snoozeModalRef"
@choose-time="chooseSnoozeTime"
@close="clearContextMenu"
/>
</template>
@@ -4,6 +4,7 @@ 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';
@@ -11,9 +12,18 @@ 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();
const { t, tm } = useI18n();
const { resolvedLocale } = useLocale();
const ninjakeys = ref(null);
@@ -28,9 +38,26 @@ 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 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 hotKeys = computed(() => [
...dynamicSnoozeActions.value,
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
@@ -42,34 +69,125 @@ 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()),
}));
};
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 +196,10 @@ watchEffect(() => {
}
});
onMounted(setCommandBarData);
onMounted(() => {
setCommandBarData();
patchNinjaKeysOpenClose(ninjakeys.value);
});
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -88,6 +209,7 @@ onMounted(setCommandBarData);
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@change="onCommandBarChange"
@selected="onSelected"
@closed="onClosed"
/>
@@ -34,9 +34,6 @@ export default {
},
},
emits: ['next', 'prev'],
data() {
return { showCustomSnoozeModal: false };
},
computed: {
...mapGetters({ meta: 'notifications/getMeta' }),
},
@@ -51,9 +48,6 @@ export default {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
async snoozeNotification(snoozedUntil) {
try {
await this.$store.dispatch('notifications/snooze', {
@@ -68,14 +62,15 @@ export default {
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
this.$refs.snoozeModalRef?.open();
} else if (typeof snoozeType === 'number') {
this.snoozeNotification(snoozeType);
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
@@ -145,14 +140,9 @@ export default {
@click="deleteNotification"
/>
</div>
<woot-modal
v-model:show="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
<CustomSnoozeModal
ref="snoozeModalRef"
@choose-time="scheduleCustomSnooze"
/>
</div>
</template>
@@ -14,13 +14,13 @@ import {
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
import {
parseReportURLParams,
parseFilterURLParams,
generateCompleteURLParams,
} from '../../helpers/reportFilterHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
const props = defineProps({
showTeamFilter: {
@@ -254,7 +254,7 @@ onMounted(() => {
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker
<DateRangePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
@@ -3,13 +3,13 @@ import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import subDays from 'date-fns/subDays';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import {
generateReportURLParams,
parseReportURLParams,
} from '../helpers/reportFilterHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
defineProps({
disabled: {
@@ -91,7 +91,7 @@ onMounted(() => {
:class="{ 'pointer-events-none opacity-50': disabled }"
>
<div class="flex flex-col flex-wrap items-start gap-2 md:flex-row">
<WootDatePicker
<DateRangePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
@@ -7,10 +7,10 @@ import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import subDays from 'date-fns/subDays';
import differenceInDays from 'date-fns/differenceInDays';
import ActiveFilterChip from './Filters/v3/ActiveFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { GROUP_BY_FILTER } from '../constants';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
import { DATE_RANGE_TYPES } from 'dashboard/components-next/DatePicker/helpers/DatePickerHelper.js';
import {
generateReportURLParams,
parseReportURLParams,
@@ -320,7 +320,7 @@ onMounted(() => {
<template>
<div class="flex flex-col w-full gap-3 lg:flex-row">
<WootDatePicker
<DateRangePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
@@ -2,7 +2,7 @@
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import SLAFilter from '../SLA/SLAFilter.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
import DateRangePicker from 'dashboard/components-next/DatePicker/DateRangePicker.vue';
import { subDays, fromUnixTime } from 'date-fns';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
@@ -88,7 +88,7 @@ onMounted(() => {
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker
<DateRangePicker
v-model:date-range="customDateRange"
v-model:range-type="selectedDateRange"
@date-range-changed="onDateRangeChange"
-1
View File
@@ -98,7 +98,6 @@
"virtua": "^0.48.6",
"vue": "^3.5.12",
"vue-chartjs": "5.3.1",
"vue-datepicker-next": "^1.0.3",
"vue-dompurify-html": "^5.1.0",
"vue-i18n": "9.14.5",
"vue-letter": "^0.2.1",
-18
View File
@@ -214,9 +214,6 @@ importers:
vue-chartjs:
specifier: 5.3.1
version: 5.3.1(chart.js@4.4.4)(vue@3.5.12(typescript@5.6.2))
vue-datepicker-next:
specifier: ^1.0.3
version: 1.0.3(vue@3.5.12(typescript@5.6.2))
vue-dompurify-html:
specifier: ^5.1.0
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
@@ -2042,9 +2039,6 @@ packages:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-format-parse@0.2.7:
resolution: {integrity: sha512-/+lyMUKoRogMuTeOVii6lUwjbVlesN9YRYLzZT/g3TEZ3uD9QnpjResujeEqUW+OSNbT7T1+SYdyEkTcRv+KDQ==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -4606,11 +4600,6 @@ packages:
vue-component-type-helpers@2.1.2:
resolution: {integrity: sha512-URuxnrOhO9lUG4LOAapGWBaa/WOLDzzyAbL+uKZqT7RS+PFy0cdXI2mUSh7GaMts6vtHaeVbGk7trd0FPJi65Q==}
vue-datepicker-next@1.0.3:
resolution: {integrity: sha512-Brqjh896BJGVxP7d6tGDsPMu0SDAB8hAdtG7zWF8VIHJB21dk1VB9KgdajD9Y9uXbg+wHN0vmL7sbMPIyehQVQ==}
peerDependencies:
vue: ^3.0.0
vue-demi@0.13.11:
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
engines: {node: '>=12'}
@@ -6759,8 +6748,6 @@ snapshots:
dependencies:
'@babel/runtime': 7.26.7
date-format-parse@0.2.7: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
@@ -9663,11 +9650,6 @@ snapshots:
vue-component-type-helpers@2.1.2: {}
vue-datepicker-next@1.0.3(vue@3.5.12(typescript@5.6.2)):
dependencies:
date-format-parse: 0.2.7
vue: 3.5.12(typescript@5.6.2)
vue-demi@0.13.11(vue@3.5.12(typescript@5.6.2)):
dependencies:
vue: 3.5.12(typescript@5.6.2)