mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-31 19:17:48 +00:00
341 lines
9.8 KiB
JavaScript
341 lines
9.8 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;
|
|
|
|
/**
|
|
* Convert slot details from inbox timezone to user timezone
|
|
* @private
|
|
* @param {Date} time - Current time
|
|
* @param {string} inboxTz - Inbox timezone
|
|
* @param {Object} slotDetails - Original slot details
|
|
* @returns {Object} Slot details with user timezone adjustments
|
|
*/
|
|
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
|
|
if (!slotDetails) return null;
|
|
|
|
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
// If timezones match, no conversion needed
|
|
if (inboxTz === userTz) return slotDetails;
|
|
|
|
// Calculate when the slot opens (absolute time)
|
|
const now = time instanceof Date ? time : new Date(time);
|
|
const openingTime = new Date(
|
|
now.getTime() + slotDetails.minutesUntilOpen * 60000
|
|
);
|
|
|
|
// Convert to user timezone
|
|
const openingInUserTz = getDateInTimezone(openingTime, userTz);
|
|
const nowInUserTz = getDateInTimezone(now, userTz);
|
|
|
|
// Calculate days difference in user timezone
|
|
const openingDate = new Date(openingInUserTz);
|
|
openingDate.setHours(0, 0, 0, 0);
|
|
const todayDate = new Date(nowInUserTz);
|
|
todayDate.setHours(0, 0, 0, 0);
|
|
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
|
|
|
|
// Return with user timezone adjustments
|
|
return {
|
|
...slotDetails,
|
|
config: {
|
|
...slotDetails.config,
|
|
openHour: openingInUserTz.getHours(),
|
|
openMinutes: openingInUserTz.getMinutes(),
|
|
dayOfWeek: openingInUserTz.getDay(),
|
|
},
|
|
daysUntilOpen,
|
|
dayOfWeek: openingInUserTz.getDay(),
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
* Returns times adjusted to user's timezone for display
|
|
* @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
|
|
);
|
|
|
|
// Find the slot (today or next)
|
|
const slotDetails =
|
|
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
|
|
|
|
// Convert to user timezone for display
|
|
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
};
|