Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, {useEffect, useRef, useState, useCallback} from 'react';
import {Text} from '@momentum-design/components/dist/react';
import {CampaignCountdownProps} from './campaign-countdown.types';
import {formatCountdown, calculateRemainingSeconds} from './campaign-countdown.utils';
import {withMetrics} from '@webex/cc-ui-logging';
import {TIME_LEFT} from '../constants';

const CampaignCountdown: React.FC<CampaignCountdownProps> = ({
timeoutInSeconds,
timeoutTimestamp,
onTimeout,
logger,
}) => {
const calculateRemaining = useCallback((): number => {
return calculateRemainingSeconds(timeoutTimestamp, timeoutInSeconds, logger);
}, [timeoutTimestamp, timeoutInSeconds, logger]);

const [remainingSeconds, setRemainingSeconds] = useState<number>(calculateRemaining());
const [hasTimedOut, setHasTimedOut] = useState<boolean>(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>();

useEffect(() => {
setRemainingSeconds(calculateRemaining());
setHasTimedOut(false);
}, [timeoutTimestamp, timeoutInSeconds, calculateRemaining]);

useEffect(() => {
if (timerRef.current !== undefined) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}

if (remainingSeconds > 0) {
timerRef.current = setTimeout(() => {
// Recalculate from wall clock when using timeoutTimestamp to handle
// browser throttling (background tabs, blocked main thread)
const newRemaining = timeoutTimestamp !== undefined ? calculateRemaining() : remainingSeconds - 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect fallback countdown when timestamp is unparsable

Use the same validity check here that calculateRemainingSeconds uses, rather than only timeoutTimestamp !== undefined. With inputs like timeoutTimestamp: '' (or any unparsable string) plus timeoutInSeconds, calculateRemaining() falls back to the original timeoutInSeconds value, and this branch reassigns that same value every tick, so the countdown never decreases and onTimeout never fires. This can happen when backend data sends an empty/invalid timestamp while the seconds fallback is provided.

Useful? React with 👍 / 👎.

setRemainingSeconds(newRemaining);
timerRef.current = undefined;
Comment thread
brain-frog marked this conversation as resolved.
}, 1000);
} else if (remainingSeconds === 0 && !hasTimedOut) {
setHasTimedOut(true);
Comment thread
brain-frog marked this conversation as resolved.
onTimeout?.();
Comment on lines +41 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip firing timeout callback without a configured deadline

This branch calls onTimeout immediately whenever remainingSeconds initializes to 0, which also happens when both timeoutTimestamp and timeoutInSeconds are absent (or not yet populated on first render). In that case a parent that always passes onTimeout can trigger the campaign timeout action instantly before backend timeout data arrives, causing premature task expiration. Add a guard so the callback only fires when at least one valid timeout source is configured.

Useful? React with 👍 / 👎.

}

return () => {
if (timerRef.current !== undefined) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
}, [remainingSeconds, hasTimedOut, onTimeout, timeoutTimestamp, calculateRemaining]);

const formattedTime = formatCountdown(remainingSeconds, logger);

return (
<Text type="body-midsize-regular" className="task-text" data-testid="campaign-countdown">
{TIME_LEFT} {formattedTime}
</Text>
);
};

const CampaignCountdownWithMetrics = withMetrics(CampaignCountdown, 'CampaignCountdown');
export default CampaignCountdownWithMetrics;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {ILogger} from '@webex/cc-store';

export interface CampaignCountdownProps {
/**
* Timeout duration in seconds.
* Use this OR timeoutTimestamp, not both.
*/
timeoutInSeconds?: number;
Comment thread
cmullenx marked this conversation as resolved.

/**
* Epoch timestamp (in milliseconds) when the countdown should expire.
* This is where `campaignPreviewOfferTimeout` from callProcessingDetails should be passed.
* Can be provided as a string or number - will be parsed automatically.
* Takes precedence over timeoutInSeconds if both are provided.
*/
timeoutTimestamp?: string | number;

/**
* Callback fired when the countdown reaches zero
*/
onTimeout?: () => void;

/**
* Logger instance for logging purposes
*/
logger?: ILogger;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {ILogger} from '@webex/cc-store';

/**
* Parses the timeoutTimestamp value (string or number) to a number.
* The backend sends campaignPreviewOfferTimeout as a string epoch timestamp in milliseconds.
*/
export const parseTimeoutTimestamp = (value: string | number | undefined, logger?: ILogger): number => {
try {
if (value === undefined) {
return 0;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = parseInt(value, 10);
if (!isNaN(parsed)) {
return parsed;
}
}
return 0;
} catch (error) {
logger?.error('CC-Widgets: CampaignCountdown: Error in parseTimeoutTimestamp', {
module: 'cc-components#campaign-countdown.utils.ts',
method: 'parseTimeoutTimestamp',
error: error.message,
});
return 0;
}
};

/**
* Calculates remaining seconds based on either a timestamp or a direct seconds value.
* If timeoutTimestamp is provided, it calculates the difference from now.
* Otherwise, it uses timeoutInSeconds directly.
*/
export const calculateRemainingSeconds = (
timeoutTimestamp?: string | number,
timeoutInSeconds?: number,
logger?: ILogger
): number => {
try {
// timeoutTimestamp takes precedence
if (timeoutTimestamp !== undefined) {
const parsedTimestamp = parseTimeoutTimestamp(timeoutTimestamp, logger);
if (parsedTimestamp > 0) {
const now = Date.now();
const diffMs = parsedTimestamp - now;
return diffMs > 0 ? Math.ceil(diffMs / 1000) : 0;
}
}
// Fall back to timeoutInSeconds
if (typeof timeoutInSeconds === 'number') {
return Math.max(0, timeoutInSeconds);
}
return 0;
} catch (error) {
logger?.error('CC-Widgets: CampaignCountdown: Error in calculateRemainingSeconds', {
module: 'cc-components#campaign-countdown.utils.ts',
method: 'calculateRemainingSeconds',
error: error.message,
});
return 0;
}
};

/**
* Formats seconds into MM:SS format for countdown display
*/
export const formatCountdown = (seconds: number, logger?: ILogger): string => {
try {
const safeSeconds = Math.max(0, seconds);
const minutes = Math.floor(safeSeconds / 60);
const remainingSeconds = safeSeconds % 60;

return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
} catch (error) {
logger?.error('CC-Widgets: CampaignCountdown: Error in formatCountdown', {
module: 'cc-components#campaign-countdown.utils.ts',
method: 'formatCountdown',
error: error.message,
});
return '00:00';
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ export const QUEUE = 'Queue:';
export const PHONE_NUMBER = 'Phone Number:';
export const CUSTOMER_NAME = 'Customer Name';
export const RONA = 'RONA:';
export const TIME_LEFT = 'Time left:';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you confirm that time left isn't used already in incomingtask component? and if so, share the const

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its hard coded there, const can be shared to incoming task in a follow up enhancement dedicated to replacing hard coded text with consts

3 changes: 3 additions & 0 deletions packages/contact-center/cc-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import IncomingTaskComponent from './components/task/IncomingTask/incoming-task'
import TaskListComponent from './components/task/TaskList/task-list';
import OutdialCallComponent from './components/task/OutdialCall/outdial-call';
import CampaignErrorDialogComponent from './components/task/CampaignErrorDialog/campaign-error-dialog';
import CampaignCountdownComponent from './components/task/CampaignCountdown/campaign-countdown';
import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript';

export {
Expand All @@ -17,10 +18,12 @@ export {
TaskListComponent,
OutdialCallComponent,
CampaignErrorDialogComponent,
CampaignCountdownComponent,
RealTimeTranscriptComponent,
};
export * from './components/StationLogin/constants';
export * from './components/StationLogin/station-login.types';
export * from './components/UserState/user-state.types';
export * from './components/task/task.types';
export * from './components/task/CampaignErrorDialog/campaign-error-dialog.types';
export * from './components/task/CampaignCountdown/campaign-countdown.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`CampaignCountdown Snapshots Rendering should match snapshot with 0 seconds timeout 1`] = `
<div>
<mdc-text
class="task-text"
data-testid="campaign-countdown"
>
Time left:

00:00
</mdc-text>
</div>
`;

exports[`CampaignCountdown Snapshots Rendering should match snapshot with 30 seconds timeout 1`] = `
<div>
<mdc-text
class="task-text"
data-testid="campaign-countdown"
>
Time left:

00:30
</mdc-text>
</div>
`;

exports[`CampaignCountdown Snapshots Rendering should match snapshot with 125 seconds timeout 1`] = `
<div>
<mdc-text
class="task-text"
data-testid="campaign-countdown"
>
Time left:

02:05
</mdc-text>
</div>
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import {render} from '@testing-library/react';
import '@testing-library/jest-dom';
import CampaignCountdownComponent from '../../../../src/components/task/CampaignCountdown/campaign-countdown';

describe('CampaignCountdown Snapshots', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Rendering', () => {
it('should match snapshot with 30 seconds timeout', () => {
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={30} />);
expect(container).toMatchSnapshot();
});

it('should match snapshot with 0 seconds timeout', () => {
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={0} />);
expect(container).toMatchSnapshot();
});

it('should match snapshot with 125 seconds timeout', () => {
const {container} = render(<CampaignCountdownComponent timeoutInSeconds={125} />);
expect(container).toMatchSnapshot();
});
});
});
Loading
Loading