Skip to content

Commit 00c1683

Browse files
authored
History events duration badge (#907)
1 parent fde0645 commit 00c1683

File tree

8 files changed

+314
-2
lines changed

8 files changed

+314
-2
lines changed

src/utils/data-formatters/__tests__/format-duration.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,9 @@ describe('formatDuration', () => {
2626
const duration: Duration = { seconds: '61', nanos: 123456789 };
2727
expect(formatDuration(duration)).toBe('1m, 1s, 123.456789ms');
2828
});
29+
30+
it('should format duration with custom separator', () => {
31+
const duration: Duration = { seconds: '31556952', nanos: 0 };
32+
expect(formatDuration(duration, { separator: ' ' })).toBe('1y 5h 49m 12s');
33+
});
2934
});

src/utils/data-formatters/format-duration.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { type Duration } from '@/__generated__/proto-ts/google/protobuf/Duration';
22
import dayjs from '@/utils/datetime/dayjs';
33

4-
const formatDuration = (duration: Duration | null) => {
4+
const formatDuration = (
5+
duration: Duration | null,
6+
{ separator = ', ' }: { separator?: string } = {}
7+
) => {
58
const defaultReturn = '0s';
69
if (!duration) {
710
return defaultReturn;
@@ -30,7 +33,7 @@ const formatDuration = (duration: Duration | null) => {
3033
const result = units
3134
.filter((unit) => values[unit])
3235
.map((unit) => `${values[unit]}${unit}`)
33-
.join(', ');
36+
.join(separator);
3437

3538
return result || defaultReturn;
3639
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import React from 'react';
2+
3+
import dayjs from 'dayjs';
4+
5+
import { render, screen, act } from '@/test-utils/rtl';
6+
7+
import { WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';
8+
9+
import getFormattedEventsDuration from '../helpers/get-formatted-events-duration';
10+
import WorkflowHistoryEventsDurationBadge from '../workflow-history-events-duration-badge';
11+
import type { Props } from '../workflow-history-events-duration-badge.types';
12+
13+
jest.mock('../helpers/get-formatted-events-duration', () =>
14+
jest.fn((startTime, endTime) =>
15+
dayjs(endTime ?? undefined).diff(dayjs(startTime), 'seconds')
16+
)
17+
);
18+
19+
const mockStartTime = new Date('2024-01-01T10:00:00Z');
20+
const mockCloseTime = new Date('2024-01-01T10:01:00Z');
21+
const mockNow = new Date('2024-01-01T10:02:00Z');
22+
23+
describe('WorkflowHistoryEventsDurationBadge', () => {
24+
beforeEach(() => {
25+
jest.useFakeTimers();
26+
jest.setSystemTime(mockNow);
27+
});
28+
29+
afterEach(() => {
30+
jest.clearAllMocks();
31+
jest.useRealTimers();
32+
});
33+
34+
it('renders duration badge for completed event', () => {
35+
setup({
36+
closeTime: mockCloseTime,
37+
});
38+
39+
expect(screen.getByText('Duration: 60')).toBeInTheDocument();
40+
});
41+
42+
it('renders duration badge for ongoing event', () => {
43+
setup({
44+
closeTime: null,
45+
});
46+
expect(screen.getByText('Duration: 120')).toBeInTheDocument();
47+
});
48+
49+
it('does not render badge for single event', () => {
50+
setup({
51+
eventsCount: 1,
52+
hasMissingEvents: false,
53+
});
54+
55+
expect(screen.queryByText(/Duration:/)).not.toBeInTheDocument();
56+
});
57+
58+
it('renders badge for single event with missing events', () => {
59+
setup({
60+
eventsCount: 1,
61+
hasMissingEvents: true,
62+
});
63+
64+
expect(screen.getByText('Duration: 120')).toBeInTheDocument();
65+
});
66+
67+
it('does not render badge when workflow is archived without close time', () => {
68+
setup({
69+
closeTime: null,
70+
workflowIsArchived: true,
71+
});
72+
73+
expect(screen.queryByText(/Duration:/)).not.toBeInTheDocument();
74+
});
75+
76+
it('does not render badge when workflow has close status without close time', () => {
77+
setup({
78+
workflowCloseStatus:
79+
WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_COMPLETED,
80+
});
81+
82+
expect(screen.queryByText(/Duration:/)).not.toBeInTheDocument();
83+
});
84+
85+
it('updates duration for ongoing event every second', () => {
86+
setup({
87+
closeTime: null,
88+
});
89+
expect(screen.getByText('Duration: 120')).toBeInTheDocument();
90+
91+
// Check if new duration is displayed each second
92+
(getFormattedEventsDuration as jest.Mock).mockClear();
93+
act(() => {
94+
jest.advanceTimersByTime(1000);
95+
jest.setSystemTime(new Date(mockNow.getTime() + 1000));
96+
});
97+
expect(screen.getByText('Duration: 121')).toBeInTheDocument();
98+
act(() => {
99+
jest.advanceTimersByTime(1000);
100+
jest.setSystemTime(new Date(mockNow.getTime() + 2000));
101+
});
102+
expect(screen.getByText('Duration: 122')).toBeInTheDocument();
103+
104+
// check that getFormattedEventsDuration is called once for each update
105+
expect(getFormattedEventsDuration).toHaveBeenCalledTimes(2);
106+
});
107+
108+
it('cleans up interval when component unmounts', () => {
109+
const { unmount } = setup();
110+
111+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
112+
unmount();
113+
114+
expect(clearIntervalSpy).toHaveBeenCalled();
115+
});
116+
117+
it('uses workflow close time when close time is not provided', () => {
118+
setup({
119+
closeTime: null,
120+
workflowCloseTime: mockCloseTime,
121+
});
122+
123+
expect(getFormattedEventsDuration).toHaveBeenCalledWith(
124+
mockStartTime,
125+
mockCloseTime
126+
);
127+
expect(screen.getByText('Duration: 60')).toBeInTheDocument();
128+
});
129+
});
130+
131+
function setup({
132+
startTime = mockStartTime,
133+
closeTime,
134+
eventsCount = 2,
135+
hasMissingEvents = false,
136+
workflowIsArchived = false,
137+
workflowCloseStatus = WorkflowExecutionCloseStatus.WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID,
138+
workflowCloseTime = null,
139+
}: Partial<Props> = {}) {
140+
return render(
141+
<WorkflowHistoryEventsDurationBadge
142+
startTime={startTime}
143+
closeTime={closeTime}
144+
eventsCount={eventsCount}
145+
hasMissingEvents={hasMissingEvents}
146+
workflowIsArchived={workflowIsArchived}
147+
workflowCloseStatus={workflowCloseStatus}
148+
workflowCloseTime={workflowCloseTime}
149+
/>
150+
);
151+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import getFormattedEventsDuration from '../get-formatted-events-duration';
2+
3+
jest.mock('@/utils/data-formatters/format-duration', () => ({
4+
__esModule: true,
5+
default: jest.fn((duration) => `mocked: ${duration.seconds}s`),
6+
}));
7+
8+
describe('getFormattedEventsDuration', () => {
9+
it('should return 0s for identical start and end times', () => {
10+
const duration = getFormattedEventsDuration(
11+
'2021-01-01T00:00:00Z',
12+
'2021-01-01T00:00:00Z'
13+
);
14+
expect(duration).toEqual('mocked: 0s');
15+
});
16+
17+
it('should return correct duration for 1 minute', () => {
18+
const duration = getFormattedEventsDuration(
19+
'2021-01-01T00:00:00Z',
20+
'2021-01-01T00:01:00Z'
21+
);
22+
expect(duration).toEqual('mocked: 60s');
23+
});
24+
25+
it('should return correct duration for 1 hour, 2 minutes, 3 seconds', () => {
26+
const duration = getFormattedEventsDuration(
27+
'2021-01-01T01:02:03Z',
28+
'2021-01-01T02:04:06Z'
29+
);
30+
expect(duration).toEqual(`mocked: ${60 * 60 + 2 * 60 + 3}s`);
31+
});
32+
33+
it('should handle endTime as null (use current time)', () => {
34+
const start = new Date(Date.now() - 60000).toISOString(); // 1 minute ago
35+
const duration = getFormattedEventsDuration(start, null);
36+
expect(duration).toEqual('mocked: 60s');
37+
});
38+
39+
it('should handle negative durations (start after end)', () => {
40+
const duration = getFormattedEventsDuration(
41+
'2021-01-01T01:00:00Z',
42+
'2021-01-01T00:00:00Z'
43+
);
44+
expect(duration).toEqual('mocked: -3600s');
45+
});
46+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import formatDuration from '@/utils/data-formatters/format-duration';
2+
import dayjs from '@/utils/datetime/dayjs';
3+
4+
export default function getFormattedEventsDuration(
5+
startTime: Date | string | number,
6+
endTime: Date | string | number | null | undefined
7+
) {
8+
const end = endTime ? dayjs(endTime) : dayjs();
9+
const start = dayjs(startTime);
10+
const diff = end.diff(start);
11+
const durationObj = dayjs.duration(diff);
12+
return formatDuration(
13+
{
14+
seconds: durationObj.asSeconds().toString(),
15+
nanos: 0,
16+
},
17+
{ separator: ' ' }
18+
);
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { type BadgeOverrides } from 'baseui/badge/types';
2+
import { type Theme } from 'baseui/theme';
3+
4+
import themeLight from '@/config/theme/theme-light.config';
5+
6+
export const overrides = {
7+
Badge: {
8+
Badge: {
9+
style: ({
10+
$theme,
11+
$hierarchy,
12+
}: {
13+
$theme: Theme;
14+
$hierarchy: string;
15+
}) => ({
16+
...$theme.typography.LabelXSmall,
17+
...($hierarchy === 'secondary'
18+
? {
19+
color: $theme.colors.contentSecondary,
20+
}
21+
: null),
22+
}),
23+
},
24+
} satisfies BadgeOverrides,
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useEffect, useState } from 'react';
2+
3+
import { Badge } from 'baseui/badge';
4+
5+
import getFormattedEventsDuration from './helpers/get-formatted-events-duration';
6+
import { overrides } from './workflow-history-events-duration-badge.styles';
7+
import { type Props } from './workflow-history-events-duration-badge.types';
8+
export default function WorkflowHistoryEventsDurationBadge({
9+
startTime,
10+
closeTime,
11+
workflowIsArchived,
12+
workflowCloseStatus,
13+
eventsCount,
14+
hasMissingEvents,
15+
workflowCloseTime,
16+
}: Props) {
17+
const endTime = closeTime || workflowCloseTime;
18+
const workflowEnded =
19+
workflowIsArchived ||
20+
workflowCloseStatus !== 'WORKFLOW_EXECUTION_CLOSE_STATUS_INVALID';
21+
const singleEvent = eventsCount === 1 && !hasMissingEvents;
22+
const noDuration = singleEvent || (workflowEnded && !endTime);
23+
24+
const [duration, setDuration] = useState<string>(() =>
25+
getFormattedEventsDuration(startTime, endTime)
26+
);
27+
28+
useEffect(() => {
29+
setDuration(getFormattedEventsDuration(startTime, endTime));
30+
if (!endTime && !noDuration) {
31+
const interval = setInterval(() => {
32+
setDuration(getFormattedEventsDuration(startTime, endTime));
33+
}, 1000);
34+
35+
return () => clearInterval(interval);
36+
}
37+
}, [startTime, endTime, noDuration]);
38+
39+
if (noDuration) {
40+
return null;
41+
}
42+
43+
return (
44+
<Badge
45+
overrides={overrides.Badge}
46+
content={`Duration: ${duration}`}
47+
shape="rectangle"
48+
color={endTime ? 'primary' : 'accent'}
49+
hierarchy={endTime ? 'secondary' : 'primary'}
50+
/>
51+
);
52+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { type WorkflowExecutionCloseStatus } from '@/__generated__/proto-ts/uber/cadence/api/v1/WorkflowExecutionCloseStatus';
2+
3+
export type Props = {
4+
startTime: Date | string | number;
5+
closeTime: Date | string | number | null | undefined;
6+
workflowIsArchived: boolean;
7+
workflowCloseStatus: WorkflowExecutionCloseStatus | null | undefined;
8+
eventsCount: number;
9+
hasMissingEvents: boolean;
10+
workflowCloseTime: Date | string | number | null | undefined;
11+
};

0 commit comments

Comments
 (0)