Files
chatwoot/app/javascript/widget/helpers/availabilityHelpers.js
Sivin Varghese 6ca38e10e9 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
<img width="995" height="442" alt="image"
src="https://github.com/user-attachments/assets/d6340738-07db-41d5-86fa-a8ecf734cc70"
/>



## 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 <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-08-22 00:43:34 +05:30

290 lines
8.1 KiB
JavaScript

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<Object>} workingHours - Full array of working-hour slot configs.
* @returns {Map<number, Object>} 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<number, Object>} 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<number, Object>} 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;
};