From 6ca38e10e93f8fb1f499863c65c05d5c435d8c47 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 22 Aug 2025 00:43:34 +0530 Subject: [PATCH] feat: Migrate availability mixins to composable and helper (#11596) # Pull Request Template ## Description **This PR includes:** * Refactored two legacy mixins (`availability.js`, `nextAvailability.js`) into a Vue 3 composable (`useAvailability`), helper module and component based rendering logic. * Fixed an issue where the widget wouldn't load if business hours were enabled but all days were unchecked. * Fixed translation issue [[#11280](https://github.com/chatwoot/chatwoot/issues/11280)](https://github.com/chatwoot/chatwoot/issues/11280). * Reduced code complexity and size. * Added test coverage for both the composable and helper functions. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/2bc3ed694b4349419505e275d14d0b98?sid=22d585e4-0dc7-4242-bcb6-e3edc16e3aee ### Story ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Fixes https://github.com/chatwoot/chatwoot/issues/12012 --------- Co-authored-by: Muhsin Keloth Co-authored-by: Pranav Co-authored-by: Shivam Mishra --- app/javascript/histoire.setup.ts | 25 +- app/javascript/widget/App.vue | 31 +- .../Availability/AvailabilityContainer.vue | 87 +++ .../Availability/AvailabilityText.story.vue | 217 +++++++ .../Availability/AvailabilityText.vue | 178 ++++++ .../widget/components/ChatFooter.vue | 9 +- .../widget/components/ChatHeader.vue | 76 +-- .../widget/components/PreChat/Form.vue | 3 +- .../widget/components/TeamAvailability.vue | 98 +-- .../composables/specs/useAvailability.spec.js | 125 ++++ .../widget/composables/useAvailability.js | 69 +++ .../widget/helpers/availabilityHelpers.js | 289 +++++++++ .../helpers/specs/availabilityHelpers.spec.js | 580 ++++++++++++++++++ app/javascript/widget/i18n/locale/en.json | 10 +- app/javascript/widget/mixins/availability.js | 100 --- .../widget/mixins/nextAvailabilityTime.js | 250 -------- app/javascript/widget/mixins/routerMixin.js | 10 - .../mixins/specs/availabilityMixin.spec.js | 87 --- .../mixins/specs/nextAvailabilityTime.spec.js | 402 ------------ app/javascript/widget/views/Home.vue | 12 +- app/javascript/widget/views/PreChatForm.vue | 10 +- 21 files changed, 1662 insertions(+), 1006 deletions(-) create mode 100644 app/javascript/widget/components/Availability/AvailabilityContainer.vue create mode 100644 app/javascript/widget/components/Availability/AvailabilityText.story.vue create mode 100644 app/javascript/widget/components/Availability/AvailabilityText.vue create mode 100644 app/javascript/widget/composables/specs/useAvailability.spec.js create mode 100644 app/javascript/widget/composables/useAvailability.js create mode 100644 app/javascript/widget/helpers/availabilityHelpers.js create mode 100644 app/javascript/widget/helpers/specs/availabilityHelpers.spec.js delete mode 100644 app/javascript/widget/mixins/availability.js delete mode 100644 app/javascript/widget/mixins/nextAvailabilityTime.js delete mode 100644 app/javascript/widget/mixins/routerMixin.js delete mode 100644 app/javascript/widget/mixins/specs/availabilityMixin.spec.js delete mode 100644 app/javascript/widget/mixins/specs/nextAvailabilityTime.spec.js diff --git a/app/javascript/histoire.setup.ts b/app/javascript/histoire.setup.ts index 7642da78d..1c80a9f85 100644 --- a/app/javascript/histoire.setup.ts +++ b/app/javascript/histoire.setup.ts @@ -1,6 +1,7 @@ import './design-system/histoire.scss'; import { defineSetupVue3 } from '@histoire/plugin-vue'; -import i18nMessages from 'dashboard/i18n'; +import dashboardI18n from 'dashboard/i18n'; +import widgetI18n from 'widget/i18n'; import { createI18n } from 'vue-i18n'; import { vResizeObserver } from '@vueuse/components'; import store from 'dashboard/store'; @@ -9,10 +10,30 @@ import VueDOMPurifyHTML from 'vue-dompurify-html'; import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js'; import { directive as onClickaway } from 'vue3-click-away'; +function mergeMessages(...sources) { + return sources.reduce((acc, src) => { + Object.keys(src).forEach(key => { + if ( + acc[key] && + typeof acc[key] === 'object' && + typeof src[key] === 'object' + ) { + acc[key] = mergeMessages(acc[key], src[key]); + } else { + acc[key] = src[key]; + } + }); + return acc; + }, {}); +} + const i18n = createI18n({ legacy: false, // https://github.com/intlify/vue-i18n/issues/1902 locale: 'en', - messages: i18nMessages, + messages: mergeMessages( + structuredClone(dashboardI18n), + structuredClone(widgetI18n) + ), }); export const setupVue3 = defineSetupVue3(({ app }) => { diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 48409cef2..379b2bc53 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -4,12 +4,10 @@ import { setHeader } from 'widget/helpers/axios'; import addHours from 'date-fns/addHours'; import { IFrameHelper, RNHelper } from 'widget/helpers/utils'; import configMixin from './mixins/configMixin'; -import availabilityMixin from 'widget/mixins/availability'; import { getLocale } from './helpers/urlParamsHelper'; import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import { isEmptyObject } from 'widget/helpers/utils'; import Spinner from 'shared/components/Spinner.vue'; -import routerMixin from './mixins/routerMixin'; import { getExtraSpaceToScroll, loadedEventConfig, @@ -20,6 +18,8 @@ import { ON_UNREAD_MESSAGE_CLICK, } from './constants/widgetBusEvents'; import { useDarkMode } from 'widget/composables/useDarkMode'; +import { useRouter } from 'vue-router'; +import { useAvailability } from 'widget/composables/useAvailability'; import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents'; import { emitter } from 'shared/helpers/mitt'; @@ -28,10 +28,13 @@ export default { components: { Spinner, }, - mixins: [availabilityMixin, configMixin, routerMixin], + mixins: [configMixin], setup() { const { prefersDarkMode } = useDarkMode(); - return { prefersDarkMode }; + const router = useRouter(); + const { isInWorkingHours } = useAvailability(); + + return { prefersDarkMode, router, isInWorkingHours }; }, data() { return { @@ -157,15 +160,17 @@ export default { this.setUnreadView(); }); emitter.on(ON_UNREAD_MESSAGE_CLICK, () => { - this.replaceRoute('messages').then(() => this.unsetUnreadView()); + this.router + .replace({ name: 'messages' }) + .then(() => this.unsetUnreadView()); }); }, registerCampaignEvents() { emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => { if (this.shouldShowPreChatForm) { - this.replaceRoute('prechat-form'); + this.router.replace({ name: 'prechat-form' }); } else { - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); emitter.emit('execute-campaign', { campaignId: this.activeCampaign.id, }); @@ -176,7 +181,7 @@ export default { const { customAttributes, campaignId } = campaignDetails; const { websiteToken } = window.chatwootWebChannel; this.executeCampaign({ campaignId, websiteToken, customAttributes }); - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); }); emitter.on('snooze-campaigns', () => { const expireBy = addHours(new Date(), 1); @@ -192,7 +197,7 @@ export default { !messageCount && !shouldSnoozeCampaign; if (this.isIFrame && isCampaignReadyToExecute) { - this.replaceRoute('campaigns').then(() => { + this.router.replace({ name: 'campaigns' }).then(() => { this.setIframeHeight(true); IFrameHelper.sendMessage({ event: 'setUnreadMode' }); }); @@ -207,7 +212,7 @@ export default { unreadMessageCount > 0 && !this.isWidgetOpen ) { - this.replaceRoute('unread-messages').then(() => { + this.router.replace({ name: 'unread-messages' }).then(() => { this.setIframeHeight(true); IFrameHelper.sendMessage({ event: 'setUnreadMode' }); }); @@ -263,7 +268,7 @@ export default { this.initCampaigns({ currentURL: referrerURL, websiteToken, - isInBusinessHours: this.isInBusinessHours, + isInBusinessHours: this.isInWorkingHours, }); window.referrerURL = referrerURL; this.setReferrerHost(referrerHost); @@ -314,12 +319,12 @@ export default { ['unread-messages', 'campaigns'].includes(this.$route.name); if (shouldShowMessageView) { - this.replaceRoute('messages'); + this.router.replace({ name: 'messages' }); } if (shouldShowHomeView) { this.$store.dispatch('conversation/setUserLastSeen'); this.unsetUnreadView(); - this.replaceRoute('home'); + this.router.replace({ name: 'home' }); } if (!message.isOpen) { this.resetCampaign(); diff --git a/app/javascript/widget/components/Availability/AvailabilityContainer.vue b/app/javascript/widget/components/Availability/AvailabilityContainer.vue new file mode 100644 index 000000000..367564751 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityContainer.vue @@ -0,0 +1,87 @@ + + + + + + + {{ headerText }} + + + + + + + + diff --git a/app/javascript/widget/components/Availability/AvailabilityText.story.vue b/app/javascript/widget/components/Availability/AvailabilityText.story.vue new file mode 100644 index 000000000..aafcb3556 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityText.story.vue @@ -0,0 +1,217 @@ + + + + + + + + + diff --git a/app/javascript/widget/components/Availability/AvailabilityText.vue b/app/javascript/widget/components/Availability/AvailabilityText.vue new file mode 100644 index 000000000..649224d20 --- /dev/null +++ b/app/javascript/widget/components/Availability/AvailabilityText.vue @@ -0,0 +1,178 @@ + + + + + + + {{ replyTimeMessage }} + + + + + {{ + isOnline + ? replyTimeMessage + : t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') + }} + + + + + + {{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }} + + + + + {{ t('REPLY_TIME.BACK_IN_SOME_TIME') }} + + + + + {{ t('REPLY_TIME.BACK_TOMORROW') }} + + + + + {{ + t('REPLY_TIME.BACK_ON_DAY', { + day: dayNames[nextSlot.config.dayOfWeek], + }) + }} + + + + + {{ + t('REPLY_TIME.BACK_IN_MINUTES', { + time: `${roundedMinutesUntilOpen}`, + }) + }} + + + + + {{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }} + + + + + {{ + t('REPLY_TIME.BACK_AT_TIME', { + time: formattedOpeningTime, + }) + }} + + + diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index 2bc6ba7ec..c85727a2b 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -6,7 +6,7 @@ import FooterReplyTo from 'widget/components/FooterReplyTo.vue'; import ChatInputWrap from 'widget/components/ChatInputWrap.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { sendEmailTranscript } from 'widget/api/conversation'; -import routerMixin from 'widget/mixins/routerMixin'; +import { useRouter } from 'vue-router'; import { IFrameHelper } from '../helpers/utils'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { emitter } from 'shared/helpers/mitt'; @@ -17,7 +17,10 @@ export default { CustomButton, FooterReplyTo, }, - mixins: [routerMixin], + setup() { + const router = useRouter(); + return { router }; + }, data() { return { inReplyTo: null, @@ -77,7 +80,7 @@ export default { this.inReplyTo = null; }, startNewConversation() { - this.replaceRoute('prechat-form'); + this.router.replace({ name: 'prechat-form' }); IFrameHelper.sendMessage({ event: 'onEvent', eventIdentifier: CHATWOOT_ON_START_CONVERSATION, diff --git a/app/javascript/widget/components/ChatHeader.vue b/app/javascript/widget/components/ChatHeader.vue index 7c497b70f..578fb984e 100644 --- a/app/javascript/widget/components/ChatHeader.vue +++ b/app/javascript/widget/components/ChatHeader.vue @@ -1,55 +1,26 @@ - @@ -79,9 +50,12 @@ export default { ${isOnline ? 'bg-n-teal-10' : 'hidden'}`" /> - - {{ replyWaitMessage }} - + diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue index 6fe8e45a1..84f0036fd 100644 --- a/app/javascript/widget/components/PreChat/Form.vue +++ b/app/javascript/widget/components/PreChat/Form.vue @@ -6,7 +6,6 @@ import { getContrastingTextColor } from '@chatwoot/utils'; import { isEmptyObject } from 'widget/helpers/utils'; import { getRegexp } from 'shared/helpers/Validators'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; -import routerMixin from 'widget/mixins/routerMixin'; import configMixin from 'widget/mixins/configMixin'; import { FormKit, createInput } from '@formkit/vue'; import PhoneInput from 'widget/components/Form/PhoneInput.vue'; @@ -17,7 +16,7 @@ export default { Spinner, FormKit, }, - mixins: [routerMixin, configMixin], + mixins: [configMixin], props: { options: { type: Object, diff --git a/app/javascript/widget/components/TeamAvailability.vue b/app/javascript/widget/components/TeamAvailability.vue index 866854c09..cc08d34c2 100644 --- a/app/javascript/widget/components/TeamAvailability.vue +++ b/app/javascript/widget/components/TeamAvailability.vue @@ -1,74 +1,27 @@ - @@ -76,17 +29,8 @@ export default { - - - - {{ headerMessage }} - - - {{ replyWaitMessage }} - - - - + + obj); + +vi.mock('widget/helpers/availabilityHelpers', () => ({ + isOnline: (...args) => mockIsOnline(...args), + isInWorkingHours: (...args) => mockIsInWorkingHours(...args), +})); + +vi.mock('dashboard/composables/useTransformKeys', () => ({ + useCamelCase: obj => mockUseCamelCase(obj), +})); + +describe('useAvailability', () => { + const originalWindow = window.chatwootWebChannel; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset mocks to return true by default + mockIsOnline.mockReturnValue(true); + mockIsInWorkingHours.mockReturnValue(true); + mockUseCamelCase.mockImplementation(obj => obj); + + window.chatwootWebChannel = { + workingHours: [], + workingHoursEnabled: false, + timezone: 'UTC', + utcOffset: 'UTC', + replyTime: 'in_a_few_minutes', + }; + }); + + afterEach(() => { + window.chatwootWebChannel = originalWindow; + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { availableAgents, hasOnlineAgents, isInWorkingHours, isOnline } = + useAvailability(); + + expect(availableAgents.value).toEqual([]); + expect(hasOnlineAgents.value).toBe(false); + expect(isInWorkingHours.value).toBe(true); + expect(isOnline.value).toBe(true); + }); + }); + + describe('with agents', () => { + it('should handle agents array', () => { + const agents = [{ id: 1 }, { id: 2 }]; + const { availableAgents, hasOnlineAgents } = useAvailability(agents); + + expect(availableAgents.value).toEqual(agents); + expect(hasOnlineAgents.value).toBe(true); + }); + + it('should handle reactive agents', () => { + const agents = ref([{ id: 1 }]); + const { hasOnlineAgents } = useAvailability(agents); + + expect(hasOnlineAgents.value).toBe(true); + + agents.value = []; + expect(hasOnlineAgents.value).toBe(false); + }); + }); + + describe('working hours', () => { + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + beforeEach(() => { + window.chatwootWebChannel = { + workingHours, + workingHoursEnabled: true, + utcOffset: '+05:30', + }; + }); + + it('should check working hours', () => { + mockIsInWorkingHours.mockReturnValueOnce(true); + const { isInWorkingHours } = useAvailability(); + const result = isInWorkingHours.value; + + expect(result).toBe(true); + expect(mockIsInWorkingHours).toHaveBeenCalledWith( + expect.any(Date), + '+05:30', + workingHours + ); + }); + + it('should determine online status based on working hours and agents', () => { + mockIsOnline.mockReturnValueOnce(true); + const { isOnline } = useAvailability([{ id: 1 }]); + const result = isOnline.value; + + expect(result).toBe(true); + expect(mockIsOnline).toHaveBeenCalledWith( + true, + expect.any(Date), + '+05:30', + workingHours, + true + ); + }); + }); + + describe('config changes', () => { + it('should react to window.chatwootWebChannel changes', () => { + const { inboxConfig } = useAvailability(); + + window.chatwootWebChannel = { + ...window.chatwootWebChannel, + replyTime: 'in_a_day', + }; + + expect(inboxConfig.value.replyTime).toBe('in_a_day'); + }); + }); +}); diff --git a/app/javascript/widget/composables/useAvailability.js b/app/javascript/widget/composables/useAvailability.js new file mode 100644 index 000000000..621584c01 --- /dev/null +++ b/app/javascript/widget/composables/useAvailability.js @@ -0,0 +1,69 @@ +import { computed, toRef } from 'vue'; +import { + isOnline as checkIsOnline, + isInWorkingHours as checkInWorkingHours, +} from 'widget/helpers/availabilityHelpers'; +import { useCamelCase } from 'dashboard/composables/useTransformKeys'; + +const DEFAULT_TIMEZONE = 'UTC'; +const DEFAULT_REPLY_TIME = 'in_a_few_minutes'; + +/** + * Composable for availability-related logic + * @param {Ref|Array} agents - Available agents (can be ref or raw array) + * @returns {Object} Availability utilities and computed properties + */ +export function useAvailability(agents = []) { + const availableAgents = toRef(agents); + + const channelConfig = computed(() => window.chatwootWebChannel || {}); + + const inboxConfig = computed(() => ({ + workingHours: channelConfig.value.workingHours?.map(useCamelCase) || [], + workingHoursEnabled: channelConfig.value.workingHoursEnabled || false, + timezone: channelConfig.value.timezone || DEFAULT_TIMEZONE, + utcOffset: + channelConfig.value.utcOffset || + channelConfig.value.timezone || + DEFAULT_TIMEZONE, + replyTime: channelConfig.value.replyTime || DEFAULT_REPLY_TIME, + })); + + const currentTime = computed(() => new Date()); + + const hasOnlineAgents = computed(() => { + const agentList = availableAgents.value || []; + return Array.isArray(agentList) ? agentList.length > 0 : false; + }); + + const isInWorkingHours = computed(() => + checkInWorkingHours( + currentTime.value, + inboxConfig.value.utcOffset, + inboxConfig.value.workingHours + ) + ); + + // Check if online (considering both working hours and agents) + const isOnline = computed(() => + checkIsOnline( + inboxConfig.value.workingHoursEnabled, + currentTime.value, + inboxConfig.value.utcOffset, + inboxConfig.value.workingHours, + hasOnlineAgents.value + ) + ); + + return { + channelConfig, + inboxConfig, + + currentTime, + availableAgents, + hasOnlineAgents, + + isOnline, + isInWorkingHours, + }; +} diff --git a/app/javascript/widget/helpers/availabilityHelpers.js b/app/javascript/widget/helpers/availabilityHelpers.js new file mode 100644 index 000000000..9af4f1c8d --- /dev/null +++ b/app/javascript/widget/helpers/availabilityHelpers.js @@ -0,0 +1,289 @@ +import { utcToZonedTime } from 'date-fns-tz'; + +// Constants +const DAYS_IN_WEEK = 7; +const MINUTES_IN_HOUR = 60; +const MINUTES_IN_DAY = 24 * 60; + +// --------------------------------------------------------------------------- +// Internal helper utilities +// --------------------------------------------------------------------------- + +/** + * Get date in timezone + * @private + * @param {Date|string} time + * @param {string} utcOffset + * @returns {Date} + */ +const getDateInTimezone = (time, utcOffset) => { + const dateString = time instanceof Date ? time.toISOString() : time; + try { + return utcToZonedTime(dateString, utcOffset); + } catch (error) { + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + // eslint-disable-next-line no-console + console.warn( + `Invalid timezone: ${utcOffset}, falling back to user timezone: ${userTimezone}` + ); + return utcToZonedTime(dateString, userTimezone); + } +}; + +/** + * Convert time to minutes + * @private + * @param {number} hours + * @param {number} minutes + * @returns {number} + */ +const toMinutes = (hours = 0, minutes = 0) => hours * MINUTES_IN_HOUR + minutes; + +/** + * Get today's config + * @private + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {Object|null} + */ +const getTodayConfig = (time, utcOffset, workingHours) => { + const date = getDateInTimezone(time, utcOffset); + const dayOfWeek = date.getDay(); + return workingHours.find(slot => slot.dayOfWeek === dayOfWeek) || null; +}; + +/** + * Check if current time is within working range, handling midnight crossing + * @private + * @param {number} currentMinutes + * @param {number} openMinutes + * @param {number} closeMinutes + * @returns {boolean} + */ +const isTimeWithinRange = (currentMinutes, openMinutes, closeMinutes) => { + const crossesMidnight = closeMinutes <= openMinutes; + + return crossesMidnight + ? currentMinutes >= openMinutes || currentMinutes < closeMinutes + : currentMinutes >= openMinutes && currentMinutes < closeMinutes; +}; + +/** + * Build a map keyed by `dayOfWeek` for all slots that are NOT closed all day. + * @private + * + * @param {Array} workingHours - Full array of working-hour slot configs. + * @returns {Map} Map where the key is the numeric day (0-6) and the value is the slot config. + */ +const getOpenDaysMap = workingHours => + new Map( + (workingHours || []) + .filter(slot => !slot.closedAllDay) + .map(slot => [slot.dayOfWeek, slot]) + ); + +/** + * Determine if today's slot is still upcoming. + * @private + * Returns an object with details if the slot is yet to open, otherwise `null`. + * + * @param {number} currentDay - `Date#getDay()` value (0-6) for current time. + * @param {number} currentMinutes - Minutes since midnight for current time. + * @param {Map} openDays - Map produced by `getOpenDaysMap`. + * @returns {Object|null} Slot details (config, minutesUntilOpen, etc.) or `null`. + */ +const checkTodayAvailability = (currentDay, currentMinutes, openDays) => { + const todayConfig = openDays.get(currentDay); + if (!todayConfig || todayConfig.openAllDay) return null; + + const todayOpenMinutes = toMinutes( + todayConfig.openHour ?? 0, + todayConfig.openMinutes ?? 0 + ); + + // Haven't opened yet today + if (currentMinutes < todayOpenMinutes) { + return { + config: todayConfig, + minutesUntilOpen: todayOpenMinutes - currentMinutes, + daysUntilOpen: 0, + dayOfWeek: currentDay, + }; + } + return null; +}; + +/** + * Search the upcoming days (including tomorrow) for the next open slot. + * @private + * + * @param {number} currentDay - Day index (0-6) representing today. + * @param {number} currentMinutes - Minutes since midnight for current time. + * @param {Map} openDays - Map of open day configs. + * @returns {Object|null} Details of the next slot or `null` if none found. + */ +const findNextSlot = (currentDay, currentMinutes, openDays) => + Array.from({ length: DAYS_IN_WEEK }, (_, i) => i + 1) + .map(daysAhead => { + const targetDay = (currentDay + daysAhead) % DAYS_IN_WEEK; + const config = openDays.get(targetDay); + + if (!config) return null; + + // Calculate minutes until this slot opens + const slotOpenMinutes = config.openAllDay + ? 0 + : toMinutes(config.openHour ?? 0, config.openMinutes ?? 0); + const minutesUntilOpen = + MINUTES_IN_DAY - + currentMinutes + // remaining mins today + (daysAhead - 1) * MINUTES_IN_DAY + // full days between + slotOpenMinutes; // opening on target day + + return { + config, + minutesUntilOpen, + daysUntilOpen: daysAhead, + dayOfWeek: targetDay, + }; + }) + .find(Boolean) || null; + +// --------------------------------------------------------------------------- +// Exported functions +// --------------------------------------------------------------------------- + +/** + * Check if open all day + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {boolean} + */ +export const isOpenAllDay = (time, utcOffset, workingHours = []) => { + const todayConfig = getTodayConfig(time, utcOffset, workingHours); + return todayConfig?.openAllDay === true; +}; + +/** + * Check if closed all day + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {boolean} + */ +export const isClosedAllDay = (time, utcOffset, workingHours = []) => { + const todayConfig = getTodayConfig(time, utcOffset, workingHours); + return todayConfig?.closedAllDay === true; +}; + +/** + * Check if in working hours + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {boolean} + */ +export const isInWorkingHours = (time, utcOffset, workingHours = []) => { + if (!workingHours.length) return false; + + const todayConfig = getTodayConfig(time, utcOffset, workingHours); + if (!todayConfig) return false; + + // Handle all-day states + if (todayConfig.openAllDay) return true; + if (todayConfig.closedAllDay) return false; + + // Check time-based availability + const date = getDateInTimezone(time, utcOffset); + const currentMinutes = toMinutes(date.getHours(), date.getMinutes()); + + const openMinutes = toMinutes( + todayConfig.openHour ?? 0, + todayConfig.openMinutes ?? 0 + ); + const closeMinutes = toMinutes( + todayConfig.closeHour ?? 0, + todayConfig.closeMinutes ?? 0 + ); + + return isTimeWithinRange(currentMinutes, openMinutes, closeMinutes); +}; + +/** + * Find next available slot with detailed information + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {Object|null} + */ +export const findNextAvailableSlotDetails = ( + time, + utcOffset, + workingHours = [] +) => { + const date = getDateInTimezone(time, utcOffset); + const currentDay = date.getDay(); + const currentMinutes = toMinutes(date.getHours(), date.getMinutes()); + + const openDays = getOpenDaysMap(workingHours); + + // No open days at all + if (openDays.size === 0) return null; + + // Check today first + const todaySlot = checkTodayAvailability( + currentDay, + currentMinutes, + openDays + ); + if (todaySlot) return todaySlot; + + // Find next slot + return findNextSlot(currentDay, currentMinutes, openDays); +}; + +/** + * Find minutes until next available slot + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @returns {number|null} + */ +export const findNextAvailableSlotDiff = ( + time, + utcOffset, + workingHours = [] +) => { + if (isInWorkingHours(time, utcOffset, workingHours)) { + return 0; + } + + const nextSlot = findNextAvailableSlotDetails(time, utcOffset, workingHours); + return nextSlot ? nextSlot.minutesUntilOpen : null; +}; + +/** + * Check if online + * @param {boolean} workingHoursEnabled + * @param {Date|string} time + * @param {string} utcOffset + * @param {Array} workingHours + * @param {boolean} hasOnlineAgents + * @returns {boolean} + */ +export const isOnline = ( + workingHoursEnabled, + time, + utcOffset, + workingHours, + hasOnlineAgents +) => { + if (!workingHoursEnabled) { + return hasOnlineAgents; + } + + const inWorkingHours = isInWorkingHours(time, utcOffset, workingHours); + return inWorkingHours && hasOnlineAgents; +}; diff --git a/app/javascript/widget/helpers/specs/availabilityHelpers.spec.js b/app/javascript/widget/helpers/specs/availabilityHelpers.spec.js new file mode 100644 index 000000000..867e30dcc --- /dev/null +++ b/app/javascript/widget/helpers/specs/availabilityHelpers.spec.js @@ -0,0 +1,580 @@ +import { utcToZonedTime } from 'date-fns-tz'; +import { + isOpenAllDay, + isClosedAllDay, + isInWorkingHours, + findNextAvailableSlotDetails, + findNextAvailableSlotDiff, + isOnline, +} from '../availabilityHelpers'; + +// Mock date-fns-tz +vi.mock('date-fns-tz', () => ({ + utcToZonedTime: vi.fn(), +})); + +describe('availabilityHelpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isOpenAllDay', () => { + it('should return true when slot is marked as open_all_day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); // Monday + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openAllDay: true }]; + + expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(true); + }); + + it('should return false when slot is not open_all_day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17, openAllDay: false }, + ]; + + expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should return false when no config exists for the day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Tuesday config + ]; + + expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false); + }); + }); + + describe('isClosedAllDay', () => { + it('should return true when slot is marked as closed_all_day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, closedAllDay: true }]; + + expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(true); + }); + + it('should return false when slot is not closed_all_day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17, closedAllDay: false }, + ]; + + expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should return false when no config exists for the day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 2, openHour: 9, closeHour: 17 }]; + + expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false); + }); + }); + + describe('isInWorkingHours', () => { + it('should return false when no working hours are configured', () => { + expect(isInWorkingHours(new Date(), 'UTC', [])).toBe(false); + }); + + it('should return true when open_all_day is true', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openAllDay: true }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true); + }); + + it('should return false when closed_all_day is true', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, closedAllDay: true }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should return true when current time is within working hours', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { + dayOfWeek: 1, + openHour: 9, + openMinutes: 0, + closeHour: 17, + closeMinutes: 0, + }, + ]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true); + }); + + it('should return false when current time is before opening', () => { + const mockDate = new Date('2024-01-15T08:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(8); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should return false when current time is after closing', () => { + const mockDate = new Date('2024-01-15T18:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(18); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should handle minutes in time comparison', () => { + const mockDate = new Date('2024-01-15T09:30:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(9); + mockDate.getMinutes = vi.fn().mockReturnValue(30); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { + dayOfWeek: 1, + openHour: 9, + openMinutes: 15, + closeHour: 17, + closeMinutes: 30, + }, + ]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true); + }); + + it('should return false when no config for current day', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Only Tuesday + ]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false); + }); + }); + + describe('findNextAvailableSlotDetails', () => { + it('should return null when no open days exist', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 0, closedAllDay: true }, + { dayOfWeek: 1, closedAllDay: true }, + { dayOfWeek: 2, closedAllDay: true }, + { dayOfWeek: 3, closedAllDay: true }, + { dayOfWeek: 4, closedAllDay: true }, + { dayOfWeek: 5, closedAllDay: true }, + { dayOfWeek: 6, closedAllDay: true }, + ]; + + expect( + findNextAvailableSlotDetails(new Date(), 'UTC', workingHours) + ).toBe(null); + }); + + it('should return today slot when not opened yet', () => { + const mockDate = new Date('2024-01-15T08:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(8); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, openMinutes: 30, closeHour: 17 }, + ]; + + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + expect(result).toEqual({ + config: workingHours[0], + minutesUntilOpen: 90, // 1.5 hours = 90 minutes + daysUntilOpen: 0, + dayOfWeek: 1, + }); + }); + + it('should return tomorrow slot when today is past closing', () => { + const mockDate = new Date('2024-01-15T18:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(18); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17 }, + { dayOfWeek: 2, openHour: 9, closeHour: 17 }, + ]; + + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + expect(result).toEqual({ + config: workingHours[1], + minutesUntilOpen: 900, // 15 hours = 900 minutes + daysUntilOpen: 1, + dayOfWeek: 2, + }); + }); + + it('should skip closed days and find next open day', () => { + const mockDate = new Date('2024-01-15T18:00:00.000Z'); // Monday evening + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(18); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17 }, + { dayOfWeek: 2, closedAllDay: true }, + { dayOfWeek: 3, closedAllDay: true }, + { dayOfWeek: 4, openHour: 10, closeHour: 16 }, + ]; + + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + // Monday 18:00 to Thursday 10:00 + // Rest of Monday: 6 hours (18:00 to 24:00) = 360 minutes + // Tuesday: 24 hours = 1440 minutes + // Wednesday: 24 hours = 1440 minutes + // Thursday morning: 10 hours = 600 minutes + // Total: 360 + 1440 + 1440 + 600 = 3840 minutes + expect(result).toEqual({ + config: workingHours[3], + minutesUntilOpen: 3840, // 64 hours = 3840 minutes + daysUntilOpen: 3, + dayOfWeek: 4, + }); + }); + + it('should handle open_all_day slots', () => { + const mockDate = new Date('2024-01-15T18:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(18); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17 }, + { dayOfWeek: 2, openAllDay: true }, + ]; + + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + expect(result).toEqual({ + config: workingHours[1], + minutesUntilOpen: 360, // 6 hours to midnight = 360 minutes + daysUntilOpen: 1, + dayOfWeek: 2, + }); + }); + + it('should wrap around week correctly', () => { + const mockDate = new Date('2024-01-20T18:00:00.000Z'); // Saturday evening + mockDate.getDay = vi.fn().mockReturnValue(6); + mockDate.getHours = vi.fn().mockReturnValue(18); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openHour: 9, closeHour: 17 }, // Monday + ]; + + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + // Saturday 18:00 to Monday 9:00 + // Rest of Saturday: 6 hours = 360 minutes + // Sunday: 24 hours = 1440 minutes + // Monday morning: 9 hours = 540 minutes + // Total: 360 + 1440 + 540 = 2340 minutes + expect(result).toEqual({ + config: workingHours[0], + minutesUntilOpen: 2340, // 39 hours = 2340 minutes + daysUntilOpen: 2, + dayOfWeek: 1, + }); + }); + + it('should handle today open_all_day correctly', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 1, openAllDay: true }, + { dayOfWeek: 2, openHour: 9, closeHour: 17 }, + ]; + + // Should skip today since it's open_all_day and look for next slot + const result = findNextAvailableSlotDetails( + new Date(), + 'UTC', + workingHours + ); + + expect(result).toEqual({ + config: workingHours[1], + minutesUntilOpen: 1380, // Rest of today + 9 hours tomorrow = 1380 minutes + daysUntilOpen: 1, + dayOfWeek: 2, + }); + }); + }); + + describe('findNextAvailableSlotDiff', () => { + it('should return 0 when currently in working hours', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe( + 0 + ); + }); + + it('should return minutes until next slot when not in working hours', () => { + const mockDate = new Date('2024-01-15T08:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(8); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe( + 60 + ); + }); + + it('should return null when no next slot available', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [ + { dayOfWeek: 0, closedAllDay: true }, + { dayOfWeek: 1, closedAllDay: true }, + { dayOfWeek: 2, closedAllDay: true }, + { dayOfWeek: 3, closedAllDay: true }, + { dayOfWeek: 4, closedAllDay: true }, + { dayOfWeek: 5, closedAllDay: true }, + { dayOfWeek: 6, closedAllDay: true }, + ]; + + expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe( + null + ); + }); + }); + + describe('isOnline', () => { + it('should return agent status when working hours disabled', () => { + expect(isOnline(false, new Date(), 'UTC', [], true)).toBe(true); + expect(isOnline(false, new Date(), 'UTC', [], false)).toBe(false); + }); + + it('should check both working hours and agents when enabled', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + // In working hours + agents available = online + expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true); + + // In working hours but no agents = offline + expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe( + false + ); + }); + + it('should return false when outside working hours even with agents', () => { + const mockDate = new Date('2024-01-15T08:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(8); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(false); + }); + + it('should handle open_all_day with agents', () => { + const mockDate = new Date('2024-01-15T02:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(2); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openAllDay: true }]; + + expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true); + expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe( + false + ); + }); + + it('should handle string date input', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect( + isOnline(true, '2024-01-15T10:00:00.000Z', 'UTC', workingHours, true) + ).toBe(true); + }); + }); + + describe('Timezone handling', () => { + it('should correctly handle different timezones', () => { + const mockDate = new Date('2024-01-15T15:30:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(15); + mockDate.getMinutes = vi.fn().mockReturnValue(30); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), 'Asia/Kolkata', workingHours)).toBe( + true + ); + expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith( + expect.any(String), + 'Asia/Kolkata' + ); + }); + + it('should handle UTC offset format', () => { + const mockDate = new Date('2024-01-15T10:00:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(10); + mockDate.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), '+05:30', workingHours)).toBe(true); + expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith( + expect.any(String), + '+05:30' + ); + }); + }); + + describe('Edge cases', () => { + it('should handle working hours at exact boundaries', () => { + // Test at exact opening time + const mockDate1 = new Date('2024-01-15T09:00:00.000Z'); + mockDate1.getDay = vi.fn().mockReturnValue(1); + mockDate1.getHours = vi.fn().mockReturnValue(9); + mockDate1.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate1); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true); + + // Test at exact closing time + const mockDate2 = new Date('2024-01-15T17:00:00.000Z'); + mockDate2.getDay = vi.fn().mockReturnValue(1); + mockDate2.getHours = vi.fn().mockReturnValue(17); + mockDate2.getMinutes = vi.fn().mockReturnValue(0); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate2); + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false); + }); + + it('should handle one minute before closing', () => { + const mockDate = new Date('2024-01-15T16:59:00.000Z'); + mockDate.getDay = vi.fn().mockReturnValue(1); + mockDate.getHours = vi.fn().mockReturnValue(16); + mockDate.getMinutes = vi.fn().mockReturnValue(59); + vi.mocked(utcToZonedTime).mockReturnValue(mockDate); + + const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }]; + + expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true); + }); + }); +}); diff --git a/app/javascript/widget/i18n/locale/en.json b/app/javascript/widget/i18n/locale/en.json index 4f244c566..c3d6ddfc1 100644 --- a/app/javascript/widget/i18n/locale/en.json +++ b/app/javascript/widget/i18n/locale/en.json @@ -19,13 +19,19 @@ }, "TEAM_AVAILABILITY": { "ONLINE": "We are online", - "OFFLINE": "We are away at the moment" + "OFFLINE": "We are away at the moment", + "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible" }, "REPLY_TIME": { "IN_A_FEW_MINUTES": "Typically replies in a few minutes", "IN_A_FEW_HOURS": "Typically replies in a few hours", "IN_A_DAY": "Typically replies in a day", - "BACK_IN": "We will be back online" + "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours", + "BACK_IN_MINUTES": "We will be back online in {time} minutes", + "BACK_AT_TIME": "We will be back online at {time}", + "BACK_ON_DAY": "We will be back online on {day}", + "BACK_TOMORROW": "We will be back online tomorrow", + "BACK_IN_SOME_TIME": "We will be back online in some time" }, "DAY_NAMES": { "SUNDAY": "Sunday", diff --git a/app/javascript/widget/mixins/availability.js b/app/javascript/widget/mixins/availability.js deleted file mode 100644 index 746a5e095..000000000 --- a/app/javascript/widget/mixins/availability.js +++ /dev/null @@ -1,100 +0,0 @@ -import { utcToZonedTime } from 'date-fns-tz'; -import { isTimeAfter } from 'shared/helpers/DateHelper'; - -export default { - computed: { - channelConfig() { - return window.chatwootWebChannel; - }, - replyTime() { - return window.chatwootWebChannel.replyTime; - }, - replyTimeStatus() { - switch (this.replyTime) { - case 'in_a_few_minutes': - return this.$t('REPLY_TIME.IN_A_FEW_MINUTES'); - case 'in_a_few_hours': - return this.$t('REPLY_TIME.IN_A_FEW_HOURS'); - case 'in_a_day': - return this.$t('REPLY_TIME.IN_A_DAY'); - default: - return this.$t('REPLY_TIME.IN_A_FEW_HOURS'); - } - }, - replyWaitMessage() { - const { workingHoursEnabled } = this.channelConfig; - if (workingHoursEnabled) { - return this.isOnline - ? this.replyTimeStatus - : `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`; - } - return this.isOnline - ? this.replyTimeStatus - : this.$t('TEAM_AVAILABILITY.OFFLINE'); - }, - outOfOfficeMessage() { - return this.channelConfig.outOfOfficeMessage; - }, - isInBetweenTheWorkingHours() { - const { - openHour, - openMinute, - closeHour, - closeMinute, - closedAllDay, - openAllDay, - } = this.currentDayAvailability; - - if (openAllDay) { - return true; - } - - if (closedAllDay) { - return false; - } - - const { utcOffset } = this.channelConfig; - const today = this.getDateWithOffset(utcOffset); - const currentHours = today.getHours(); - const currentMinutes = today.getMinutes(); - const isAfterStartTime = isTimeAfter( - currentHours, - currentMinutes, - openHour, - openMinute - ); - const isBeforeEndTime = isTimeAfter( - closeHour, - closeMinute, - currentHours, - currentMinutes - ); - return isAfterStartTime && isBeforeEndTime; - }, - currentDayAvailability() { - const { utcOffset } = this.channelConfig; - const dayOfTheWeek = this.getDateWithOffset(utcOffset).getDay(); - const [workingHourConfig = {}] = this.channelConfig.workingHours.filter( - workingHour => workingHour.day_of_week === dayOfTheWeek - ); - return { - closedAllDay: workingHourConfig.closed_all_day, - openHour: workingHourConfig.open_hour, - openMinute: workingHourConfig.open_minutes, - closeHour: workingHourConfig.close_hour, - closeMinute: workingHourConfig.close_minutes, - openAllDay: workingHourConfig.open_all_day, - }; - }, - isInBusinessHours() { - const { workingHoursEnabled } = this.channelConfig; - return workingHoursEnabled ? this.isInBetweenTheWorkingHours : true; - }, - }, - - methods: { - getDateWithOffset(utcOffset) { - return utcToZonedTime(new Date().toISOString(), utcOffset); - }, - }, -}; diff --git a/app/javascript/widget/mixins/nextAvailabilityTime.js b/app/javascript/widget/mixins/nextAvailabilityTime.js deleted file mode 100644 index 0fc963083..000000000 --- a/app/javascript/widget/mixins/nextAvailabilityTime.js +++ /dev/null @@ -1,250 +0,0 @@ -import { - timeSlotParse, - defaultTimeSlot, -} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js'; -import { utcToZonedTime } from 'date-fns-tz'; -import { generateRelativeTime } from 'shared/helpers/DateHelper'; - -const MINUTE_ROUNDING_FACTOR = 5; - -export default { - data() { - return { - dayNames: [ - this.$t('DAY_NAMES.SUNDAY'), - this.$t('DAY_NAMES.MONDAY'), - this.$t('DAY_NAMES.TUESDAY'), - this.$t('DAY_NAMES.WEDNESDAY'), - this.$t('DAY_NAMES.THURSDAY'), - this.$t('DAY_NAMES.FRIDAY'), - this.$t('DAY_NAMES.SATURDAY'), - ], - timeSlots: [...defaultTimeSlot], - timeSlot: {}, - }; - }, - computed: { - channelConfig() { - return window.chatwootWebChannel; - }, - workingHours() { - return this.channelConfig.workingHours; - }, - newDateWithTimeZone() { - return utcToZonedTime(new Date(), this.timeZoneValue); - }, - presentHour() { - return this.newDateWithTimeZone.getHours(); - }, - presentMinute() { - return this.newDateWithTimeZone.getMinutes(); - }, - currentDay() { - const date = this.newDateWithTimeZone; - const day = date.getDay(); - const currentDay = Object.keys(this.dayNames).find( - key => this.dayNames[key] === this.dayNames[day] - ); - return Number(currentDay); - }, - timeZoneValue() { - return this.channelConfig.timezone; - }, - languageCode() { - return window.chatwootWebChannel.locale; - }, - currentDayWorkingHours() { - return this.workingHours.find( - slot => slot.day_of_week === this.currentDay - ); - }, - nextDayWorkingHours() { - let nextDay = this.getNextDay(this.currentDay); - let nextWorkingHour = this.getNextWorkingHour(nextDay); - - // It gets the next working hour for the next day. If there is no working hour for the next day, - // it keeps iterating through the days of the week until it finds the next working hour. - while (!nextWorkingHour) { - nextDay = this.getNextDay(nextDay); - nextWorkingHour = this.getNextWorkingHour(nextDay); - } - return nextWorkingHour; - }, - currentDayTimings() { - const { - open_hour: openHour, - open_minutes: openMinute, - close_hour: closeHour, - } = this.currentDayWorkingHours ?? {}; - return { - openHour, - openMinute, - closeHour, - }; - }, - nextDayTimings() { - const { open_hour: openHour, open_minutes: openMinute } = - this.nextDayWorkingHours ?? {}; - return { - openHour, - openMinute, - }; - }, - dayDiff() { - // Here this is used to get the difference between current day and next working day - const nextDay = this.nextDayWorkingHours.day_of_week; - const totalDays = 6; - return nextDay > this.currentDay - ? nextDay - this.currentDay - 1 - : totalDays - this.currentDay + nextDay; - }, - dayNameOfNextWorkingDay() { - return this.dayNames[this.nextDayWorkingHours.day_of_week]; - }, - hoursAndMinutesBackInOnline() { - if (this.presentHour >= this.currentDayTimings.closeHour) { - return this.getHoursAndMinutesUntilNextDayOpen( - this.nextDayWorkingHours.open_all_day - ? 0 - : this.nextDayTimings.openHour, - this.nextDayTimings.openMinute, - this.currentDayTimings.closeHour - ); - } - return this.getHoursAndMinutesUntilNextDayOpen( - this.currentDayTimings.openHour, - this.currentDayTimings.openMinute, - this.currentDayTimings.closeHour - ); - }, - exactTimeInAmPm() { - return `${ - this.timeSlot.day === this.currentDay ? `at ${this.timeSlot.from}` : '' - }`; - }, - hoursAndMinutesLeft() { - const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline; - - const timeLeftChars = []; - - if (hoursLeft > 0) { - const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft; - const hourRelative = generateRelativeTime( - roundedUpHoursLeft, - 'hour', - this.languageCode - ); - timeLeftChars.push(`${hourRelative}`); - } - - if (minutesLeft > 0 && hoursLeft === 0) { - const roundedUpMinLeft = - Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) * - MINUTE_ROUNDING_FACTOR; - const minRelative = generateRelativeTime( - roundedUpMinLeft, - 'minutes', - this.languageCode - ); - timeLeftChars.push(`${minRelative}`); - } - - return timeLeftChars.join(' '); - }, - hoursAndMinutesToBack() { - const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline; - if (hoursLeft >= 3) { - return this.exactTimeInAmPm; - } - if (hoursLeft > 0 || minutesLeft > 0) { - return this.hoursAndMinutesLeft; - } - return 'in some time'; - }, - timeLeftToBackInOnline() { - if ( - this.hoursAndMinutesBackInOnline.hoursLeft >= 24 || - (this.timeSlot.day !== this.currentDay && this.dayDiff === 0) - ) { - const hourRelative = generateRelativeTime( - this.dayDiff + 1, - 'days', - this.languageCode - ); - return `${hourRelative}`; - } - if ( - this.dayDiff >= 1 && - this.presentHour >= this.currentDayTimings.closeHour - ) { - return `on ${this.dayNameOfNextWorkingDay}`; - } - return this.hoursAndMinutesToBack; - }, - }, - mounted() { - this.setTimeSlot(); - }, - methods: { - getNextDay(day) { - // This code calculates the next day of the week based on the current day. If the current day is Saturday (6), then the next day will be Sunday (0). - return (day + 1) % 7; - }, - getNextWorkingHour(day) { - const workingHour = this.workingHours.find( - slot => slot.day_of_week === day - ); - if (workingHour && !workingHour.closed_all_day) { - return workingHour; - } - return null; - }, - getHoursAndMinutesUntilNextDayOpen( - openHour, // If the present time is after the closing time of the current day, then the openHour will be the opening hour of the next day else it will be the opening hour of the current day. - openMinutes, // If the present time is after the closing time of the current day, then the openMinutes will be the opening minutes of the next day else it will be the opening minutes of the current day. - closeHour // The closeHour will be the closing hour of the current day. It will be used to calculate the time remaining until the next day's opening hours. - ) { - // This code calculates the time remaining until the next day's opening hours, - // given the current time, the opening hours, and the closing hours of the current day. - if (closeHour < openHour) { - openHour += 24; - } - let diffMinutes = - openHour * 60 + - openMinutes - - (this.presentHour * 60 + this.presentMinute); - diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes; - const [hoursLeft, minutesLeft] = [ - Math.floor(diffMinutes / 60), - diffMinutes % 60, - ]; - - // It returns the remaining time in hours and minutes as an object with keys hours and minutes. - return { hoursLeft, minutesLeft }; - }, - setTimeSlot() { - // It checks if the working hours feature is enabled for the store. - - const timeSlots = this.workingHours; - - // If the present hour is after the closing hour of the current day, - // then the next day's working hours will be used to calculate the time remaining until the next day's opening hours, - // else the current day's working hours will be used - const currentSlot = - this.presentHour >= this.currentDayTimings.closeHour - ? this.nextDayWorkingHours - : this.currentDayWorkingHours; - - // It parses the working hours to get the time slots in AM/PM format. - const slots = timeSlotParse(timeSlots).length - ? timeSlotParse(timeSlots) - : defaultTimeSlot; - this.timeSlots = slots; - - // It finds the time slot for the current slot. - this.timeSlot = this.timeSlots.find( - slot => slot.day === currentSlot.day_of_week - ); - }, - }, -}; diff --git a/app/javascript/widget/mixins/routerMixin.js b/app/javascript/widget/mixins/routerMixin.js deleted file mode 100644 index b3b37f6fd..000000000 --- a/app/javascript/widget/mixins/routerMixin.js +++ /dev/null @@ -1,10 +0,0 @@ -export default { - methods: { - async replaceRoute(name, params = {}) { - if (this.$route.name !== name) { - return this.$router.replace({ name, params }); - } - return undefined; - }, - }, -}; diff --git a/app/javascript/widget/mixins/specs/availabilityMixin.spec.js b/app/javascript/widget/mixins/specs/availabilityMixin.spec.js deleted file mode 100644 index 5a4e051d1..000000000 --- a/app/javascript/widget/mixins/specs/availabilityMixin.spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { defineComponent, h } from 'vue'; -import availabilityMixin from '../availability'; -import { vi } from 'vitest'; - -global.chatwootWebChannel = { - workingHoursEnabled: true, - workingHours: [ - { - day_of_week: 3, - closed_all_day: false, - open_hour: 8, - open_minutes: 30, - close_hour: 17, - close_minutes: 35, - open_all_day: false, - }, - { - day_of_week: 4, - closed_all_day: false, - open_hour: 8, - open_minutes: 30, - close_hour: 17, - close_minutes: 30, - open_all_day: false, - }, - ], - utcOffset: '-07:00', -}; - -let Component; - -describe('availabilityMixin', () => { - beforeEach(() => { - vi.useRealTimers(); - Component = defineComponent({ - mixins: [availabilityMixin], - render() { - return h('div'); - }, - }); - }); - - it('returns valid isInBetweenWorkingHours if in different timezone', () => { - vi.useFakeTimers().setSystemTime( - new Date('Thu Apr 14 2022 06:04:46 GMT+0530') - ); - const wrapper = mount(Component); - expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true); - }); - - it('returns valid isInBetweenWorkingHours if in same timezone', () => { - global.chatwootWebChannel.utcOffset = '+05:30'; - - vi.useFakeTimers().setSystemTime( - new Date('Thu Apr 14 2022 09:01:46 GMT+0530') - ); - const wrapper = mount(Component); - expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true); - }); - - it('returns false if closed all day', () => { - global.chatwootWebChannel.utcOffset = '-07:00'; - global.chatwootWebChannel.workingHours = [ - { day_of_week: 3, closed_all_day: true }, - ]; - - vi.useFakeTimers().setSystemTime( - new Date('Thu Apr 14 2022 09:01:46 GMT+0530') - ); - const wrapper = mount(Component); - expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(false); - }); - - it('returns true if open all day', () => { - global.chatwootWebChannel.utcOffset = '-07:00'; - global.chatwootWebChannel.workingHours = [ - { day_of_week: 3, open_all_day: true }, - ]; - - vi.useFakeTimers().setSystemTime( - new Date('Thu Apr 14 2022 09:01:46 GMT+0530') - ); - const wrapper = mount(Component); - expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true); - }); -}); diff --git a/app/javascript/widget/mixins/specs/nextAvailabilityTime.spec.js b/app/javascript/widget/mixins/specs/nextAvailabilityTime.spec.js deleted file mode 100644 index b9717cf43..000000000 --- a/app/javascript/widget/mixins/specs/nextAvailabilityTime.spec.js +++ /dev/null @@ -1,402 +0,0 @@ -import { defineComponent, h } from 'vue'; -import { mount } from '@vue/test-utils'; -import nextAvailabilityTimeMixin from '../nextAvailabilityTime'; - -describe('nextAvailabilityTimeMixin', () => { - const chatwootWebChannel = { - workingHoursEnabled: true, - workingHours: [ - { - day_of_week: 0, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 1, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 2, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 3, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 4, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 5, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - { - day_of_week: 6, - open_hour: 9, - closed_all_day: false, - open_minutes: 0, - close_hour: 17, - }, - ], - }; - - let Component; - - beforeEach(() => { - Component = defineComponent({ - mixins: [nextAvailabilityTimeMixin], - render() { - return h('div'); - }, - }); - window.chatwootWebChannel = chatwootWebChannel; - }); - - afterEach(() => { - delete window.chatwootWebChannel; - }); - - beforeEach(() => { - vi.useRealTimers(); - }); - - it('should return day names', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - expect(wrapper.vm.dayNames).toEqual([ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]); - }); - - it('should return channelConfig', () => { - const wrapper = mount(Component); - expect(wrapper.vm.channelConfig).toEqual(chatwootWebChannel); - }); - - it('should return workingHours', () => { - const wrapper = mount(Component); - expect(wrapper.vm.workingHours).toEqual(chatwootWebChannel.workingHours); - }); - - it('should return currentDayWorkingHours', () => { - const currentDay = new Date().getDay(); - const expectedWorkingHours = chatwootWebChannel.workingHours.find( - slot => slot.day_of_week === currentDay - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - expect(wrapper.vm.currentDayWorkingHours).toEqual(expectedWorkingHours); - }); - - it('should return nextDayWorkingHours', () => { - const currentDay = new Date().getDay(); - const nextDay = currentDay === 6 ? 0 : currentDay + 1; - const expectedWorkingHours = chatwootWebChannel.workingHours.find( - slot => slot.day_of_week === nextDay - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - expect(wrapper.vm.nextDayWorkingHours).toEqual(expectedWorkingHours); - }); - - it('should return presentHour', () => { - const wrapper = mount(Component); - expect(wrapper.vm.presentHour).toBe(new Date().getHours()); - }); - - it('should return presentMinute', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - expect(wrapper.vm.presentMinute).toBe(new Date().getMinutes()); - }); - - it('should return currentDay', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const date = new Date(); - const day = date.getDay(); - const currentDay = Object.keys(wrapper.vm.dayNames).find( - key => wrapper.vm.dayNames[key] === wrapper.vm.dayNames[day] - ); - expect(wrapper.vm.currentDay).toBe(Number(currentDay)); - }); - - it('should return currentDayTimings', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const { - open_hour: openHour, - open_minutes: openMinute, - close_hour: closeHour, - } = wrapper.vm.currentDayWorkingHours; - expect(wrapper.vm.currentDayTimings).toEqual({ - openHour, - openMinute, - closeHour, - }); - }); - - it('should return nextDayTimings', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const { open_hour: openHour, open_minutes: openMinute } = - wrapper.vm.nextDayWorkingHours; - - expect(wrapper.vm.nextDayTimings).toEqual({ - openHour, - openMinute, - }); - }); - - it('should return dayDiff', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const currentDay = wrapper.vm.currentDay; - const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week; - const totalDays = 6; - const expectedDayDiff = - nextDay > currentDay - ? nextDay - currentDay - 1 - : totalDays - currentDay + nextDay; - - expect(wrapper.vm.dayDiff).toEqual(expectedDayDiff); - }); - - it('should return dayNameOfNextWorkingDay', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week; - const expectedDayName = wrapper.vm.dayNames[nextDay]; - expect(wrapper.vm.dayNameOfNextWorkingDay).toEqual(expectedDayName); - }); - - it('should return hoursAndMinutesBackInOnline', () => { - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - const currentDayCloseHour = - chatwootWebChannel.workingHours[wrapper.vm.currentDay].close_hour; - const nextDayOpenHour = - chatwootWebChannel.workingHours[ - wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1 - ].open_hour; - const nextDayOpenMinute = - chatwootWebChannel.workingHours[ - wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1 - ].open_minutes; - const expectedHoursAndMinutes = - wrapper.vm.getHoursAndMinutesUntilNextDayOpen( - nextDayOpenHour, - nextDayOpenMinute, - currentDayCloseHour - ); - expect(wrapper.vm.hoursAndMinutesBackInOnline).toEqual( - expectedHoursAndMinutes - ); - }); - - it('should return getNextDay', () => { - const wrapper = mount(Component); - expect(wrapper.vm.getNextDay(6)).toBe(0); - }); - - it('should return in 30 minutes', () => { - vi.useFakeTimers('modern').setSystemTime( - new Date('Thu Apr 14 2022 14:04:46 GMT+0530') - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - - chatwootWebChannel.workingHours[4].open_hour = 18; - chatwootWebChannel.workingHours[4].open_minutes = 0; - chatwootWebChannel.workingHours[4].close_hour = 23; - expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 30 minutes'); - }); - - it('should return in 2 hours', () => { - vi.useFakeTimers('modern').setSystemTime( - new Date('Thu Apr 14 2022 22:04:46 GMT+0530') - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - chatwootWebChannel.workingHours[4].open_hour = 19; - expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 2 hours'); - }); - - it('should return at 09:00 AM', () => { - vi.useFakeTimers('modern').setSystemTime( - new Date('Thu Apr 15 2022 22:04:46 GMT+0530') - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - chatwootWebChannel.workingHours[4].open_hour = 10; - expect(wrapper.vm.timeLeftToBackInOnline).toBe('at 09:00 AM'); - }); - - it('should return tomorrow', () => { - vi.useFakeTimers('modern').setSystemTime( - new Date('Thu Apr 1 2022 23:04:46 GMT+0530') - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - chatwootWebChannel.workingHours[4].open_hour = 9; - chatwootWebChannel.workingHours[4].close_hour = 16; - expect(wrapper.vm.timeLeftToBackInOnline).toBe('tomorrow'); - }); - - it.skip('should return on Saturday', () => { - vi.useFakeTimers('modern').setSystemTime( - new Date('Thu Apr 14 2022 23:04:46 GMT+0530') - ); - const wrapper = mount(Component); - wrapper.vm.dayNames = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - ]; - - chatwootWebChannel.workingHours[4].open_hour = 9; - chatwootWebChannel.workingHours[4].close_hour = 16; - chatwootWebChannel.workingHours[5].closed_all_day = true; - expect(wrapper.vm.timeLeftToBackInOnline).toBe('on Saturday'); - }); -}); diff --git a/app/javascript/widget/views/Home.vue b/app/javascript/widget/views/Home.vue index 123153c65..332783895 100755 --- a/app/javascript/widget/views/Home.vue +++ b/app/javascript/widget/views/Home.vue @@ -1,7 +1,7 @@