diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx index 1d1ef829e..65df644b3 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -3,16 +3,12 @@ import {ButtonCircle, TooltipNext, Text} from '@momentum-ui/react-collaboration' import {Avatar, Icon} from '@momentum-design/components/dist/react'; import TaskTimer from '../../TaskTimer'; import {CallControlConsultComponentsProps} from '../../task.types'; -import { - createConsultButtons, - getVisibleButtons, - getConsultStatusText, - createTimerKey, -} from './call-control-custom.utils'; +import {createConsultButtons, getVisibleButtons, createTimerKey} from './call-control-custom.utils'; const CallControlConsultComponent: React.FC = ({ agentName, - startTimeStamp, + consultTimerLabel, + consultTimerTimestamp, consultTransfer, endConsultCall, consultConference, @@ -22,7 +18,12 @@ const CallControlConsultComponent: React.FC = controlVisibility, toggleConsultMute, }) => { - const timerKey = createTimerKey(startTimeStamp); + // Use the label and timestamp calculated in helper.ts + // Stable key based on timestamp to prevent timer resets + const timerKey = createTimerKey(consultTimerTimestamp); + + // Use consultTimerTimestamp with fallback + const effectiveTimestamp = consultTimerTimestamp || Date.now(); const buttons = createConsultButtons( isMuted, @@ -47,8 +48,8 @@ const CallControlConsultComponent: React.FC = {agentName} - {getConsultStatusText(controlVisibility.isConsultInitiated)} •  - + {consultTimerLabel} •  + diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index a9d427cde..d1310e82f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -28,7 +28,8 @@ const CallControlCADComponent: React.FC = (props) => isRecording, holdTime, consultAgentName, - consultStartTimeStamp, + consultTimerLabel, + consultTimerTimestamp, endConsultCall, consultTransfer, consultConference, @@ -36,6 +37,8 @@ const CallControlCADComponent: React.FC = (props) => callControlClassName, callControlConsultClassName, startTimestamp, + stateTimerLabel, + stateTimerTimestamp, controlVisibility, logger, isMuted, @@ -166,6 +169,12 @@ const CallControlCADComponent: React.FC = (props) =>
{currentMediaType.labelName} - + {stateTimerLabel && stateTimerTimestamp && ( + <> + {' '} + • {stateTimerLabel} - + + )} {controlVisibility.isConferenceInProgress && !controlVisibility.wrapup.isVisible && ( <> @@ -271,7 +280,8 @@ const CallControlCADComponent: React.FC = (props) =>
void; /** - * Timestamp when the consult call started. + * Label for the state timer (e.g., "Wrap Up", "Post Call"). */ - consultStartTimeStamp?: number; + stateTimerLabel?: string | null; + + /** + * Timestamp for the state timer. + */ + stateTimerTimestamp?: number; + + /** + * Label for the consult timer (e.g., "Consulting", "Consult on Hold"). + */ + consultTimerLabel?: string; + + /** + * Timestamp for the consult timer. + */ + consultTimerTimestamp?: number; /** * Audio stream for the call control. @@ -462,7 +477,6 @@ export type CallControlComponentProps = Pick< | 'exitConference' | 'endConsultCall' | 'consultTransfer' - | 'consultStartTimeStamp' | 'callControlAudio' | 'consultAgentName' | 'setConsultAgentName' @@ -470,6 +484,10 @@ export type CallControlComponentProps = Pick< | 'callControlClassName' | 'callControlConsultClassName' | 'startTimestamp' + | 'stateTimerLabel' + | 'stateTimerTimestamp' + | 'consultTimerLabel' + | 'consultTimerTimestamp' | 'allowConsultToQueue' | 'lastTargetType' | 'setLastTargetType' @@ -600,7 +618,8 @@ export interface ConsultTransferPopoverComponentProps { */ export interface CallControlConsultComponentsProps { agentName: string; - startTimeStamp: number; + consultTimerLabel: string; + consultTimerTimestamp: number; consultTransfer: () => void; endConsultCall: () => void; consultConference: () => void; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.snapshot.tsx index dbc6414b4..fb4d38d24 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.snapshot.tsx @@ -106,7 +106,8 @@ describe('CallControlConsultComponent Snapshots', () => { const defaultProps = { agentName: 'Alice', - startTimeStamp: Date.now(), + consultTimerLabel: 'Consulting', + consultTimerTimestamp: Date.now(), consultTransfer: mockOnTransfer, endConsultCall: mockEndConsultCall, toggleConsultMute: mockOnToggleConsultMute, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx index 9e0d73e90..8008d1d89 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -91,7 +91,8 @@ describe('CallControlConsultComponent', () => { const defaultProps = { agentName: 'Alice', - startTimeStamp: Date.now(), + consultTimerLabel: 'Consulting', + consultTimerTimestamp: Date.now(), consultTransfer: mockOnTransfer, endConsultCall: mockEndConsultCall, toggleConsultMute: mockOnToggleConsultMute, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx index fdc0c9e13..574c62eb3 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx @@ -87,7 +87,6 @@ describe('CallControlComponent Snapshots', () => { consultCall: jest.fn(), endConsultCall: jest.fn(), consultTransfer: jest.fn(), - consultStartTimeStamp: undefined, callControlAudio: null, consultAgentName: '', setConsultAgentName: jest.fn(), @@ -95,6 +94,10 @@ describe('CallControlComponent Snapshots', () => { callControlClassName: '', callControlConsultClassName: '', startTimestamp: Date.now(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, allowConsultToQueue: mockProfile.allowConsultToQueue, lastTargetType: 'agent', setLastTargetType: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8287d94ab..ce20e8776 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -109,7 +109,6 @@ describe('CallControlComponent', () => { consultCall: jest.fn(), endConsultCall: jest.fn(), consultTransfer: jest.fn(), - consultStartTimeStamp: Date.now(), callControlAudio: null as unknown as MediaStream, consultAgentName: '', setConsultAgentName: jest.fn(), @@ -117,6 +116,10 @@ describe('CallControlComponent', () => { callControlClassName: '', callControlConsultClassName: '', startTimestamp: Date.now(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, allowConsultToQueue: true, lastTargetType: 'agent', setLastTargetType: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx index 139b5e8ff..aad10baf6 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx @@ -119,7 +119,6 @@ describe('CallControlCADComponent Snapshots', () => { consultCall: jest.fn(), endConsultCall: jest.fn(), consultTransfer: jest.fn(), - consultStartTimeStamp: 1234567890000, callControlAudio: null, consultAgentName: '', setConsultAgentName: jest.fn(), @@ -127,6 +126,10 @@ describe('CallControlCADComponent Snapshots', () => { callControlClassName: '', callControlConsultClassName: '', startTimestamp: 1234567890000, + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, allowConsultToQueue: true, lastTargetType: 'agent', setLastTargetType: jest.fn(), @@ -272,7 +275,6 @@ describe('CallControlCADComponent Snapshots', () => { isConsultInitiatedOrAccepted: true, }, consultAgentName: 'Consult Agent', - consultStartTimeStamp: 1234567890000, }; screen = render(); mainContainer = screen.container.querySelector('.call-control-container'); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index 4ea1e4138..e6e048737 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -129,7 +129,6 @@ describe('CallControlCADComponent', () => { consultCall: jest.fn(), endConsultCall: jest.fn(), consultTransfer: jest.fn(), - consultStartTimeStamp: Date.now(), callControlAudio: null as unknown as MediaStream, consultAgentName: '', setConsultAgentName: jest.fn(), @@ -137,6 +136,10 @@ describe('CallControlCADComponent', () => { callControlClassName: '', callControlConsultClassName: '', startTimestamp: Date.now(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, allowConsultToQueue: true, lastTargetType: 'agent', setLastTargetType: jest.fn(), @@ -232,7 +235,6 @@ describe('CallControlCADComponent', () => { const consultProps = { ...defaultProps, consultAgentName: 'Consult Agent', - consultStartTimeStamp: Date.now(), controlVisibility: { ...mockControlVisibility, isConsultInitiatedOrAccepted: true, diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index 07bc3d085..0f7cf870b 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -331,3 +331,32 @@ export const findHoldStatus = (task: ITask, mType: string, agentId: string): boo : false : (interaction.media[mediaId] && interaction.media[mediaId].isHold) || false; // For all the other agent for main whatever is the status of main call hold }; + +/** + * Finds the hold timestamp for a specific media type (mainCall, consult, etc.) + * Used for timer alignment in Consult & Conference scenarios to match Agent Desktop behavior. + * + * @param task - The task object containing interaction data + * @param mType - The media type to search for ('mainCall', 'consult', 'conference') + * @returns The hold timestamp in milliseconds or null if not on hold + */ +export const findHoldTimestamp = (task: ITask, mType: string): number | null => { + const interaction = task?.data?.interaction; + + if (!interaction || !interaction.media) { + return null; + } + + // Adjust mType if agent is secondary EPDN agent + mType = setmTypeForEPDN(task, mType); + + // Find media ID for the specified type (mainCall, consult, etc.) + const mediaId = findMediaResourceId(task, mType); + + // Return the holdTimestamp if media exists and has a hold timestamp + if (mediaId && interaction.media[mediaId]?.holdTimestamp !== undefined) { + return interaction.media[mediaId].holdTimestamp; + } + + return null; +}; diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index 06b34c3a7..78ddb7e3d 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -3,6 +3,7 @@ import { getIsConferenceInProgress, getConferenceParticipants, getConferenceParticipantsCount, + findHoldTimestamp, } from '../src/task-utils'; import {mockTask} from '../../test-fixtures/src/fixtures'; import {ITask} from '../src/store.types'; @@ -1009,3 +1010,149 @@ describe('getConferenceParticipantsCount', () => { expect(getConferenceParticipantsCount(task)).toBe(3); }); }); + +describe('findHoldTimestamp', () => { + it('should return null when task data is missing or undefined', () => { + const task = {} as Partial as ITask; + expect(findHoldTimestamp(task, 'mainCall')).toBeNull(); + }); + + it('should return null when interaction is missing', () => { + const task = createMockTask({ + interaction: undefined as unknown as ITask['data']['interaction'], + }); + expect(findHoldTimestamp(task, 'mainCall')).toBeNull(); + }); + + it('should return null when interaction.media is missing', () => { + const task = createMockTask({ + interaction: createPartialInteraction({ + media: undefined, + }), + }); + expect(findHoldTimestamp(task, 'mainCall')).toBeNull(); + }); + + it('should return null when no media exists for the specified mType', () => { + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + someOtherId: { + mType: 'someOtherType', + mediaResourceId: 'someOtherId', + }, + }, + }), + }); + expect(findHoldTimestamp(task, 'mainCall')).toBeNull(); + }); + + it('should return null when media exists but has no holdTimestamp', () => { + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + mainCallId: { + mType: 'mainCall', + mediaResourceId: 'mainCallId', + isHold: true, + // No holdTimestamp + }, + }, + }), + }); + expect(findHoldTimestamp(task, 'mainCall')).toBeNull(); + }); + + it('should return holdTimestamp for mainCall when it exists', () => { + const holdTimestamp = 1638360000000; + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + mainCallId: { + mType: 'mainCall', + mediaResourceId: 'mainCallId', + isHold: true, + holdTimestamp: holdTimestamp, + }, + }, + }), + }); + expect(findHoldTimestamp(task, 'mainCall')).toBe(holdTimestamp); + }); + + it('should return holdTimestamp for consult when it exists', () => { + const consultHoldTimestamp = 1638360500000; + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + consultId: { + mType: 'consult', + mediaResourceId: 'consultId', + isHold: true, + holdTimestamp: consultHoldTimestamp, + }, + }, + }), + }); + expect(findHoldTimestamp(task, 'consult')).toBe(consultHoldTimestamp); + }); + + it('should return correct holdTimestamp when multiple media types exist', () => { + const mainHoldTimestamp = 1638360000000; + const consultHoldTimestamp = 1638360500000; + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + mainCallId: { + mType: 'mainCall', + mediaResourceId: 'mainCallId', + isHold: true, + holdTimestamp: mainHoldTimestamp, + }, + consultId: { + mType: 'consult', + mediaResourceId: 'consultId', + isHold: true, + holdTimestamp: consultHoldTimestamp, + }, + }, + }), + }); + + expect(findHoldTimestamp(task, 'mainCall')).toBe(mainHoldTimestamp); + expect(findHoldTimestamp(task, 'consult')).toBe(consultHoldTimestamp); + }); + + it('should handle holdTimestamp as zero', () => { + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + mainCallId: { + mType: 'mainCall', + mediaResourceId: 'mainCallId', + isHold: false, + holdTimestamp: 0, + }, + }, + }), + }); + expect(findHoldTimestamp(task, 'mainCall')).toBe(0); + }); + + it('should return null when mediaResourceId does not match', () => { + const task = createMockTask({ + interaction: createPartialInteraction({ + media: { + mainCallId: { + mType: 'mainCall', + mediaResourceId: 'mainCallId', + isHold: true, + holdTimestamp: 1638360000000, + }, + }, + }), + }); + // Request consult but only mainCall exists + expect(findHoldTimestamp(task, 'consult')).toBeNull(); + }); +}); diff --git a/packages/contact-center/task/src/Utils/constants.ts b/packages/contact-center/task/src/Utils/constants.ts index c9f9cb695..047fabe24 100644 --- a/packages/contact-center/task/src/Utils/constants.ts +++ b/packages/contact-center/task/src/Utils/constants.ts @@ -3,3 +3,10 @@ export const MEDIA_TYPE_TELEPHONY = 'telephony'; export const MEDIA_TYPE_CHAT = 'chat'; export const MEDIA_TYPE_EMAIL = 'email'; export const MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE = 7; + +// Timer Labels +export const TIMER_LABEL_WRAP_UP = 'Wrap Up'; +export const TIMER_LABEL_POST_CALL = 'Post Call'; +export const TIMER_LABEL_CONSULT_ON_HOLD = 'Consult on Hold'; +export const TIMER_LABEL_CONSULT_REQUESTED = 'Consult Requested'; +export const TIMER_LABEL_CONSULTING = 'Consulting'; diff --git a/packages/contact-center/task/src/Utils/timer-utils.ts b/packages/contact-center/task/src/Utils/timer-utils.ts new file mode 100644 index 000000000..9e3cf15cf --- /dev/null +++ b/packages/contact-center/task/src/Utils/timer-utils.ts @@ -0,0 +1,141 @@ +import {ITask, findHoldTimestamp} from '@webex/cc-store'; +import {ControlVisibility} from '@webex/cc-components'; +import { + TIMER_LABEL_WRAP_UP, + TIMER_LABEL_POST_CALL, + TIMER_LABEL_CONSULT_ON_HOLD, + TIMER_LABEL_CONSULT_REQUESTED, + TIMER_LABEL_CONSULTING, +} from './constants'; + +/** + * Timer data structure containing label and timestamp + */ +export interface TimerData { + label: string | null; + timestamp: number; +} + +/** + * Calculate state timer label and timestamp based on task state. + * Priority: Wrap Up > Post Call + * + * @param currentTask - The current task object + * @param controlVisibility - Control visibility flags + * @param agentId - The current agent ID + * @returns TimerData object with label and timestamp + */ +export function calculateStateTimerData( + currentTask: ITask | null, + controlVisibility: ControlVisibility | null, + agentId: string +): TimerData { + // Default return value + const defaultTimer: TimerData = {label: null, timestamp: 0}; + + if (!currentTask || !controlVisibility) { + return defaultTimer; + } + + const interaction = currentTask.data?.interaction; + const participant = interaction?.participants?.[agentId]; + + if (!participant) { + return defaultTimer; + } + + // Extract timestamps from participant data + let wrapUpTimestamp = 0; + let postCallTimestamp = 0; + + // Wrap-up timestamp: use lastUpdated if currently in wrap-up, otherwise use wrapUpTimestamp + if (participant.isWrapUp) { + wrapUpTimestamp = participant.lastUpdated || 0; + } else { + wrapUpTimestamp = participant.wrapUpTimestamp || 0; + } + + // Post-call timestamp: use currentStateTimestamp + postCallTimestamp = participant.currentStateTimestamp || 0; + + // Priority 1: Wrap-up state (highest priority) + if (controlVisibility.wrapup?.isVisible && wrapUpTimestamp) { + return { + label: TIMER_LABEL_WRAP_UP, + timestamp: wrapUpTimestamp, + }; + } + + // Priority 2: Post-call state (only if not in wrap-up) + const isInPostCall = interaction?.state === 'post_call' || participant?.currentState === 'post_call'; + if (isInPostCall && postCallTimestamp) { + return { + label: TIMER_LABEL_POST_CALL, + timestamp: postCallTimestamp, + }; + } + + return defaultTimer; +} + +/** + * Calculate consult timer label and timestamp based on consult state. + * Handles consult on hold vs active consulting states. + * + * @param currentTask - The current task object + * @param controlVisibility - Control visibility flags + * @param agentId - The current agent ID + * @returns TimerData object with label and timestamp + */ +export function calculateConsultTimerData( + currentTask: ITask | null, + controlVisibility: ControlVisibility | null, + agentId: string +): TimerData { + // Default return value + const defaultTimer: TimerData = {label: TIMER_LABEL_CONSULTING, timestamp: 0}; + + if (!currentTask || !controlVisibility) { + return defaultTimer; + } + + const interaction = currentTask.data?.interaction; + const participant = interaction?.participants?.[agentId]; + + if (!participant) { + return defaultTimer; + } + + // Extract consult start timestamp + let consultStartTimeStamp = 0; + if (participant.consultTimestamp) { + consultStartTimeStamp = participant.consultTimestamp; + } else if (participant.lastUpdated) { + consultStartTimeStamp = participant.lastUpdated; + } + + // If no consult timestamp, return default + if (!consultStartTimeStamp) { + return defaultTimer; + } + + // Check if consult call is on hold + if (controlVisibility.consultCallHeld) { + // Extract consult hold timestamp + const consultHoldTimestamp = findHoldTimestamp(currentTask, 'consult'); + + return { + label: TIMER_LABEL_CONSULT_ON_HOLD, + // Use consultHoldTimestamp when on hold, fallback to consult start time + timestamp: consultHoldTimestamp && consultHoldTimestamp > 0 ? consultHoldTimestamp : consultStartTimeStamp, + }; + } + + // Active consulting - determine label based on consult state + const label = controlVisibility.isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; + + return { + label, + timestamp: consultStartTimeStamp, + }; +} diff --git a/packages/contact-center/task/src/Utils/useHoldTimer.ts b/packages/contact-center/task/src/Utils/useHoldTimer.ts new file mode 100644 index 000000000..eea2fd758 --- /dev/null +++ b/packages/contact-center/task/src/Utils/useHoldTimer.ts @@ -0,0 +1,89 @@ +import {useEffect, useRef, useState} from 'react'; +import {ITask} from '@webex/cc-store'; +import {findHoldTimestamp} from './task-util'; + +// Worker script for hold timer - defined at module level as it never changes +const HOLD_TIMER_WORKER_SCRIPT = ` + let intervalId = null; + self.onmessage = function(e) { + if (e.data.type === 'start') { + const eventTime = e.data.eventTime; + if (intervalId) clearInterval(intervalId); + intervalId = setInterval(() => { + const elapsed = Math.floor((Date.now() - eventTime) / 1000); + self.postMessage({ type: 'elapsedTime', elapsed }); + }, 1000); + } + if (e.data.type === 'stop') { + if (intervalId) clearInterval(intervalId); + intervalId = null; + } + }; +`; + +/** + * Custom hook to manage hold timer using a Web Worker + * Prioritizes consult hold over main call hold + * + * @param currentTask - The current task object + * @returns holdTime - The elapsed time in seconds since the call was put on hold + */ +export const useHoldTimer = (currentTask: ITask | null): number => { + const [holdTime, setHoldTime] = useState(0); + const workerRef = useRef(null); + + useEffect(() => { + // Clean up previous worker if any + if (workerRef.current) { + if (typeof workerRef.current.postMessage === 'function') { + workerRef.current.postMessage({type: 'stop'}); + } + if (typeof workerRef.current.terminate === 'function') { + workerRef.current.terminate(); + } + workerRef.current = null; + } + + // Get holdTimestamp - prioritize consult hold over main call hold + // This ensures the hold timer shows the correct time for whichever call is currently on hold + const consultHoldTs = currentTask?.data?.interaction + ? findHoldTimestamp(currentTask.data.interaction, 'consult') + : null; + const mainCallHoldTs = currentTask?.data?.interaction + ? findHoldTimestamp(currentTask.data.interaction, 'mainCall') + : null; + + // Use consult hold timestamp if available, otherwise use main call hold timestamp + const activeHoldTimestamp = consultHoldTs || mainCallHoldTs; + + if (activeHoldTimestamp) { + const holdTimeMs = activeHoldTimestamp < 10000000000 ? activeHoldTimestamp * 1000 : activeHoldTimestamp; + const blob = new Blob([HOLD_TIMER_WORKER_SCRIPT], {type: 'application/javascript'}); + const workerUrl = URL.createObjectURL(blob); + workerRef.current = new Worker(workerUrl); + + // Set initial holdTime immediately for instant UI update + setHoldTime(Math.floor((Date.now() - holdTimeMs) / 1000)); + + workerRef.current.onmessage = (e) => { + if (e.data.type === 'elapsedTime') setHoldTime(e.data.elapsed); + if (e.data.type === 'stop') setHoldTime(0); + }; + + workerRef.current.postMessage({type: 'start', eventTime: holdTimeMs}); + } else { + setHoldTime(0); + } + + // Cleanup on unmount or when dependencies change + return () => { + if (workerRef.current) { + workerRef.current.postMessage({type: 'stop'}); + workerRef.current.terminate(); + workerRef.current = null; + } + }; + }, [currentTask]); + + return holdTime; +}; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index e8e54ff5f..c09e50502 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -1,4 +1,4 @@ -import {useEffect, useCallback, useState, useRef, useMemo} from 'react'; +import {useEffect, useCallback, useState, useMemo} from 'react'; import {AddressBookEntriesResponse, AddressBookEntrySearchParams, ITask} from '@webex/contact-center'; import {useCallControlProps, UseTaskListProps, UseTaskProps, useOutdialCallProps} from './task.types'; import store, { @@ -11,7 +11,10 @@ import store, { findMediaResourceId, MEDIA_TYPE_TELEPHONY_LOWER, } from '@webex/cc-store'; -import {findHoldTimestamp, getControlsVisibility} from './Utils/task-util'; +import {getControlsVisibility} from './Utils/task-util'; +import {TIMER_LABEL_CONSULTING} from './Utils/constants'; +import {calculateStateTimerData, calculateConsultTimerData} from './Utils/timer-utils'; +import {useHoldTimer} from './Utils/useHoldTimer'; import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/services/config/types'; const ENGAGED_LABEL = 'ENGAGED'; @@ -289,76 +292,21 @@ export const useCallControl = (props: useCallControlProps) => { const [isRecording, setIsRecording] = useState(true); const [buddyAgents, setBuddyAgents] = useState([]); const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); - const [holdTime, setHoldTime] = useState(0); const [startTimestamp, setStartTimestamp] = useState(0); const [secondsUntilAutoWrapup, setsecondsUntilAutoWrapup] = useState(null); - const workerRef = useRef(null); - const [lastTargetType, setLastTargetType] = useState<'agent' | 'queue'>('agent'); - const [conferenceParticipants, setConferenceParticipants] = useState([]); - - const workerScript = ` - let intervalId = null; - self.onmessage = function(e) { - if (e.data.type === 'start') { - const eventTime = e.data.eventTime; - if (intervalId) clearInterval(intervalId); - intervalId = setInterval(() => { - const elapsed = Math.floor((Date.now() - eventTime) / 1000); - self.postMessage({ type: 'elapsedTime', elapsed }); - }, 1000); - } - if (e.data.type === 'stop') { - if (intervalId) clearInterval(intervalId); - intervalId = null; - } - }; - `; - - useEffect(() => { - // Clean up previous worker if any - if (workerRef.current) { - if (typeof workerRef.current.postMessage === 'function') { - workerRef.current.postMessage({type: 'stop'}); - } - if (typeof workerRef.current.terminate === 'function') { - workerRef.current.terminate(); - } - workerRef.current = null; - } - - // Get holdTimestamp from the interaction object - const holdTimestamp = currentTask?.data?.interaction - ? findHoldTimestamp(currentTask.data.interaction, 'mainCall') - : null; - - if (holdTimestamp) { - const holdTimeMs = holdTimestamp < 10000000000 ? holdTimestamp * 1000 : holdTimestamp; - const blob = new Blob([workerScript], {type: 'application/javascript'}); - const workerUrl = URL.createObjectURL(blob); - workerRef.current = new Worker(workerUrl); - // Set initial holdTime immediately for instant UI update - setHoldTime(Math.floor((Date.now() - holdTimeMs) / 1000)); + // State timer labels and timestamps + const [stateTimerLabel, setStateTimerLabel] = useState(null); + const [stateTimerTimestamp, setStateTimerTimestamp] = useState(0); - workerRef.current.onmessage = (e) => { - if (e.data.type === 'elapsedTime') setHoldTime(e.data.elapsed); - if (e.data.type === 'stop') setHoldTime(0); - }; - - workerRef.current.postMessage({type: 'start', eventTime: holdTimeMs}); - } else { - setHoldTime(0); - } + // Consult timer labels and timestamps + const [consultTimerLabel, setConsultTimerLabel] = useState(TIMER_LABEL_CONSULTING); + const [consultTimerTimestamp, setConsultTimerTimestamp] = useState(0); + const [lastTargetType, setLastTargetType] = useState<'agent' | 'queue'>('agent'); + const [conferenceParticipants, setConferenceParticipants] = useState([]); - // Cleanup on unmount or when dependencies change - return () => { - if (workerRef.current) { - workerRef.current.postMessage({type: 'stop'}); - workerRef.current.terminate(); - workerRef.current = null; - } - }; - }, [currentTask]); + // Use custom hook for hold timer management + const holdTime = useHoldTimer(currentTask); useEffect(() => { if (currentTask && store?.cc?.agentConfig?.agentId) { @@ -399,17 +347,25 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, logger]); - // Check for consulting agent whenever currentTask changes + // Extract main call timestamp whenever currentTask changes useEffect(() => { extractConsultingAgent(); - if ( - currentTask?.data?.interaction?.participants && - store?.cc?.agentConfig?.agentId && - currentTask.data.interaction.participants[store.cc.agentConfig.agentId]?.joinTimestamp - ) { - setStartTimestamp(currentTask.data.interaction.participants[store.cc.agentConfig.agentId].joinTimestamp); + + if (!currentTask?.data?.interaction?.participants || !agentId) { + return; } - }, [currentTask, extractConsultingAgent]); + + const participant = currentTask.data.interaction.participants[agentId]; + + if (!participant) { + return; + } + + // Main call timer - use joinTimestamp + if (participant.joinTimestamp) { + setStartTimestamp(participant.joinTimestamp); + } + }, [currentTask, agentId, extractConsultingAgent]); const loadBuddyAgents = useCallback(async () => { try { @@ -911,6 +867,21 @@ export const useCallControl = (props: useCallControlProps) => { }; }, [currentTask?.autoWrapup, controlVisibility?.wrapup]); + // Calculate state timer label and timestamp using utils + // Priority: Wrap Up > Post Call + useEffect(() => { + const stateTimerData = calculateStateTimerData(currentTask, controlVisibility, agentId); + setStateTimerLabel(stateTimerData.label); + setStateTimerTimestamp(stateTimerData.timestamp); + }, [currentTask, controlVisibility, agentId]); + + // Calculate consult timer label and timestamp using utils + useEffect(() => { + const consultTimerData = calculateConsultTimerData(currentTask, controlVisibility, agentId); + setConsultTimerLabel(consultTimerData.label); + setConsultTimerTimestamp(consultTimerData.timestamp); + }, [currentTask, controlVisibility, agentId]); + return { currentTask, endCall, @@ -935,6 +906,10 @@ export const useCallControl = (props: useCallControlProps) => { setConsultAgentName, holdTime, startTimestamp, + stateTimerLabel, + stateTimerTimestamp, + consultTimerLabel, + consultTimerTimestamp, lastTargetType, setLastTargetType, controlVisibility, diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 269c5fce1..3f92280ce 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -89,6 +89,10 @@ describe('CallControl Component', () => { getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render( diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index c377b6fe8..2a0d2908a 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -85,6 +85,10 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render( @@ -178,6 +182,10 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render(); @@ -252,6 +260,10 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render( @@ -328,6 +340,10 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); const {container} = render( diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 7567c0fd3..725959c80 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,6 +1,12 @@ import {renderHook, act, waitFor} from '@testing-library/react'; import {useIncomingTask, useTaskList, useCallControl, useOutdialCall} from '../src/helper'; import * as taskUtils from '../src/Utils/task-util'; +import { + TIMER_LABEL_WRAP_UP, + TIMER_LABEL_POST_CALL, + TIMER_LABEL_CONSULT_ON_HOLD, + TIMER_LABEL_CONSULTING, +} from '../src/Utils/constants'; import {AddressBookEntriesResponse, EntryPointListResponse, TASK_EVENTS, IContactCenter} from '@webex/cc-store'; import {ITask} from '@webex/contact-center'; import { @@ -3395,6 +3401,376 @@ describe('useCallControl', () => { store.getBuddyAgents = originalGetBuddyAgents; }); }); + + describe('Timer State Management', () => { + const mockControlVisibility = { + accept: {isVisible: false, isEnabled: false}, + decline: {isVisible: false, isEnabled: false}, + end: {isVisible: true, isEnabled: true}, + muteUnmute: {isVisible: true, isEnabled: true}, + holdResume: {isVisible: true, isEnabled: true}, + pauseResumeRecording: {isVisible: false, isEnabled: false}, + recordingIndicator: {isVisible: false, isEnabled: false}, + transfer: {isVisible: true, isEnabled: true}, + conference: {isVisible: false, isEnabled: false}, + exitConference: {isVisible: false, isEnabled: false}, + mergeConference: {isVisible: false, isEnabled: false}, + consult: {isVisible: true, isEnabled: true}, + endConsult: {isVisible: false, isEnabled: false}, + consultConference: {isVisible: false, isEnabled: false}, + consultTransfer: {isVisible: false, isEnabled: false}, + consultTransferConsult: {isVisible: false, isEnabled: false}, + mergeConferenceConsult: {isVisible: false, isEnabled: false}, + muteUnmuteConsult: {isVisible: false, isEnabled: false}, + endConsultCall: {isVisible: false, isEnabled: false}, + switchToMainCall: {isVisible: false, isEnabled: false}, + switchToConsult: {isVisible: false, isEnabled: false}, + wrapup: {isVisible: false, isEnabled: false}, + isConferenceInProgress: false, + isConsultInitiated: false, + isConsultInitiatedAndAccepted: false, + isConsultReceived: false, + isConsultInitiatedOrAccepted: false, + isHeld: false, + consultCallHeld: false, + }; + + it('should set stateTimerLabel to "Wrap Up" when in wrapup state', async () => { + const mockTaskInWrapup = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + wrapUpRequired: true, // This makes wrapup.isVisible = true + interaction: { + ...mockCurrentTask.data.interaction, + participants: { + agent1: { + joinTimestamp: 1000, + isWrapUp: true, + lastUpdated: 3000, + wrapUpTimestamp: 3000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskInWrapup, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_WRAP_UP); + expect(result.current.stateTimerTimestamp).toBe(3000); + }); + }); + + it('should set stateTimerLabel to "Post Call" when in post_call state', () => { + const mockTaskInPostCall = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + state: 'post_call', + participants: { + agent1: { + joinTimestamp: 1000, + currentState: 'post_call', + currentStateTimestamp: 4000, + pType: 'Agent', + }, + }, + }, + }, + }; + + mockGetControlsVisibility.mockImplementation(() => ({ + ...mockControlVisibility, + wrapup: {isVisible: false, isEnabled: false}, + })); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskInPostCall, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_POST_CALL); + expect(result.current.stateTimerTimestamp).toBe(4000); + }); + + it('should prioritize "Wrap Up" over "Post Call" label', async () => { + const mockTaskWithBothStates = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + wrapUpRequired: true, // This makes wrapup.isVisible = true + interaction: { + ...mockCurrentTask.data.interaction, + state: 'post_call', + participants: { + agent1: { + joinTimestamp: 1000, + isWrapUp: true, + wrapUpTimestamp: 3000, + currentStateTimestamp: 4000, + lastUpdated: 3000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskWithBothStates, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_WRAP_UP); + expect(result.current.stateTimerTimestamp).toBe(3000); + }); + }); + + it('should set consultTimerLabel to "Consult on Hold" when consult is held', async () => { + const mockTaskWithConsultHeld = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsultInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + media: { + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: 5000, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + joinTimestamp: 1000, + consultTimestamp: 2000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskWithConsultHeld, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.consultTimerLabel).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.current.consultTimerTimestamp).toBe(5000); + }); + }); + + it('should use consultTimestamp for active consult timer', async () => { + const mockTaskWithActiveConsult = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsultInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + media: { + 'consult-id': { + mType: 'consult', + isHold: false, + mediaResourceId: 'consult-id', + participants: ['agent1', 'agent2'], + }, + }, + participants: { + agent1: { + id: 'agent1', + joinTimestamp: 1000, + consultTimestamp: 2000, + pType: 'Agent', + }, + agent2: { + id: 'agent2', + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskWithActiveConsult, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + // The timer should use the consultTimestamp (2000) + expect(result.current.consultTimerTimestamp).toBe(2000); + // The label should be "Consulting" or "Consult Requested" depending on state + expect(result.current.consultTimerLabel).toMatch(/Consult/); + }); + }); + + it('should set consultTimerLabel to "Consulting" when consult is active', () => { + const mockTaskWithActiveConsult = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + participants: { + agent1: { + joinTimestamp: 1000, + consultTimestamp: 2000, + pType: 'Agent', + }, + }, + }, + }, + }; + + mockGetControlsVisibility.mockImplementation(() => ({ + ...mockControlVisibility, + consultCallHeld: false, + isConsultInitiated: false, + })); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskWithActiveConsult, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + expect(result.current.consultTimerLabel).toBe(TIMER_LABEL_CONSULTING); + expect(result.current.consultTimerTimestamp).toBe(2000); + }); + + it('should preserve consult timer when resuming from hold', async () => { + const mockTaskWithConsultHeld = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsultInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + media: { + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: 5000, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + joinTimestamp: 1000, + consultTimestamp: 2000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result, rerender} = renderHook( + ({task}) => + useCallControl({ + currentTask: task, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }), + { + initialProps: { + task: mockTaskWithConsultHeld, + }, + } + ); + + await waitFor(() => { + expect(result.current.consultTimerTimestamp).toBe(5000); + }); + + // Resume from hold + const mockTaskConsultResumed = { + ...mockTaskWithConsultHeld, + data: { + ...mockTaskWithConsultHeld.data, + interaction: { + ...mockTaskWithConsultHeld.data.interaction, + media: { + 'consult-id': { + mType: 'consult', + isHold: false, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + }, + }, + }; + + rerender({task: mockTaskConsultResumed}); + + // Should use original consultTimestamp, not reset to 0 + await waitFor(() => { + expect(result.current.consultTimerTimestamp).toBe(2000); + }); + }); + }); }); describe('useOutdialCall', () => { @@ -4463,7 +4839,7 @@ describe('Task Hook Error Handling and Logging', () => { }); it('should handle synchronous errors in toggleMute', () => { - mockGetControlsVisibility.mockReturnValue({ + mockGetControlsVisibility.mockImplementation(() => ({ muteUnmute: {isVisible: true, isEnabled: true}, muteUnmuteConsult: {isVisible: false, isEnabled: false}, holdResume: {isVisible: true, isEnabled: true}, @@ -4491,7 +4867,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const errorTask = { ...mockTaskWithInteraction, @@ -4716,7 +5092,7 @@ describe('Task Hook Error Handling and Logging', () => { }), }; - mockGetControlsVisibility.mockReturnValue({ + mockGetControlsVisibility.mockImplementation(() => ({ muteUnmute: {isVisible: true, isEnabled: true}, muteUnmuteConsult: {isVisible: false, isEnabled: false}, holdResume: {isVisible: true, isEnabled: true}, @@ -4744,7 +5120,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const taskWithAutoWrapup = { ...mockTaskWithInteraction, @@ -4778,7 +5154,7 @@ describe('Task Hook Error Handling and Logging', () => { getTimeLeftSeconds: jest.fn().mockReturnValue(30), }; - mockGetControlsVisibility.mockReturnValue({ + mockGetControlsVisibility.mockImplementation(() => ({ muteUnmute: {isVisible: true, isEnabled: true}, muteUnmuteConsult: {isVisible: false, isEnabled: false}, holdResume: {isVisible: true, isEnabled: true}, @@ -4806,7 +5182,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const taskWithAutoWrapup = { ...mockTaskWithInteraction, diff --git a/packages/contact-center/task/tests/utils/timer-utils.test.ts b/packages/contact-center/task/tests/utils/timer-utils.test.ts new file mode 100644 index 000000000..4bda03731 --- /dev/null +++ b/packages/contact-center/task/tests/utils/timer-utils.test.ts @@ -0,0 +1,332 @@ +import {calculateStateTimerData, calculateConsultTimerData} from '../../src/Utils/timer-utils'; +import { + TIMER_LABEL_WRAP_UP, + TIMER_LABEL_POST_CALL, + TIMER_LABEL_CONSULT_ON_HOLD, + TIMER_LABEL_CONSULTING, + TIMER_LABEL_CONSULT_REQUESTED, +} from '../../src/Utils/constants'; +import {ITask} from '@webex/cc-store'; + +const mockControlVisibility = { + accept: {isVisible: true, isEnabled: true}, + decline: {isVisible: true, isEnabled: true}, + end: {isVisible: true, isEnabled: true}, + muteUnmute: {isVisible: true, isEnabled: true}, + muteUnmuteConsult: {isVisible: true, isEnabled: true}, + holdResume: {isVisible: true, isEnabled: true}, + consult: {isVisible: true, isEnabled: true}, + transfer: {isVisible: true, isEnabled: true}, + conference: {isVisible: true, isEnabled: true}, + wrapup: {isVisible: true, isEnabled: true}, + pauseResumeRecording: {isVisible: true, isEnabled: true}, + endConsult: {isVisible: true, isEnabled: true}, + recordingIndicator: {isVisible: true, isEnabled: true}, + exitConference: {isVisible: false, isEnabled: false}, + mergeConference: {isVisible: false, isEnabled: false}, + mergeConferenceConsult: {isVisible: false, isEnabled: false}, + consultTransfer: {isVisible: false, isEnabled: false}, + consultTransferConsult: {isVisible: false, isEnabled: false}, + switchToMainCall: {isVisible: false, isEnabled: false}, + switchToConsult: {isVisible: false, isEnabled: false}, + isConferenceInProgress: false, + isConsultInitiated: false, + isConsultInitiatedAndAccepted: false, + isConsultReceived: false, + isConsultInitiatedOrAccepted: false, + isHeld: false, + consultCallHeld: false, +}; + +describe('timer-utils', () => { + describe('calculateStateTimerData', () => { + it('should return default when currentTask is null', () => { + const result = calculateStateTimerData(null, mockControlVisibility, 'agent1'); + expect(result).toEqual({label: null, timestamp: 0}); + }); + + it('should return default when controlVisibility is null', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: {joinTimestamp: 1000}, + }, + }, + }, + } as unknown as ITask; + + const result = calculateStateTimerData(mockTask, null, 'agent1'); + expect(result).toEqual({label: null, timestamp: 0}); + }); + + it('should return Wrap Up label when in wrapup state', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + isWrapUp: true, + lastUpdated: 3000, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateStateTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_WRAP_UP); + expect(result.timestamp).toBe(3000); + }); + + it('should use wrapUpTimestamp when not currently in wrapup', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + isWrapUp: false, + wrapUpTimestamp: 2500, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateStateTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_WRAP_UP); + expect(result.timestamp).toBe(2500); + }); + + it('should return Post Call label when in post_call state', () => { + const mockTask = { + data: { + interaction: { + state: 'post_call', + participants: { + agent1: { + currentState: 'post_call', + currentStateTimestamp: 4000, + }, + }, + }, + }, + } as unknown as ITask; + + const visibility = { + ...mockControlVisibility, + wrapup: {isVisible: false, isEnabled: false}, + }; + + const result = calculateStateTimerData(mockTask, visibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_POST_CALL); + expect(result.timestamp).toBe(4000); + }); + + it('should prioritize Wrap Up over Post Call', () => { + const mockTask = { + data: { + interaction: { + state: 'post_call', + participants: { + agent1: { + isWrapUp: true, + lastUpdated: 3000, + currentStateTimestamp: 4000, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateStateTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_WRAP_UP); + expect(result.timestamp).toBe(3000); + }); + + it('should return default when participant not found', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent2: {joinTimestamp: 1000}, + }, + }, + }, + } as unknown as ITask; + + const result = calculateStateTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result).toEqual({label: null, timestamp: 0}); + }); + }); + + describe('calculateConsultTimerData', () => { + it('should return default when currentTask is null', () => { + const result = calculateConsultTimerData(null, mockControlVisibility, 'agent1'); + expect(result).toEqual({label: TIMER_LABEL_CONSULTING, timestamp: 0}); + }); + + it('should return default when controlVisibility is null', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: {consultTimestamp: 2000}, + }, + }, + }, + } as unknown as ITask; + + const result = calculateConsultTimerData(mockTask, null, 'agent1'); + expect(result).toEqual({label: TIMER_LABEL_CONSULTING, timestamp: 0}); + }); + + it('should return Consulting label with consultTimestamp', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateConsultTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULTING); + expect(result.timestamp).toBe(2000); + }); + + it('should use lastUpdated if consultTimestamp is not available', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + lastUpdated: 2500, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateConsultTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULTING); + expect(result.timestamp).toBe(2500); + }); + + it('should return Consult Requested label when consult is initiated', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + } as unknown as ITask; + + const visibility = { + ...mockControlVisibility, + isConsultInitiated: true, + consultCallHeld: false, + }; + + const result = calculateConsultTimerData(mockTask, visibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULT_REQUESTED); + expect(result.timestamp).toBe(2000); + }); + + it('should return Consult on Hold when consult is held', () => { + const mockTask = { + data: { + interaction: { + media: { + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: 5000, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + } as unknown as ITask; + + const visibility = { + ...mockControlVisibility, + consultCallHeld: true, + }; + + const result = calculateConsultTimerData(mockTask, visibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.timestamp).toBe(5000); + }); + + it('should fallback to consultTimestamp if consultHoldTimestamp is 0', () => { + const mockTask = { + data: { + interaction: { + media: {}, + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + } as unknown as ITask; + + const visibility = { + ...mockControlVisibility, + consultCallHeld: true, + }; + + const result = calculateConsultTimerData(mockTask, visibility, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.timestamp).toBe(2000); + }); + + it('should return default when no consult timestamp available', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent1: { + joinTimestamp: 1000, + }, + }, + }, + }, + } as unknown as ITask; + + const result = calculateConsultTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result).toEqual({label: TIMER_LABEL_CONSULTING, timestamp: 0}); + }); + + it('should return default when participant not found', () => { + const mockTask = { + data: { + interaction: { + participants: { + agent2: {consultTimestamp: 2000}, + }, + }, + }, + } as unknown as ITask; + + const result = calculateConsultTimerData(mockTask, mockControlVisibility, 'agent1'); + expect(result).toEqual({label: TIMER_LABEL_CONSULTING, timestamp: 0}); + }); + }); +}); diff --git a/packages/contact-center/task/tests/utils/useHoldTimer.test.ts b/packages/contact-center/task/tests/utils/useHoldTimer.test.ts new file mode 100644 index 000000000..c45d49d90 --- /dev/null +++ b/packages/contact-center/task/tests/utils/useHoldTimer.test.ts @@ -0,0 +1,293 @@ +import {renderHook, waitFor} from '@testing-library/react'; +import {useHoldTimer} from '../../src/Utils/useHoldTimer'; +import {ITask} from '@webex/cc-store'; + +// Mock the findHoldTimestamp utility +jest.mock('../../src/Utils/task-util', () => ({ + findHoldTimestamp: jest.fn(), +})); + +import {findHoldTimestamp} from '../../src/Utils/task-util'; + +const mockFindHoldTimestamp = findHoldTimestamp as jest.MockedFunction; + +interface WorkerMessage { + type: string; + eventTime?: number; +} + +interface WorkerEvent { + data: { + type: string; + elapsed?: number; + }; +} + +// Mock Web Worker and URL APIs that aren't available in Jest/JSDOM +class MockWorker { + url: string; + onmessage: ((e: WorkerEvent) => void) | null = null; + + constructor(stringUrl: string) { + this.url = stringUrl; + } + + postMessage(msg: WorkerMessage) { + // Simulate worker behavior + if (msg.type === 'start') { + // Immediately send back elapsed time + setTimeout(() => { + if (this.onmessage) { + const elapsed = Math.floor((Date.now() - (msg.eventTime || 0)) / 1000); + this.onmessage({data: {type: 'elapsedTime', elapsed}}); + } + }, 0); + } else if (msg.type === 'stop') { + if (this.onmessage) { + this.onmessage({data: {type: 'stop'}}); + } + } + } + + terminate() { + this.onmessage = null; + } +} + +global.Worker = MockWorker as unknown as typeof Worker; +global.URL.createObjectURL = jest.fn(() => 'mock-url'); + +describe('useHoldTimer', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('should return 0 when currentTask is null', () => { + mockFindHoldTimestamp.mockReturnValue(null); + + const {result} = renderHook(() => useHoldTimer(null)); + + expect(result.current).toBe(0); + }); + + it('should return 0 when no hold timestamp found', () => { + mockFindHoldTimestamp.mockReturnValue(null); + + const mockTask = { + data: { + interaction: { + media: {}, + }, + }, + } as unknown as ITask; + + const {result} = renderHook(() => useHoldTimer(mockTask)); + + expect(result.current).toBe(0); + }); + + it('should set initial hold time when call is on hold', () => { + const holdTimestamp = Date.now() - 5000; // 5 seconds ago + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'consult') return null; + if (mType === 'mainCall') return holdTimestamp; + return null; + }); + + const mockTask = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: holdTimestamp, + }, + }, + }, + }, + } as unknown as ITask; + + const {result} = renderHook(() => useHoldTimer(mockTask)); + + // Should be approximately 5 seconds (with small margin for test execution) + expect(result.current).toBeGreaterThanOrEqual(4); + expect(result.current).toBeLessThanOrEqual(6); + }); + + it('should prioritize consult hold over main call hold', () => { + const consultHoldTimestamp = Date.now() - 3000; // 3 seconds ago + const mainCallHoldTimestamp = Date.now() - 10000; // 10 seconds ago + + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'consult') return consultHoldTimestamp; + if (mType === 'mainCall') return mainCallHoldTimestamp; + return null; + }); + + const mockTask = { + data: { + interaction: { + media: { + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: consultHoldTimestamp, + }, + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: mainCallHoldTimestamp, + }, + }, + }, + }, + } as unknown as ITask; + + const {result} = renderHook(() => useHoldTimer(mockTask)); + + // Should use consult hold time (3 seconds), not main call (10 seconds) + expect(result.current).toBeGreaterThanOrEqual(2); + expect(result.current).toBeLessThanOrEqual(4); + }); + + it('should handle timestamp in seconds and convert to milliseconds', () => { + const timestampInSeconds = Math.floor(Date.now() / 1000) - 7; // 7 seconds ago in seconds + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'mainCall') return timestampInSeconds; + return null; + }); + + const mockTask = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: timestampInSeconds, + }, + }, + }, + }, + } as unknown as ITask; + + const {result} = renderHook(() => useHoldTimer(mockTask)); + + // Should correctly convert and calculate ~7 seconds + expect(result.current).toBeGreaterThanOrEqual(6); + expect(result.current).toBeLessThanOrEqual(8); + }); + + it('should update hold time when currentTask changes', async () => { + const initialHoldTimestamp = Date.now() - 5000; + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'mainCall') return initialHoldTimestamp; + return null; + }); + + const mockTask1 = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: initialHoldTimestamp, + }, + }, + }, + }, + } as unknown as ITask; + + const {result, rerender} = renderHook(({task}) => useHoldTimer(task), { + initialProps: {task: mockTask1}, + }); + + const initialHoldTime = result.current; + expect(initialHoldTime).toBeGreaterThan(0); + + // Change to a new task with different hold timestamp + const newHoldTimestamp = Date.now() - 10000; + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'mainCall') return newHoldTimestamp; + return null; + }); + + const mockTask2 = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: newHoldTimestamp, + }, + }, + }, + }, + } as unknown as ITask; + + rerender({task: mockTask2}); + + await waitFor(() => { + expect(result.current).toBeGreaterThan(initialHoldTime); + }); + }); + + it('should reset to 0 when call is resumed', async () => { + const holdTimestamp = Date.now() - 5000; + mockFindHoldTimestamp.mockImplementation((task, mType) => { + if (mType === 'mainCall') return holdTimestamp; + return null; + }); + + const mockTask1 = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: true, + holdTimestamp: holdTimestamp, + }, + }, + }, + }, + } as unknown as ITask; + + const {result, rerender} = renderHook(({task}) => useHoldTimer(task), { + initialProps: {task: mockTask1}, + }); + + expect(result.current).toBeGreaterThan(0); + + // Resume call (no hold timestamp) + mockFindHoldTimestamp.mockReturnValue(null); + + const mockTask2 = { + data: { + interaction: { + media: { + 'main-id': { + mType: 'mainCall', + isHold: false, + }, + }, + }, + }, + } as unknown as ITask; + + rerender({task: mockTask2}); + + await waitFor(() => { + expect(result.current).toBe(0); + }); + }); +});