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
ab93821d2b fix(agent-bot): stabilize webhook delivery for transient upstream failures (#13521)
This fixes the agent-bot webhook delivery path so transient upstream
failures follow the expected delivery lifecycle. Existing fallback
behavior is preserved, and fallback actions are applied only after
delivery attempts are exhausted.

To reproduce, configure an agent-bot webhook endpoint to return 429/500
for message events. Before this fix, failure handling could be applied
too early; after this fix, delivery attempts complete first and then
existing fallback handling runs.

Tested with:
- bundle exec rspec spec/jobs/agent_bots/webhook_job_spec.rb
spec/lib/webhooks/trigger_spec.rb
- bundle exec rubocop spec/jobs/agent_bots/webhook_job_spec.rb
spec/lib/webhooks/trigger_spec.rb

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-02 14:18:29 +04:00
Shivam MishraandGitHub 8d48e05283 feat: reclaim mobile_v2 flag for report_rollup (#13666) 2026-03-02 13:12:42 +05:30
Sivin VargheseandGitHub 17306e7862 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-28 12:22:05 +05:30
c08fa631a9 feat: Add temporary account setting to disable Captain auto-resolve (#13680)
Add a temporary `captain_disable_auto_resolve` boolean setting on
accounts to prevent Captain from resolving conversations. Guards both
the scheduled resolution job and the assistant's resolve tool.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:37:00 -08:00
14b4c83dc6 fix: Prevent AudioTranscriptionJob from crashing on OpenAI 401 error (#13653)
Describe the bug
In v4.8.0, when an audio message is received, the system enqueues
Messages::AudioTranscriptionJob even if OpenAI and Captain are disabled.
This causes a Faraday::UnauthorizedError (401) which crashes the Sidekiq
job and breaks the pipeline for that message.

To Reproduce
Disable OpenAI/Captain integrations.

Send an audio message to an inbox.

Check Sidekiq logs and observe the 401 crash in
AudioTranscriptionService.

What this PR does
Adds a rescue Faraday::UnauthorizedError block inside
AudioTranscriptionService#perform. Instead of crashing the worker, it
logs a warning and gracefully exits, allowing the job to complete
successfully.

Note: This fixes the backend crash. However, there is still a frontend
reactivity issue where the audio player UI requires an F5 to load the
media, which has been reported in Issue #11013.

---------

Co-authored-by: Eloi Junior Seganfredo <eloi@seganfredo.local>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-27 14:12:03 +04:00
df92fd12cb fix: bot handoff should set waiting time (#13417)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-27 15:31:49 +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
Sojan JoseandGitHub d84ae196d5 fix: call authorization_error! on IMAP auth failures (#13560) (revert) (#13671)
This reverts commit 7acd239c70 to further
debug upstream issues.
2026-02-26 18:45:18 -08:00
Sivin VargheseandGitHub 30443ca132 Merge branch 'develop' into chore/improve-conversation-snooze 2026-02-26 21:43:42 +05:30
Muhsin KelothandGitHub bdcc62f1b0 feat(facebook): Mark Messenger native-app echoes as external echo message (#13665)
When agents send replies from the native Facebook Messenger app (not
Chatwoot), echo events were created without external_echo metadata and
could be misrepresented in the UI. This change updates Messenger echo
message creation to:

- set content_attributes.external_echo = true for outgoing_echo messages
- set echo message status to delivered
- keep sender as nil for echo messages (existing behavior)

<img width="2614" height="1264" alt="CleanShot 2026-02-26 at 16 32
04@2x"
src="https://github.com/user-attachments/assets/ba61c941-465d-4893-814e-855e6b6c79e8"
/>
2026-02-26 19:05:15 +04:00
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
91 changed files with 4712 additions and 634 deletions
-1
View File
@@ -12,7 +12,6 @@ gem 'bootsnap', require: false
gem 'acts-as-taggable-on'
gem 'attr_extras'
gem 'browser'
gem 'counter_culture'
gem 'hashie'
gem 'jbuilder'
gem 'kaminari'
-4
View File
@@ -192,9 +192,6 @@ GEM
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
counter_culture (3.12.1)
activerecord (>= 4.2)
activesupport (>= 4.2)
crack (1.0.0)
bigdecimal
rexml
@@ -1044,7 +1041,6 @@ DEPENDENCIES
cld3 (~> 3.7)
climate_control
commonmarker
counter_culture
csv-safe
database_cleaner
datadog (~> 2.0)
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
+1 -17
View File
@@ -2,9 +2,6 @@ class ConversationFinder
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
# Filter params that affect conversation count
# NOTE: When adding a new filter, add it here to prevent incorrect cache usage
FILTER_PARAMS = %i[inbox_id team_id labels q source_id conversation_type].freeze
SORT_OPTIONS = {
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
@@ -187,23 +184,10 @@ class ConversationFinder
end
def set_count_for_all_conversations
# Use cache only for admins with no filters other than status
use_cache = current_account.feature_enabled?(:counter_cache_optimization) &&
@is_admin &&
FILTER_PARAMS.none? { |key| params[key] } &&
params[:status] != 'all'
all_count = if use_cache
status = params[:status].presence_in(Conversation.statuses.keys) || DEFAULT_STATUS
current_account.public_send("#{status}_conversations_count")
else
@conversations.count
end
[
@conversations.assigned_to(current_user).count,
@conversations.unassigned.count,
all_count
@conversations.count
]
end
@@ -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"
+7
View File
@@ -1,7 +1,14 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error)
end
def perform(url, payload, webhook_type = :agent_bot_webhook)
super(url, payload, webhook_type)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
raise
end
end
@@ -15,7 +15,6 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return false if inbox.channel.in_backoff?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)
+13 -16
View File
@@ -6,29 +6,26 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def perform(channel, interval = 1)
return unless should_fetch_email?(channel)
fetch_emails_with_backoff(channel, interval)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel, interval)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
private
def fetch_emails_with_backoff(channel, interval)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) }
channel.clear_backoff!
rescue Imap::AuthenticationError => e
Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}"
channel.authorization_error!
rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e
Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}"
channel.apply_backoff!
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
end
def should_fetch_email?(channel)
channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff?
channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel, interval)
@@ -1,7 +0,0 @@
class Internal::BackfillConversationCountersJob < ApplicationJob
queue_as :low
def perform(account)
Internal::BackfillConversationCountersService.new(account: account).perform
end
end
+2 -8
View File
@@ -41,6 +41,7 @@ class Account < ApplicationRecord
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
@@ -90,6 +91,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_disable_auto_resolve
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -144,7 +146,6 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_destroy :remove_account_sequences
after_save :backfill_conversation_counters_if_needed
def agents
users.where(account_users: { role: :agent })
@@ -209,13 +210,6 @@ class Account < ApplicationRecord
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
end
def backfill_conversation_counters_if_needed
return unless saved_change_to_feature_flags?
return unless feature_counter_cache_optimization?
Internal::BackfillConversationCountersJob.perform_later(self)
end
def validate_limit_keys
# method overridden in enterprise module
end
-4
View File
@@ -72,10 +72,6 @@ class Channel::Email < ApplicationRecord
imap_enabled && imap_address == 'imap.gmail.com'
end
def backoff_log_identifier
"Error for email channel - #{inbox.id}"
end
private
def ensure_forward_to_email
-70
View File
@@ -1,70 +0,0 @@
# Backoffable provides transient-error retry backoff for models that depend on external services.
#
# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!.
# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that
# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!.
#
# Call clear_backoff! after a successful operation to reset the counter.
module Backoffable
extend ActiveSupport::Concern
def backoff_log_identifier
inbox_id = respond_to?(:inbox) && inbox&.id
inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}"
end
def backoff_retry_count
::Redis::Alfred.get(backoff_retry_count_key).to_i
end
def in_backoff?
val = ::Redis::Alfred.get(backoff_retry_after_key)
val.present? && Time.zone.at(val.to_f) > Time.current
end
def apply_backoff!
new_count = backoff_retry_count + 1
max_interval, max_retries = backoff_limits
if new_count > max_retries
exhaust_backoff(new_count)
else
schedule_backoff_retry(new_count, max_interval, max_retries)
end
end
def clear_backoff!
::Redis::Alfred.delete(backoff_retry_count_key)
::Redis::Alfred.delete(backoff_retry_after_key)
end
private
def backoff_limits
max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i
max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i
[max_interval, (max_interval - 1) + max_count]
end
def exhaust_backoff(new_count)
Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization"
clear_backoff!
prompt_reauthorization!
end
def schedule_backoff_retry(new_count, max_interval, max_retries)
wait_minutes = [new_count, max_interval].min
::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours)
::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours)
Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m"
end
def backoff_retry_count_key
format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
end
def backoff_retry_after_key
format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id)
end
end
-3
View File
@@ -13,8 +13,6 @@
module Reauthorizable
extend ActiveSupport::Concern
include Backoffable
AUTHORIZATION_ERROR_THRESHOLD = 2
# model attribute
@@ -67,7 +65,6 @@ module Reauthorizable
def reauthorized!
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
clear_backoff!
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
+1 -2
View File
@@ -98,8 +98,6 @@ class Conversation < ApplicationRecord
}
belongs_to :account
counter_culture :account,
column_name: proc { |model| "#{model.status}_conversations_count" }
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
@@ -161,6 +159,7 @@ class Conversation < ApplicationRecord
end
def bot_handoff!
update(waiting_since: Time.current) if waiting_since.blank?
open!
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
end
@@ -1 +0,0 @@
class Imap::AuthenticationError < StandardError; end
@@ -107,11 +107,7 @@ class Imap::BaseFetchEmailService
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
begin
imap.authenticate(authentication_type, channel.imap_login, imap_password)
rescue Net::IMAP::NoResponseError => e
raise Imap::AuthenticationError, e.message
end
imap.authenticate(authentication_type, channel.imap_login, imap_password)
imap.select('INBOX')
imap
end
@@ -1,26 +0,0 @@
class Internal::BackfillConversationCountersService
# Backfills the conversation status counter cache columns on accounts
# (open_conversations_count, resolved_conversations_count, pending_conversations_count, snoozed_conversations_count).
#
# Safe to re-run to correct any drift.
#
# Usage:
# Internal::BackfillConversationCountersService.new.perform
# Internal::BackfillConversationCountersService.new(account: Account.find(id)).perform
#
def initialize(account: nil)
@account = account
end
def perform
scope = @account ? Account.where(id: @account.id) : Account.all
scope.find_each do |account|
account.update(
open_conversations_count: account.conversations.open.count,
resolved_conversations_count: account.conversations.resolved.count,
pending_conversations_count: account.conversations.pending.count,
snoozed_conversations_count: account.conversations.snoozed.count
)
end
end
end
+6 -7
View File
@@ -17,10 +17,10 @@
display_name: Facebook Channel
enabled: true
help_url: https://chwt.app/hc/fb
- name: counter_cache_optimization
display_name: Counter Cache Optimization
enabled: false
chatwoot_internal: true
- name: channel_twitter
display_name: Twitter Channel
enabled: true
deprecated: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
@@ -74,10 +74,9 @@
- name: voice_recorder
display_name: Voice Recorder
enabled: true
- name: mobile_v2
display_name: Mobile App V2
- name: report_rollup
display_name: Report Rollup
enabled: false
deprecated: true
- name: channel_website
display_name: Website Channel
enabled: true
-13
View File
@@ -96,19 +96,6 @@
locked: false
# ------- End of Account Related Config ------- #
# ------- Transient Error Backoff Config ------- #
- name: BACKOFF_MAX_INTERVAL_MINUTES
display_title: 'Backoff Max Interval (minutes)'
description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus'
value: 5
locked: false
- name: BACKOFF_MAX_INTERVAL_COUNT
display_title: 'Backoff Max Retry Count'
description: 'Number of additional retries at the maximum interval before prompting reauthorization'
value: 10
locked: false
# ------- End of Transient Error Backoff Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
display_title: 'Inbound Email Domain'
@@ -1,8 +0,0 @@
class AddStatusConversationCountersToAccounts < ActiveRecord::Migration[7.1]
def change
add_column :accounts, :open_conversations_count, :integer, default: 0, null: false
add_column :accounts, :resolved_conversations_count, :integer, default: 0, null: false
add_column :accounts, :pending_conversations_count, :integer, default: 0, null: false
add_column :accounts, :snoozed_conversations_count, :integer, default: 0, null: false
end
end
@@ -1,11 +0,0 @@
# counter_cache_optimization reclaims the deprecated channel_twitter bit (position 4).
# channel_twitter was enabled: true, so all existing accounts have bit 4 set to 1.
# counter_cache_optimization is enabled: false, so we must clear bit 4 for all accounts.
class DisableCounterCacheOptimizationForExistingAccounts < ActiveRecord::Migration[7.0]
def up
Account.feature_counter_cache_optimization.find_each(batch_size: 100) do |account|
account.disable_features(:counter_cache_optimization)
account.save!(validate: false)
end
end
end
@@ -0,0 +1,8 @@
class DisableReportRollupForAllAccounts < ActiveRecord::Migration[7.1]
def up
Account.feature_report_rollup.find_each(batch_size: 100) do |account|
account.disable_features(:report_rollup)
account.save!(validate: false)
end
end
end
+1 -5
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_02_26_125101) do
ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -73,10 +73,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_125101) do
t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.integer "open_conversations_count", default: 0, null: false
t.integer "resolved_conversations_count", default: 0, null: false
t.integer "pending_conversations_count", default: 0, null: false
t.integer "snoozed_conversations_count", default: 0, null: false
t.index ["status"], name: "index_accounts_on_status"
end
@@ -42,10 +42,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def process_response
ActiveRecord::Base.transaction do
if handoff_requested?
process_action('handoff')
else
if handoff_requested?
process_action('handoff')
else
ActiveRecord::Base.transaction do
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
@@ -2,6 +2,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
queue_as :low
def perform(inbox)
return if inbox.account.captain_disable_auto_resolve
Current.executed_by = inbox.captain_assistant
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
@@ -12,6 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
inbox = captain_inbox.inbox
next if inbox.email?
next if inbox.account.captain_disable_auto_resolve
Captain::InboxPendingConversationsResolutionJob.perform_later(
inbox
@@ -19,6 +19,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
{ success: true, transcriptions: transcriptions }
rescue Faraday::UnauthorizedError
Rails.logger.warn('Skipping audio transcription: OpenAI configuration is invalid or disabled (401 Unauthorized).')
{ error: 'OpenAI configuration is invalid or disabled (401)' }
end
private
@@ -6,6 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved?
return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
-6
View File
@@ -16,10 +16,4 @@ module ExceptionList
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
SocketError
].freeze
IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [
EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError,
Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError,
Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError
]).freeze
end
-4
View File
@@ -52,8 +52,4 @@ module Redis::RedisKeys
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
## Transient Error Backoff
BACKOFF_RETRY_COUNT = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_count'.freeze
BACKOFF_RETRY_AFTER = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_after'.freeze
end
+10 -2
View File
@@ -15,9 +15,17 @@ class Webhooks::Trigger
def execute
perform_request
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
raise if @webhook_type == :agent_bot_webhook
handle_failure(e)
rescue StandardError => e
handle_error(e)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}"
handle_failure(e)
end
def handle_failure(error)
handle_error(error)
Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{error.message}"
end
private
-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)
@@ -59,6 +59,36 @@ describe Messages::Facebook::MessageBuilder do
expect(contact.name).to eq(default_name)
end
it 'marks echo messages as external echo messages' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
first_name: 'Jane',
last_name: 'Dae',
account_id: facebook_channel.inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
echo_message_object = {
messaging: {
sender: { id: facebook_channel.page_id },
recipient: { id: '3383290475046708' },
message: { mid: 'm_echo_1', text: 'Echo testing', is_echo: true, app_id: '263902037430900' }
}
}.to_json
echo_message = Integrations::Facebook::MessageParser.new(echo_message_object)
described_class.new(echo_message, facebook_channel.inbox, outgoing_echo: true).perform
message = facebook_channel.inbox.messages.find_by(source_id: 'm_echo_1')
expect(message).to be_present
expect(message.message_type).to eq('outgoing')
expect(message.sender).to be_nil
expect(message.status).to eq('delivered')
expect(message.content_attributes['external_echo']).to be true
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
@@ -92,6 +92,44 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
end
end
# Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the
# same transaction defers the message's after_create_commit until commit, at which
# point it clears waiting_since (bot_response). The handoff path must stay outside
# the transaction so the callback fires before bot_handoff! sets waiting_since.
context 'when handoff is requested' do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:agent) { create(:user, account: account, role: :agent) }
before do
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
end
it 'sets waiting_since to approximately the handoff time' do
freeze_time do
described_class.perform_now(conversation, assistant)
conversation.reload
expect(conversation.status).to eq('open')
expect(conversation.waiting_since).to be_within(1.second).of(Time.current)
end
end
it 'preserves waiting_since so a human reply consumes it for reply_time tracking' do
described_class.perform_now(conversation, assistant)
conversation.reload
expect(conversation.waiting_since).to be_present
# A human reply clears waiting_since (consumed by dispatch_create_events
# to emit FIRST_REPLY_CREATED or REPLY_CREATED for reply_time tracking).
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent, account: account, inbox: inbox)
expect(conversation.reload.waiting_since).to be_nil
end
end
context 'when message contains an image' do
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
@@ -64,4 +64,15 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
}
)
end
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
inbox.account.update!(captain_disable_auto_resolve: true)
expect do
described_class.perform_now(inbox)
end.not_to(change { resolvable_pending_conversation.reload.status })
expect(resolvable_pending_conversation.reload.status).to eq('pending')
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
end
end
@@ -30,6 +30,22 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
end
end
context 'when account has captain_disable_auto_resolve enabled' do
let!(:regular_inbox) { create(:inbox, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
account.update!(captain_disable_auto_resolve: true)
end
it 'does not enqueue resolution jobs' do
expect do
described_class.perform_now
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
.with(regular_inbox)
end
end
context 'when inbox has no captain enabled' do
let!(:inbox_without_captain) { create(:inbox, account: create(:account)) }
@@ -36,6 +36,17 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
end
end
describe 'when auto-resolve is disabled for the account' do
before { account.update!(captain_disable_auto_resolve: true) }
it 'does not resolve and returns a disabled message' do
result = tool.perform(tool_context, reason: 'Possible spam')
expect(result).to eq('Auto-resolve is disabled for this account')
expect(conversation.reload).not_to be_resolved
end
end
describe 'resolving an already resolved conversation' do
let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :resolved) }
+29
View File
@@ -8,6 +8,16 @@ RSpec.describe AgentBots::WebhookJob do
let(:url) { 'https://test.com' }
let(:payload) { { name: 'test' } }
let(:webhook_type) { :agent_bot_webhook }
let(:retryable_error) { RestClient::InternalServerError.new(nil, 500) }
before do
ActiveJob::Base.queue_adapter = :test
end
after do
clear_enqueued_jobs
clear_performed_jobs
end
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
@@ -19,4 +29,23 @@ RSpec.describe AgentBots::WebhookJob do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job }
end
it 'configures retry handlers for 429 and 500 errors' do
handlers = described_class.rescue_handlers.map(&:first)
expect(handlers).to include('RestClient::TooManyRequests', 'RestClient::InternalServerError')
end
it 'retries 3 times and handles failure after retries are exhausted' do
allow(Webhooks::Trigger).to receive(:execute).and_raise(retryable_error)
trigger_instance = instance_double(Webhooks::Trigger, handle_failure: true)
allow(Webhooks::Trigger).to receive(:new).and_return(trigger_instance)
allow(Rails.logger).to receive(:warn)
expect(Webhooks::Trigger).to receive(:execute).exactly(3).times
expect(trigger_instance).to receive(:handle_failure).with(instance_of(RestClient::InternalServerError)).once
expect(Rails.logger).to receive(:warn).with(/AgentBots::WebhookJob/).exactly(3).times
perform_enqueued_jobs { job }
end
end
@@ -38,14 +38,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
end
context 'when channel is in backoff' do
it 'does not fetch emails' do
allow(imap_email_channel).to receive(:in_backoff?).and_return(true)
expect(Imap::FetchEmailService).not_to receive(:new)
described_class.perform_now(imap_email_channel)
end
end
context 'when the channel is regular imap' do
it 'calls the imap fetch service' do
fetch_service = double
@@ -64,17 +56,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
described_class.perform_now(imap_email_channel, 4)
expect(fetch_service).to have_received(:perform)
end
it 'clears backoff after successful fetch' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
allow(imap_email_channel).to receive(:clear_backoff!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:clear_backoff!)
end
end
context 'when the channel is Microsoft' do
@@ -88,37 +69,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
end
context 'when authentication error is raised' do
it 'calls authorization_error! on the channel' do
allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError)
allow(imap_email_channel).to receive(:authorization_error!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:authorization_error!)
end
end
context 'when a transient IMAP error is raised' do
it 'calls apply_backoff! on the channel' do
allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError)
allow(imap_email_channel).to receive(:apply_backoff!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:apply_backoff!)
end
end
context 'when lock acquisition fails' do
it 'does not raise an error' do
lock_manager = instance_double(Redis::LockManager, lock: false)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
expect { described_class.perform_now(imap_email_channel) }.not_to raise_error
end
end
context 'when IMAP OAuth errors out' do
it 'marks the connection as requiring authorization' do
error_response = double
+50
View File
@@ -77,6 +77,40 @@ describe Webhooks::Trigger do
let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) }
let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) }
it 'raises 500 errors for retry and does not reopen conversation immediately' do
payload = { event: 'message_created', id: pending_message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::InternalServerError.new(nil, 500)).once
expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::InternalServerError)
expect(pending_conversation.reload.status).to eq('pending')
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
end
it 'raises 429 errors for retry and does not reopen conversation immediately' do
payload = { event: 'message_created', id: pending_message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::TooManyRequests.new(nil, 429)).once
expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::TooManyRequests)
expect(pending_conversation.reload.status).to eq('pending')
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
end
it 'reopens conversation and enqueues activity message if pending' do
payload = { event: 'message_created', id: pending_message.id }
@@ -166,6 +200,22 @@ describe Webhooks::Trigger do
expect(activity_message.content).to eq(agent_bot_error_content)
end
end
it 'handles 500 without raising for non-agent webhooks' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
expect(RestClient::Request).to receive(:execute)
.with(
method: :post,
url: url,
payload: payload.to_json,
headers: { content_type: :json, accept: :json },
timeout: webhook_timeout
).and_raise(RestClient::InternalServerError.new(nil, 500)).once
expect { trigger.execute(url, payload, webhook_type) }.not_to raise_error
expect(message.reload.status).to eq('failed')
end
end
describe 'request headers' do
-2
View File
@@ -2,14 +2,12 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
require Rails.root.join 'spec/models/concerns/backoffable_shared.rb'
RSpec.describe Channel::Email do
let(:channel) { create(:channel_email) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
it_behaves_like 'backoffable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for email' do
@@ -1,43 +0,0 @@
require 'rails_helper'
shared_examples_for 'backoffable' do
let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) }
before do
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2)
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3)
# max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff!
end
it 'starts with no backoff' do
expect(obj.in_backoff?).to be false
expect(obj.backoff_retry_count).to eq 0
end
it 'ramps backoff on each failure' do
obj.apply_backoff!
expect(obj.backoff_retry_count).to eq 1
expect(obj.in_backoff?).to be true
end
it 'caps wait time at max interval' do
4.times { obj.apply_backoff! }
expect(obj.backoff_retry_count).to eq 4
expect(obj.in_backoff?).to be true
end
it 'exhausts backoff and calls prompt_reauthorization! after max retries' do
allow(obj).to receive(:prompt_reauthorization!)
5.times { obj.apply_backoff! }
expect(obj).to have_received(:prompt_reauthorization!)
expect(obj.backoff_retry_count).to eq 0
expect(obj.in_backoff?).to be false
end
it 'clear_backoff! resets retry count and backoff window' do
obj.apply_backoff!
obj.clear_backoff!
expect(obj.in_backoff?).to be false
expect(obj.backoff_retry_count).to eq 0
end
end
+41
View File
@@ -313,6 +313,47 @@ RSpec.describe Conversation do
end
end
describe '#bot_handoff!' do
let(:conversation) { create(:conversation, status: :pending) }
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
context 'when waiting_since is blank' do
before { conversation.update(waiting_since: nil) }
it 'sets waiting_since to current time' do
freeze_time do
conversation.bot_handoff!
expect(conversation.reload.waiting_since).to eq(Time.current)
end
end
end
context 'when waiting_since is already set' do
let(:original_time) { 1.hour.ago }
before { conversation.update(waiting_since: original_time) }
it 'preserves existing waiting_since' do
conversation.bot_handoff!
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_time)
end
end
it 'changes status to open' do
conversation.bot_handoff!
expect(conversation.reload.status).to eq('open')
end
it 'dispatches CONVERSATION_BOT_HANDOFF event' do
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation))
conversation.bot_handoff!
end
end
describe '#toggle_priority' do
it 'defaults priority to nil when created' do
conversation = create(:conversation, status: 'open')