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>
This commit is contained in:
289
app/javascript/widget/helpers/availabilityHelpers.js
Normal file
289
app/javascript/widget/helpers/availabilityHelpers.js
Normal file
@@ -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<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;
|
||||
};
|
||||
580
app/javascript/widget/helpers/specs/availabilityHelpers.spec.js
Normal file
580
app/javascript/widget/helpers/specs/availabilityHelpers.spec.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user