From 5b0db27eed4a9040de4e13f3d2e0ce0666af33f1 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Tue, 9 Dec 2025 08:16:39 +0530 Subject: [PATCH 1/4] fix(task-timer): implement-task-timer-issues --- .../call-control-consult.tsx | 18 +- .../task/CallControlCAD/call-control-cad.tsx | 12 ++ .../src/components/task/task.types.ts | 50 ++++++ .../call-control-consult.snapshot.tsx | 2 + .../call-control-consult.tsx | 2 + .../call-control-cad.snapshot.tsx.snap | 2 - .../contact-center/store/src/task-utils.ts | 29 ++++ packages/contact-center/task/src/helper.ts | 158 ++++++++++++++++-- .../task/tests/CallControl/index.tsx | 9 + .../task/tests/CallControlCAD/index.tsx | 36 ++++ 10 files changed, 296 insertions(+), 22 deletions(-) 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..1c4fa53ce 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,13 @@ 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,8 +19,13 @@ const CallControlConsultComponent: React.FC = controlVisibility, toggleConsultMute, }) => { + // Use the label and timestamp calculated in helper.ts + // Stable key based on original start time to prevent timer resets const timerKey = createTimerKey(startTimeStamp); + // Use consultTimerTimestamp if available, otherwise fallback to startTimeStamp + const effectiveTimestamp = consultTimerTimestamp || startTimeStamp || Date.now(); + const buttons = createConsultButtons( isMuted, controlVisibility, @@ -47,8 +49,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..87336ddf2 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 @@ -29,6 +29,8 @@ const CallControlCADComponent: React.FC = (props) => holdTime, consultAgentName, consultStartTimeStamp, + consultTimerLabel, + consultTimerTimestamp, endConsultCall, consultTransfer, consultConference, @@ -36,6 +38,8 @@ const CallControlCADComponent: React.FC = (props) => callControlClassName, callControlConsultClassName, startTimestamp, + stateTimerLabel, + stateTimerTimestamp, controlVisibility, logger, isMuted, @@ -166,6 +170,12 @@ const CallControlCADComponent: React.FC = (props) =>
{currentMediaType.labelName} - + {stateTimerLabel && stateTimerTimestamp && ( + <> + {' '} + • {stateTimerLabel} - + + )} {controlVisibility.isConferenceInProgress && !controlVisibility.wrapup.isVisible && ( <> @@ -272,6 +282,8 @@ const CallControlCADComponent: React.FC = (props) => 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..597da9efe 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 @@ -107,6 +107,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..7ae24ff11 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 @@ -92,6 +92,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/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap index 9bcd97e0d..bd155c0e6 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap @@ -235,7 +235,6 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control - Consulting  •  - Consulting  •  { + 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) { + return interaction.media[mediaId].holdTimestamp; + } + + return null; +}; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index a74a31e12..a0a3bf9e0 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -292,6 +292,21 @@ export const useCallControl = (props: useCallControlProps) => { const [holdTime, setHoldTime] = useState(0); const [startTimestamp, setStartTimestamp] = useState(0); const [secondsUntilAutoWrapup, setsecondsUntilAutoWrapup] = useState(null); + + // Timestamp state variables for various call states + const [consultStartTimeStamp, setConsultStartTimeStamp] = useState(0); + const [wrapUpTimestamp, setWrapUpTimestamp] = useState(0); + const [postCallTimestamp, setPostCallTimestamp] = useState(0); + const [holdTimestamp, setHoldTimestamp] = useState(0); + const [consultHoldTimestamp, setConsultHoldTimestamp] = useState(0); + + // State timer labels and timestamps + const [stateTimerLabel, setStateTimerLabel] = useState(null); + const [stateTimerTimestamp, setStateTimerTimestamp] = useState(0); + + // Consult timer labels and timestamps + const [consultTimerLabel, setConsultTimerLabel] = useState('Consulting'); + const [consultTimerTimestamp, setConsultTimerTimestamp] = useState(0); const workerRef = useRef(null); const [lastTargetType, setLastTargetType] = useState<'agent' | 'queue'>('agent'); const [conferenceParticipants, setConferenceParticipants] = useState([]); @@ -326,13 +341,20 @@ export const useCallControl = (props: useCallControlProps) => { workerRef.current = null; } - // Get holdTimestamp from the interaction object - const holdTimestamp = currentTask?.data?.interaction + // 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; - if (holdTimestamp) { - const holdTimeMs = holdTimestamp < 10000000000 ? holdTimestamp * 1000 : holdTimestamp; + // 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([workerScript], {type: 'application/javascript'}); const workerUrl = URL.createObjectURL(blob); workerRef.current = new Worker(workerUrl); @@ -399,17 +421,64 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, logger]); - // Check for consulting agent whenever currentTask changes + // Extract all timestamps 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; + } + + const participant = currentTask.data.interaction.participants[agentId]; + const interaction = currentTask.data.interaction; + + if (!participant) { + return; + } + + // 1. Main call timer - use joinTimestamp + if (participant.joinTimestamp) { + setStartTimestamp(participant.joinTimestamp); + } + + // 2. Consult timer - use consultTimestamp or fallback to lastUpdated + // This ensures consult timer doesn't reset when resuming from hold + if (participant.consultTimestamp) { + setConsultStartTimeStamp(participant.consultTimestamp); + } else if (participant.lastUpdated) { + setConsultStartTimeStamp(participant.lastUpdated); + } + + // 3. Wrap-up timer - if currently in wrap-up, use lastUpdated; otherwise use wrapUpTimestamp + if (participant.isWrapUp) { + setWrapUpTimestamp(participant.lastUpdated || 0); + } else { + setWrapUpTimestamp(participant.wrapUpTimestamp || 0); + } + + // 4. Post-call timer - use currentStateTimestamp + if (participant.currentStateTimestamp) { + setPostCallTimestamp(participant.currentStateTimestamp); + } else { + setPostCallTimestamp(0); + } + + // 5. Main call hold timestamp + const mainHoldTs = findHoldTimestamp(interaction, 'mainCall'); + if (mainHoldTs) { + setHoldTimestamp(mainHoldTs); + } else { + setHoldTimestamp(0); + } + + // 6. Consult hold timestamp + const consultHoldTs = findHoldTimestamp(interaction, 'consult'); + if (consultHoldTs) { + setConsultHoldTimestamp(consultHoldTs); + } else { + setConsultHoldTimestamp(0); } - }, [currentTask, extractConsultingAgent]); + }, [currentTask, agentId, extractConsultingAgent]); const loadBuddyAgents = useCallback(async () => { try { @@ -911,6 +980,62 @@ export const useCallControl = (props: useCallControlProps) => { }; }, [currentTask?.autoWrapup, controlVisibility?.wrapup]); + // Calculate state timer label and timestamp + // Priority: Wrap Up > Post Call + useEffect(() => { + if (!currentTask || !controlVisibility) { + setStateTimerLabel(null); + setStateTimerTimestamp(0); + return; + } + + const interaction = currentTask.data?.interaction; + const participant = interaction?.participants?.[agentId]; + + // Priority 1: Wrap-up state (highest priority) + if (controlVisibility.wrapup?.isVisible && wrapUpTimestamp) { + setStateTimerLabel('Wrap Up'); + setStateTimerTimestamp(wrapUpTimestamp); + } + // Priority 2: Post-call state (only if not in wrap-up) + else { + const isInPostCall = interaction?.state === 'post_call' || participant?.currentState === 'post_call'; + if (isInPostCall && postCallTimestamp) { + setStateTimerLabel('Post Call'); + setStateTimerTimestamp(postCallTimestamp); + } else { + setStateTimerLabel(null); + setStateTimerTimestamp(0); + } + } + }, [currentTask, controlVisibility, wrapUpTimestamp, postCallTimestamp, agentId]); + + // Calculate consult timer label and timestamp + useEffect(() => { + if (!currentTask || !consultStartTimeStamp || !controlVisibility) { + setConsultTimerLabel('Consulting'); + setConsultTimerTimestamp(0); + return; + } + + // Determine label and timestamp based on consult hold state + if (controlVisibility.consultCallHeld) { + setConsultTimerLabel('Consult on Hold'); + // Use consultHoldTimestamp when on hold + if (consultHoldTimestamp && consultHoldTimestamp > 0) { + setConsultTimerTimestamp(consultHoldTimestamp); + } else { + // Fallback to consult start time + setConsultTimerTimestamp(consultStartTimeStamp); + } + } else { + // Active consulting - use consultStartTimeStamp to preserve original timer + const label = controlVisibility.isConsultInitiated ? 'Consult Requested' : 'Consulting'; + setConsultTimerLabel(label); + setConsultTimerTimestamp(consultStartTimeStamp); + } + }, [currentTask, consultStartTimeStamp, consultHoldTimestamp, controlVisibility, agentId]); + return { currentTask, endCall, @@ -935,6 +1060,15 @@ export const useCallControl = (props: useCallControlProps) => { setConsultAgentName, holdTime, startTimestamp, + consultStartTimeStamp, + wrapUpTimestamp, + postCallTimestamp, + holdTimestamp, + consultHoldTimestamp, + 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..6146b77a4 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -89,6 +89,15 @@ 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}}), + consultStartTimeStamp: 0, + wrapUpTimestamp: 0, + postCallTimestamp: 0, + holdTimestamp: 0, + consultHoldTimestamp: 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..a7ae939a5 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -85,6 +85,15 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + consultStartTimeStamp: 0, + wrapUpTimestamp: 0, + postCallTimestamp: 0, + holdTimestamp: 0, + consultHoldTimestamp: 0, + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render( @@ -178,6 +187,15 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + consultStartTimeStamp: 0, + wrapUpTimestamp: 0, + postCallTimestamp: 0, + holdTimestamp: 0, + consultHoldTimestamp: 0, + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render(); @@ -252,6 +270,15 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + consultStartTimeStamp: 0, + wrapUpTimestamp: 0, + postCallTimestamp: 0, + holdTimestamp: 0, + consultHoldTimestamp: 0, + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); render( @@ -328,6 +355,15 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), + consultStartTimeStamp: 0, + wrapUpTimestamp: 0, + postCallTimestamp: 0, + holdTimestamp: 0, + consultHoldTimestamp: 0, + stateTimerLabel: null, + stateTimerTimestamp: 0, + consultTimerLabel: 'Consulting', + consultTimerTimestamp: 0, }); const {container} = render( From ed36679b839155e248cb9d7ad6687cb26252e741 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Wed, 10 Dec 2025 17:01:59 +0530 Subject: [PATCH 2/4] fix(task-timer): add-unit-test --- .../src/components/task/task.types.ts | 6 - .../contact-center/store/src/task-utils.ts | 2 +- .../contact-center/store/tests/task-utils.ts | 147 ++++++ packages/contact-center/task/src/helper.ts | 12 +- .../task/tests/CallControl/index.tsx | 1 - .../task/tests/CallControlCAD/index.tsx | 4 - packages/contact-center/task/tests/helper.ts | 486 +++++++++++++++++- 7 files changed, 629 insertions(+), 29 deletions(-) diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 8dcc9e55e..83e2880b7 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -337,11 +337,6 @@ export interface ControlProps { */ postCallTimestamp?: number; - /** - * Timestamp when main call was put on hold. - */ - holdTimestamp?: number; - /** * Timestamp when consult call was put on hold. */ @@ -510,7 +505,6 @@ export type CallControlComponentProps = Pick< | 'startTimestamp' | 'wrapUpTimestamp' | 'postCallTimestamp' - | 'holdTimestamp' | 'consultHoldTimestamp' | 'stateTimerLabel' | 'stateTimerTimestamp' diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index 24fe53e60..0f7cf870b 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -354,7 +354,7 @@ export const findHoldTimestamp = (task: ITask, mType: string): number | null => const mediaId = findMediaResourceId(task, mType); // Return the holdTimestamp if media exists and has a hold timestamp - if (mediaId && interaction.media[mediaId]?.holdTimestamp) { + if (mediaId && interaction.media[mediaId]?.holdTimestamp !== undefined) { return interaction.media[mediaId].holdTimestamp; } 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/helper.ts b/packages/contact-center/task/src/helper.ts index a0a3bf9e0..82294e2f0 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -297,7 +297,6 @@ export const useCallControl = (props: useCallControlProps) => { const [consultStartTimeStamp, setConsultStartTimeStamp] = useState(0); const [wrapUpTimestamp, setWrapUpTimestamp] = useState(0); const [postCallTimestamp, setPostCallTimestamp] = useState(0); - const [holdTimestamp, setHoldTimestamp] = useState(0); const [consultHoldTimestamp, setConsultHoldTimestamp] = useState(0); // State timer labels and timestamps @@ -463,15 +462,7 @@ export const useCallControl = (props: useCallControlProps) => { setPostCallTimestamp(0); } - // 5. Main call hold timestamp - const mainHoldTs = findHoldTimestamp(interaction, 'mainCall'); - if (mainHoldTs) { - setHoldTimestamp(mainHoldTs); - } else { - setHoldTimestamp(0); - } - - // 6. Consult hold timestamp + // 5. Consult hold timestamp const consultHoldTs = findHoldTimestamp(interaction, 'consult'); if (consultHoldTs) { setConsultHoldTimestamp(consultHoldTs); @@ -1063,7 +1054,6 @@ export const useCallControl = (props: useCallControlProps) => { consultStartTimeStamp, wrapUpTimestamp, postCallTimestamp, - holdTimestamp, consultHoldTimestamp, stateTimerLabel, stateTimerTimestamp, diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 6146b77a4..4b066efd5 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -92,7 +92,6 @@ describe('CallControl Component', () => { consultStartTimeStamp: 0, wrapUpTimestamp: 0, postCallTimestamp: 0, - holdTimestamp: 0, consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index a7ae939a5..1358b36bf 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -88,7 +88,6 @@ describe('CallControlCAD Component', () => { consultStartTimeStamp: 0, wrapUpTimestamp: 0, postCallTimestamp: 0, - holdTimestamp: 0, consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, @@ -190,7 +189,6 @@ describe('CallControlCAD Component', () => { consultStartTimeStamp: 0, wrapUpTimestamp: 0, postCallTimestamp: 0, - holdTimestamp: 0, consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, @@ -273,7 +271,6 @@ describe('CallControlCAD Component', () => { consultStartTimeStamp: 0, wrapUpTimestamp: 0, postCallTimestamp: 0, - holdTimestamp: 0, consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, @@ -358,7 +355,6 @@ describe('CallControlCAD Component', () => { consultStartTimeStamp: 0, wrapUpTimestamp: 0, postCallTimestamp: 0, - holdTimestamp: 0, consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 7567c0fd3..c2cfcf261 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -3395,6 +3395,480 @@ 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 extract consultStartTimeStamp from consultTimestamp', () => { + mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); + const mockTaskWithConsult = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + participants: { + agent1: { + joinTimestamp: 1000, + consultTimestamp: 2000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskWithConsult, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + expect(result.current.consultStartTimeStamp).toBe(2000); + }); + + it('should extract wrapUpTimestamp when in wrapup state', () => { + mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); + const mockTaskInWrapup = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + participants: { + agent1: { + joinTimestamp: 1000, + isWrapUp: true, + lastUpdated: 3000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskInWrapup, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + expect(result.current.wrapUpTimestamp).toBe(3000); + }); + + it('should extract postCallTimestamp from currentStateTimestamp', () => { + mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); + const mockTaskInPostCall = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + interaction: { + ...mockCurrentTask.data.interaction, + state: 'post_call', + participants: { + agent1: { + joinTimestamp: 1000, + currentStateTimestamp: 4000, + pType: 'Agent', + }, + }, + }, + }, + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockTaskInPostCall, + logger, + deviceType: 'BROWSER', + featureFlags: {}, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + expect(result.current.postCallTimestamp).toBe(4000); + }); + + 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('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('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('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('Consult on Hold'); + expect(result.current.consultTimerTimestamp).toBe(5000); + }); + }); + + it('should use consultStartTimeStamp 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 consultStartTimeStamp (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('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 +4937,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 +4965,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const errorTask = { ...mockTaskWithInteraction, @@ -4716,7 +5190,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 +5218,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const taskWithAutoWrapup = { ...mockTaskWithInteraction, @@ -4778,7 +5252,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 +5280,7 @@ describe('Task Hook Error Handling and Logging', () => { isConsultReceived: false, isHeld: false, consultCallHeld: false, - }); + })); const taskWithAutoWrapup = { ...mockTaskWithInteraction, From 6f3ee78e982032990ce124ecafdb7f195f2d7bf2 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 11 Dec 2025 10:48:55 +0530 Subject: [PATCH 3/4] fix(task-timer): create-timer-constants --- .../task/src/Utils/constants.ts | 7 +++++++ packages/contact-center/task/src/helper.ts | 19 +++++++++++++------ packages/contact-center/task/tests/helper.ts | 16 +++++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) 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/helper.ts b/packages/contact-center/task/src/helper.ts index 82294e2f0..ae68a2712 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -12,6 +12,13 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, } from '@webex/cc-store'; import {findHoldTimestamp, getControlsVisibility} from './Utils/task-util'; +import { + TIMER_LABEL_WRAP_UP, + TIMER_LABEL_POST_CALL, + TIMER_LABEL_CONSULT_ON_HOLD, + TIMER_LABEL_CONSULT_REQUESTED, + TIMER_LABEL_CONSULTING, +} from './Utils/constants'; import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/services/config/types'; const ENGAGED_LABEL = 'ENGAGED'; @@ -304,7 +311,7 @@ export const useCallControl = (props: useCallControlProps) => { const [stateTimerTimestamp, setStateTimerTimestamp] = useState(0); // Consult timer labels and timestamps - const [consultTimerLabel, setConsultTimerLabel] = useState('Consulting'); + const [consultTimerLabel, setConsultTimerLabel] = useState(TIMER_LABEL_CONSULTING); const [consultTimerTimestamp, setConsultTimerTimestamp] = useState(0); const workerRef = useRef(null); const [lastTargetType, setLastTargetType] = useState<'agent' | 'queue'>('agent'); @@ -985,14 +992,14 @@ export const useCallControl = (props: useCallControlProps) => { // Priority 1: Wrap-up state (highest priority) if (controlVisibility.wrapup?.isVisible && wrapUpTimestamp) { - setStateTimerLabel('Wrap Up'); + setStateTimerLabel(TIMER_LABEL_WRAP_UP); setStateTimerTimestamp(wrapUpTimestamp); } // Priority 2: Post-call state (only if not in wrap-up) else { const isInPostCall = interaction?.state === 'post_call' || participant?.currentState === 'post_call'; if (isInPostCall && postCallTimestamp) { - setStateTimerLabel('Post Call'); + setStateTimerLabel(TIMER_LABEL_POST_CALL); setStateTimerTimestamp(postCallTimestamp); } else { setStateTimerLabel(null); @@ -1004,14 +1011,14 @@ export const useCallControl = (props: useCallControlProps) => { // Calculate consult timer label and timestamp useEffect(() => { if (!currentTask || !consultStartTimeStamp || !controlVisibility) { - setConsultTimerLabel('Consulting'); + setConsultTimerLabel(TIMER_LABEL_CONSULTING); setConsultTimerTimestamp(0); return; } // Determine label and timestamp based on consult hold state if (controlVisibility.consultCallHeld) { - setConsultTimerLabel('Consult on Hold'); + setConsultTimerLabel(TIMER_LABEL_CONSULT_ON_HOLD); // Use consultHoldTimestamp when on hold if (consultHoldTimestamp && consultHoldTimestamp > 0) { setConsultTimerTimestamp(consultHoldTimestamp); @@ -1021,7 +1028,7 @@ export const useCallControl = (props: useCallControlProps) => { } } else { // Active consulting - use consultStartTimeStamp to preserve original timer - const label = controlVisibility.isConsultInitiated ? 'Consult Requested' : 'Consulting'; + const label = controlVisibility.isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; setConsultTimerLabel(label); setConsultTimerTimestamp(consultStartTimeStamp); } diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index c2cfcf261..550f9a844 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 { @@ -3567,7 +3573,7 @@ describe('useCallControl', () => { ); await waitFor(() => { - expect(result.current.stateTimerLabel).toBe('Wrap Up'); + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_WRAP_UP); expect(result.current.stateTimerTimestamp).toBe(3000); }); }); @@ -3609,7 +3615,7 @@ describe('useCallControl', () => { }) ); - expect(result.current.stateTimerLabel).toBe('Post Call'); + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_POST_CALL); expect(result.current.stateTimerTimestamp).toBe(4000); }); @@ -3649,7 +3655,7 @@ describe('useCallControl', () => { ); await waitFor(() => { - expect(result.current.stateTimerLabel).toBe('Wrap Up'); + expect(result.current.stateTimerLabel).toBe(TIMER_LABEL_WRAP_UP); expect(result.current.stateTimerTimestamp).toBe(3000); }); }); @@ -3695,7 +3701,7 @@ describe('useCallControl', () => { ); await waitFor(() => { - expect(result.current.consultTimerLabel).toBe('Consult on Hold'); + expect(result.current.consultTimerLabel).toBe(TIMER_LABEL_CONSULT_ON_HOLD); expect(result.current.consultTimerTimestamp).toBe(5000); }); }); @@ -3788,7 +3794,7 @@ describe('useCallControl', () => { }) ); - expect(result.current.consultTimerLabel).toBe('Consulting'); + expect(result.current.consultTimerLabel).toBe(TIMER_LABEL_CONSULTING); expect(result.current.consultTimerTimestamp).toBe(2000); }); From 9e16d13a53aad8d96a435931b4998f1f7715adf7 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Fri, 12 Dec 2025 15:57:59 +0530 Subject: [PATCH 4/4] fix(task-timer): address-review-comments --- .../call-control-consult.tsx | 9 +- .../task/CallControlCAD/call-control-cad.tsx | 2 - .../src/components/task/task.types.ts | 25 -- .../call-control-consult.snapshot.tsx | 1 - .../call-control-consult.tsx | 1 - .../CallControl/call-control.snapshot.tsx | 5 +- .../task/CallControl/call-control.tsx | 5 +- .../call-control-cad.snapshot.tsx.snap | 2 + .../call-control-cad.snapshot.tsx | 6 +- .../task/CallControlCAD/call-control-cad.tsx | 6 +- .../task/src/Utils/timer-utils.ts | 141 ++++++++ .../task/src/Utils/useHoldTimer.ts | 89 +++++ packages/contact-center/task/src/helper.ts | 194 +--------- .../task/tests/CallControl/index.tsx | 4 - .../task/tests/CallControlCAD/index.tsx | 16 - packages/contact-center/task/tests/helper.ts | 108 +----- .../task/tests/utils/timer-utils.test.ts | 332 ++++++++++++++++++ .../task/tests/utils/useHoldTimer.test.ts | 293 ++++++++++++++++ 18 files changed, 898 insertions(+), 341 deletions(-) create mode 100644 packages/contact-center/task/src/Utils/timer-utils.ts create mode 100644 packages/contact-center/task/src/Utils/useHoldTimer.ts create mode 100644 packages/contact-center/task/tests/utils/timer-utils.test.ts create mode 100644 packages/contact-center/task/tests/utils/useHoldTimer.test.ts 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 1c4fa53ce..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 @@ -7,7 +7,6 @@ import {createConsultButtons, getVisibleButtons, createTimerKey} from './call-co const CallControlConsultComponent: React.FC = ({ agentName, - startTimeStamp, consultTimerLabel, consultTimerTimestamp, consultTransfer, @@ -20,11 +19,11 @@ const CallControlConsultComponent: React.FC = toggleConsultMute, }) => { // Use the label and timestamp calculated in helper.ts - // Stable key based on original start time to prevent timer resets - const timerKey = createTimerKey(startTimeStamp); + // Stable key based on timestamp to prevent timer resets + const timerKey = createTimerKey(consultTimerTimestamp); - // Use consultTimerTimestamp if available, otherwise fallback to startTimeStamp - const effectiveTimestamp = consultTimerTimestamp || startTimeStamp || Date.now(); + // Use consultTimerTimestamp with fallback + const effectiveTimestamp = consultTimerTimestamp || Date.now(); const buttons = createConsultButtons( isMuted, 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 87336ddf2..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,6 @@ const CallControlCADComponent: React.FC = (props) => isRecording, holdTime, consultAgentName, - consultStartTimeStamp, consultTimerLabel, consultTimerTimestamp, endConsultCall, @@ -281,7 +280,6 @@ const CallControlCADComponent: React.FC = (props) =>
void; - /** - * Timestamp when the consult call started. - */ - consultStartTimeStamp?: number; - - /** - * Timestamp when wrap-up state started. - */ - wrapUpTimestamp?: number; - - /** - * Timestamp when post-call state started. - */ - postCallTimestamp?: number; - - /** - * Timestamp when consult call was put on hold. - */ - consultHoldTimestamp?: number; - /** * Label for the state timer (e.g., "Wrap Up", "Post Call"). */ @@ -495,7 +475,6 @@ export type CallControlComponentProps = Pick< | 'exitConference' | 'endConsultCall' | 'consultTransfer' - | 'consultStartTimeStamp' | 'callControlAudio' | 'consultAgentName' | 'setConsultAgentName' @@ -503,9 +482,6 @@ export type CallControlComponentProps = Pick< | 'callControlClassName' | 'callControlConsultClassName' | 'startTimestamp' - | 'wrapUpTimestamp' - | 'postCallTimestamp' - | 'consultHoldTimestamp' | 'stateTimerLabel' | 'stateTimerTimestamp' | 'consultTimerLabel' @@ -625,7 +601,6 @@ export interface ConsultTransferPopoverComponentProps { */ export interface CallControlConsultComponentsProps { agentName: string; - startTimeStamp: number; consultTimerLabel: string; consultTimerTimestamp: number; consultTransfer: () => 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 597da9efe..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,6 @@ describe('CallControlConsultComponent Snapshots', () => { const defaultProps = { agentName: 'Alice', - startTimeStamp: Date.now(), consultTimerLabel: 'Consulting', consultTimerTimestamp: Date.now(), consultTransfer: mockOnTransfer, 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 7ae24ff11..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,6 @@ describe('CallControlConsultComponent', () => { const defaultProps = { agentName: 'Alice', - startTimeStamp: Date.now(), consultTimerLabel: 'Consulting', consultTimerTimestamp: Date.now(), consultTransfer: mockOnTransfer, 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/__snapshots__/call-control-cad.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap index bd155c0e6..9bcd97e0d 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap @@ -235,6 +235,7 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control + Consulting  •  + Consulting  •  { 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/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 ae68a2712..6cdaa1e71 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 {ITask} from '@webex/contact-center'; import {useCallControlProps, UseTaskListProps, UseTaskProps, useOutdialCallProps} from './task.types'; import store, { @@ -11,14 +11,10 @@ import store, { findMediaResourceId, MEDIA_TYPE_TELEPHONY_LOWER, } from '@webex/cc-store'; -import {findHoldTimestamp, getControlsVisibility} from './Utils/task-util'; -import { - TIMER_LABEL_WRAP_UP, - TIMER_LABEL_POST_CALL, - TIMER_LABEL_CONSULT_ON_HOLD, - TIMER_LABEL_CONSULT_REQUESTED, - TIMER_LABEL_CONSULTING, -} from './Utils/constants'; +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'; @@ -296,16 +292,9 @@ 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); - // Timestamp state variables for various call states - const [consultStartTimeStamp, setConsultStartTimeStamp] = useState(0); - const [wrapUpTimestamp, setWrapUpTimestamp] = useState(0); - const [postCallTimestamp, setPostCallTimestamp] = useState(0); - const [consultHoldTimestamp, setConsultHoldTimestamp] = useState(0); - // State timer labels and timestamps const [stateTimerLabel, setStateTimerLabel] = useState(null); const [stateTimerTimestamp, setStateTimerTimestamp] = useState(0); @@ -313,80 +302,11 @@ export const useCallControl = (props: useCallControlProps) => { // Consult timer labels and timestamps const [consultTimerLabel, setConsultTimerLabel] = useState(TIMER_LABEL_CONSULTING); const [consultTimerTimestamp, setConsultTimerTimestamp] = useState(0); - 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 - 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([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)); - - 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]); + // Use custom hook for hold timer management + const holdTime = useHoldTimer(currentTask); useEffect(() => { if (currentTask && store?.cc?.agentConfig?.agentId) { @@ -427,7 +347,7 @@ export const useCallControl = (props: useCallControlProps) => { } }, [currentTask, logger]); - // Extract all timestamps whenever currentTask changes + // Extract main call timestamp whenever currentTask changes useEffect(() => { extractConsultingAgent(); @@ -436,46 +356,15 @@ export const useCallControl = (props: useCallControlProps) => { } const participant = currentTask.data.interaction.participants[agentId]; - const interaction = currentTask.data.interaction; if (!participant) { return; } - // 1. Main call timer - use joinTimestamp + // Main call timer - use joinTimestamp if (participant.joinTimestamp) { setStartTimestamp(participant.joinTimestamp); } - - // 2. Consult timer - use consultTimestamp or fallback to lastUpdated - // This ensures consult timer doesn't reset when resuming from hold - if (participant.consultTimestamp) { - setConsultStartTimeStamp(participant.consultTimestamp); - } else if (participant.lastUpdated) { - setConsultStartTimeStamp(participant.lastUpdated); - } - - // 3. Wrap-up timer - if currently in wrap-up, use lastUpdated; otherwise use wrapUpTimestamp - if (participant.isWrapUp) { - setWrapUpTimestamp(participant.lastUpdated || 0); - } else { - setWrapUpTimestamp(participant.wrapUpTimestamp || 0); - } - - // 4. Post-call timer - use currentStateTimestamp - if (participant.currentStateTimestamp) { - setPostCallTimestamp(participant.currentStateTimestamp); - } else { - setPostCallTimestamp(0); - } - - // 5. Consult hold timestamp - const consultHoldTs = findHoldTimestamp(interaction, 'consult'); - if (consultHoldTs) { - setConsultHoldTimestamp(consultHoldTs); - } else { - setConsultHoldTimestamp(0); - } }, [currentTask, agentId, extractConsultingAgent]); const loadBuddyAgents = useCallback(async () => { @@ -978,61 +867,20 @@ export const useCallControl = (props: useCallControlProps) => { }; }, [currentTask?.autoWrapup, controlVisibility?.wrapup]); - // Calculate state timer label and timestamp + // Calculate state timer label and timestamp using utils // Priority: Wrap Up > Post Call useEffect(() => { - if (!currentTask || !controlVisibility) { - setStateTimerLabel(null); - setStateTimerTimestamp(0); - return; - } - - const interaction = currentTask.data?.interaction; - const participant = interaction?.participants?.[agentId]; - - // Priority 1: Wrap-up state (highest priority) - if (controlVisibility.wrapup?.isVisible && wrapUpTimestamp) { - setStateTimerLabel(TIMER_LABEL_WRAP_UP); - setStateTimerTimestamp(wrapUpTimestamp); - } - // Priority 2: Post-call state (only if not in wrap-up) - else { - const isInPostCall = interaction?.state === 'post_call' || participant?.currentState === 'post_call'; - if (isInPostCall && postCallTimestamp) { - setStateTimerLabel(TIMER_LABEL_POST_CALL); - setStateTimerTimestamp(postCallTimestamp); - } else { - setStateTimerLabel(null); - setStateTimerTimestamp(0); - } - } - }, [currentTask, controlVisibility, wrapUpTimestamp, postCallTimestamp, agentId]); + const stateTimerData = calculateStateTimerData(currentTask, controlVisibility, agentId); + setStateTimerLabel(stateTimerData.label); + setStateTimerTimestamp(stateTimerData.timestamp); + }, [currentTask, controlVisibility, agentId]); - // Calculate consult timer label and timestamp + // Calculate consult timer label and timestamp using utils useEffect(() => { - if (!currentTask || !consultStartTimeStamp || !controlVisibility) { - setConsultTimerLabel(TIMER_LABEL_CONSULTING); - setConsultTimerTimestamp(0); - return; - } - - // Determine label and timestamp based on consult hold state - if (controlVisibility.consultCallHeld) { - setConsultTimerLabel(TIMER_LABEL_CONSULT_ON_HOLD); - // Use consultHoldTimestamp when on hold - if (consultHoldTimestamp && consultHoldTimestamp > 0) { - setConsultTimerTimestamp(consultHoldTimestamp); - } else { - // Fallback to consult start time - setConsultTimerTimestamp(consultStartTimeStamp); - } - } else { - // Active consulting - use consultStartTimeStamp to preserve original timer - const label = controlVisibility.isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; - setConsultTimerLabel(label); - setConsultTimerTimestamp(consultStartTimeStamp); - } - }, [currentTask, consultStartTimeStamp, consultHoldTimestamp, controlVisibility, agentId]); + const consultTimerData = calculateConsultTimerData(currentTask, controlVisibility, agentId); + setConsultTimerLabel(consultTimerData.label); + setConsultTimerTimestamp(consultTimerData.timestamp); + }, [currentTask, controlVisibility, agentId]); return { currentTask, @@ -1058,10 +906,6 @@ export const useCallControl = (props: useCallControlProps) => { setConsultAgentName, holdTime, startTimestamp, - consultStartTimeStamp, - wrapUpTimestamp, - postCallTimestamp, - consultHoldTimestamp, stateTimerLabel, stateTimerTimestamp, consultTimerLabel, diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 4b066efd5..3f92280ce 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -89,10 +89,6 @@ 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}}), - consultStartTimeStamp: 0, - wrapUpTimestamp: 0, - postCallTimestamp: 0, - consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index 1358b36bf..2a0d2908a 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -85,10 +85,6 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), - consultStartTimeStamp: 0, - wrapUpTimestamp: 0, - postCallTimestamp: 0, - consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', @@ -186,10 +182,6 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), - consultStartTimeStamp: 0, - wrapUpTimestamp: 0, - postCallTimestamp: 0, - consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', @@ -268,10 +260,6 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), - consultStartTimeStamp: 0, - wrapUpTimestamp: 0, - postCallTimestamp: 0, - consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', @@ -352,10 +340,6 @@ describe('CallControlCAD Component', () => { getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), - consultStartTimeStamp: 0, - wrapUpTimestamp: 0, - postCallTimestamp: 0, - consultHoldTimestamp: 0, stateTimerLabel: null, stateTimerTimestamp: 0, consultTimerLabel: 'Consulting', diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 550f9a844..725959c80 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -3435,110 +3435,6 @@ describe('useCallControl', () => { consultCallHeld: false, }; - it('should extract consultStartTimeStamp from consultTimestamp', () => { - mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); - const mockTaskWithConsult = { - ...mockCurrentTask, - data: { - ...mockCurrentTask.data, - interaction: { - ...mockCurrentTask.data.interaction, - participants: { - agent1: { - joinTimestamp: 1000, - consultTimestamp: 2000, - pType: 'Agent', - }, - }, - }, - }, - }; - - const {result} = renderHook(() => - useCallControl({ - currentTask: mockTaskWithConsult, - logger, - deviceType: 'BROWSER', - featureFlags: {}, - isMuted: false, - conferenceEnabled: false, - agentId: 'agent1', - }) - ); - - expect(result.current.consultStartTimeStamp).toBe(2000); - }); - - it('should extract wrapUpTimestamp when in wrapup state', () => { - mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); - const mockTaskInWrapup = { - ...mockCurrentTask, - data: { - ...mockCurrentTask.data, - interaction: { - ...mockCurrentTask.data.interaction, - participants: { - agent1: { - joinTimestamp: 1000, - isWrapUp: true, - lastUpdated: 3000, - pType: 'Agent', - }, - }, - }, - }, - }; - - const {result} = renderHook(() => - useCallControl({ - currentTask: mockTaskInWrapup, - logger, - deviceType: 'BROWSER', - featureFlags: {}, - isMuted: false, - conferenceEnabled: false, - agentId: 'agent1', - }) - ); - - expect(result.current.wrapUpTimestamp).toBe(3000); - }); - - it('should extract postCallTimestamp from currentStateTimestamp', () => { - mockGetControlsVisibility.mockImplementation(() => mockControlVisibility); - const mockTaskInPostCall = { - ...mockCurrentTask, - data: { - ...mockCurrentTask.data, - interaction: { - ...mockCurrentTask.data.interaction, - state: 'post_call', - participants: { - agent1: { - joinTimestamp: 1000, - currentStateTimestamp: 4000, - pType: 'Agent', - }, - }, - }, - }, - }; - - const {result} = renderHook(() => - useCallControl({ - currentTask: mockTaskInPostCall, - logger, - deviceType: 'BROWSER', - featureFlags: {}, - isMuted: false, - conferenceEnabled: false, - agentId: 'agent1', - }) - ); - - expect(result.current.postCallTimestamp).toBe(4000); - }); - it('should set stateTimerLabel to "Wrap Up" when in wrapup state', async () => { const mockTaskInWrapup = { ...mockCurrentTask, @@ -3706,7 +3602,7 @@ describe('useCallControl', () => { }); }); - it('should use consultStartTimeStamp for active consult timer', async () => { + it('should use consultTimestamp for active consult timer', async () => { const mockTaskWithActiveConsult = { ...mockCurrentTask, data: { @@ -3751,7 +3647,7 @@ describe('useCallControl', () => { ); await waitFor(() => { - // The timer should use the consultStartTimeStamp (2000) + // 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/); 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); + }); + }); +});