{}
-const TestSpanNode = ({data, id, selected, ...props}: IProps) => {
+const TestSpanNode = ({data, id, selected}: IProps) => {
const {span, testSpecs, testOutputs} = useSpanData(id);
const {isLoading, onSelectAsCurrent, showSelectAsCurrent} = useSelectAsCurrent({
selected,
diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx
new file mode 100644
index 0000000000..17edffd5d8
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx
@@ -0,0 +1,66 @@
+import Span from 'models/Span.model';
+import Connector from './ConnectorV2';
+import {IPropsComponent} from '../SpanNodeFactoryV2';
+import {useTimeline} from '../Timeline.provider';
+import * as S from '../TimelineV2.styled';
+
+const BaseLeftPadding = 16; // TODO: Move to Timeline.constants
+
+function toPercent(value: number) {
+ return `${(value * 100).toFixed(1)}%`;
+}
+
+function getHintSide(viewStart: number, viewEnd: number) {
+ return viewStart > 1 - viewEnd ? 'left' : 'right';
+}
+
+interface IProps extends IPropsComponent {
+ header?: React.ReactNode;
+ span: Span;
+}
+
+const BaseSpanNode = ({header, index, node, span, style}: IProps) => {
+ const {collapsedSpans, getScale, matchedSpans, onSpanCollapse, onSpanClick, selectedSpan} = useTimeline();
+ const {start: viewStart, end: viewEnd} = getScale(span.startTime, span.endTime);
+ const hintSide = getHintSide(viewStart, viewEnd);
+ const isSelected = selectedSpan === node.data.id;
+ const isMatched = matchedSpans.includes(node.data.id);
+ const isCollapsed = collapsedSpans.includes(node.data.id);
+ const leftPadding = node.depth * BaseLeftPadding;
+
+ return (
+
+ onSpanClick(node.data.id)}
+ $isEven={index % 2 === 0}
+ $isMatched={isMatched}
+ $isSelected={isSelected}
+ >
+
+
+
+
+ {span.name}
+
+
+
+
+
+
+
+ {span.duration}
+
+
+
+
+ );
+};
+
+export default BaseSpanNode;
diff --git a/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx
new file mode 100644
index 0000000000..650db16451
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx
@@ -0,0 +1,47 @@
+import * as S from '../TimelineV2.styled';
+
+interface IProps {
+ hasParent: boolean;
+ id: string;
+ isCollapsed: boolean;
+ leftPadding: number;
+ onCollapse(id: string): void;
+ totalChildren: number;
+}
+
+const Connector = ({hasParent, id, isCollapsed, leftPadding, onCollapse, totalChildren}: IProps) => (
+
+ {hasParent && (
+ <>
+
+
+ >
+ )}
+
+ {totalChildren > 0 ? (
+ <>
+ {!isCollapsed && }
+
+
+ {totalChildren}
+
+ {
+ event.stopPropagation();
+ onCollapse(id);
+ }}
+ />
+ >
+ ) : (
+
+ )}
+
+);
+
+export default Connector;
diff --git a/web/src/components/Visualization/components/Timeline/Header.tsx b/web/src/components/Visualization/components/Timeline/Header.tsx
new file mode 100644
index 0000000000..d5ff7cdc41
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/Header.tsx
@@ -0,0 +1,22 @@
+import Ticks from './Ticks/Ticks';
+import * as S from './TimelineV2.styled';
+
+const NUM_TICKS = 5;
+
+interface IProps {
+ duration: number;
+}
+
+const Header = ({duration}: IProps) => (
+
+
+
+ Span
+
+
+
+
+
+);
+
+export default Header;
diff --git a/web/src/components/Visualization/components/Timeline/ListWrapper.tsx b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx
new file mode 100644
index 0000000000..cda954bfb7
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx
@@ -0,0 +1,33 @@
+import {FixedSizeList as List} from 'react-window';
+import Header from './Header';
+import SpanNodeFactory from './SpanNodeFactoryV2';
+import * as S from './TimelineV2.styled';
+import {useTimeline} from './Timeline.provider';
+
+const HEADER_HEIGHT = 242;
+
+interface IProps {
+ listRef: React.RefObject;
+}
+
+const ListWrapper = ({listRef}: IProps) => {
+ const {spans, viewEnd, viewStart} = useTimeline();
+
+ return (
+
+
+
+ {SpanNodeFactory}
+
+
+ );
+};
+
+export default ListWrapper;
diff --git a/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx
new file mode 100644
index 0000000000..9f3c92ca47
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx
@@ -0,0 +1,10 @@
+import Navigation from '../Navigation';
+import {useTimeline} from './Timeline.provider';
+
+const NavigationWrapper = () => {
+ const {matchedSpans, onSpanNavigation, selectedSpan} = useTimeline();
+
+ return ;
+};
+
+export default NavigationWrapper;
diff --git a/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx
new file mode 100644
index 0000000000..9e6aed970c
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx
@@ -0,0 +1,30 @@
+import {NodeTypesEnum} from 'constants/Visualization.constants';
+import {TNode} from 'types/Timeline.types';
+// import TestSpanNode from './TestSpanNode/TestSpanNode';
+import TraceSpanNode from './TraceSpanNode/TraceSpanNodeV2';
+
+export interface IPropsComponent {
+ index: number;
+ node: TNode;
+ style: React.CSSProperties;
+}
+
+const ComponentMap: Record React.ReactElement> = {
+ [NodeTypesEnum.TestSpan]: TraceSpanNode,
+ [NodeTypesEnum.TraceSpan]: TraceSpanNode,
+};
+
+interface IProps {
+ data: TNode[];
+ index: number;
+ style: React.CSSProperties;
+}
+
+const SpanNodeFactory = ({data, ...props}: IProps) => {
+ const node = data[props.index];
+ const Component = ComponentMap[node.type];
+
+ return ;
+};
+
+export default SpanNodeFactory;
diff --git a/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts
new file mode 100644
index 0000000000..0af2ed4f32
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts
@@ -0,0 +1,37 @@
+import {Typography} from 'antd';
+import styled, {css} from 'styled-components';
+
+export const Ticks = styled.div`
+ pointer-events: none;
+ position: relative;
+`;
+
+export const Tick = styled.div`
+ align-items: center;
+ background: ${({theme}) => theme.color.borderLight};
+ display: flex;
+ height: 100%;
+ position: absolute;
+ width: 1px;
+
+ :first-child,
+ :last-child {
+ width: 0;
+ }
+`;
+
+export const TickLabel = styled(Typography.Text)<{$isEndAnchor: boolean}>`
+ color: ${({theme}) => theme.color.text};
+ font-size: ${({theme}) => theme.size.sm};
+ font-weight: 400;
+ left: 0.25rem;
+ position: absolute;
+ white-space: nowrap;
+
+ ${({$isEndAnchor}) =>
+ $isEndAnchor &&
+ css`
+ left: initial;
+ right: 0.25rem;
+ `};
+`;
diff --git a/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx
new file mode 100644
index 0000000000..5387421b91
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import Date, {ONE_MILLISECOND} from 'utils/Date';
+import * as S from './Ticks.styled';
+
+function getLabels(numTicks: number, startTime: number, endTime: number) {
+ const viewingDuration = endTime - startTime;
+ const labels = [];
+
+ for (let i = 0; i < numTicks; i += 1) {
+ const durationAtTick = startTime + (i / (numTicks - 1)) * viewingDuration;
+ labels.push(Date.formatDuration(durationAtTick * ONE_MILLISECOND));
+ }
+
+ return labels;
+}
+
+interface IProps {
+ endTime?: number;
+ numTicks: number;
+ startTime?: number;
+}
+
+const Ticks = ({endTime = 0, numTicks, startTime = 0}: IProps) => {
+ const labels = getLabels(numTicks, startTime, endTime);
+
+ return (
+
+ {labels.map((label, index) => {
+ const portion = index / (numTicks - 1);
+ return (
+
+ = 1}>{label}
+
+ );
+ })}
+
+ );
+};
+
+export default Ticks;
diff --git a/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx
new file mode 100644
index 0000000000..64a3c630c2
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx
@@ -0,0 +1,120 @@
+import {NodeTypesEnum} from 'constants/Visualization.constants';
+import noop from 'lodash/noop';
+import without from 'lodash/without';
+import Span from 'models/Span.model';
+import TimelineModel from 'models/Timeline.model';
+import {createContext, useCallback, useContext, useMemo, useState} from 'react';
+import {FixedSizeList as List} from 'react-window';
+import {useAppDispatch, useAppSelector} from 'redux/hooks';
+import {selectSpan} from 'redux/slices/Trace.slice';
+import TraceSelectors from 'selectors/Trace.selectors';
+import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service';
+import TimelineService, {TScaleFunction} from 'services/Timeline.service';
+import {TNode} from 'types/Timeline.types';
+
+interface IContext {
+ collapsedSpans: string[];
+ getScale: TScaleFunction;
+ matchedSpans: string[];
+ onSpanClick(spanId: string): void;
+ onSpanCollapse(spanId: string): void;
+ onSpanNavigation(spanId: string): void;
+ selectedSpan: string;
+ spans: TNode[];
+ viewEnd: number;
+ viewStart: number;
+}
+
+export const Context = createContext({
+ collapsedSpans: [],
+ getScale: () => ({start: 0, end: 0}),
+ matchedSpans: [],
+ onSpanClick: noop,
+ onSpanCollapse: noop,
+ onSpanNavigation: noop,
+ selectedSpan: '',
+ spans: [],
+ viewEnd: 0,
+ viewStart: 0,
+});
+
+interface IProps {
+ children: React.ReactNode;
+ listRef: React.RefObject;
+ nodeType: NodeTypesEnum;
+ spans: Span[];
+}
+
+export const useTimeline = () => useContext(Context);
+
+const TimelineProvider = ({children, listRef, nodeType, spans}: IProps) => {
+ const dispatch = useAppDispatch();
+ const [collapsedSpans, setCollapsedSpans] = useState([]);
+ const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans);
+ const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan);
+
+ const nodes = useMemo(() => TimelineModel(spans, nodeType), [spans, nodeType]);
+ const filteredNodes = useMemo(() => TimelineService.getFilteredNodes(nodes, collapsedSpans), [collapsedSpans, nodes]);
+ const [min, max] = useMemo(() => TimelineService.getMinMax(nodes), [nodes]);
+ const getScale = useCallback(() => TimelineService.createScaleFunc({min, max}), [max, min]);
+
+ const onSpanClick = useCallback(
+ (spanId: string) => {
+ TraceAnalyticsService.onTimelineSpanClick(spanId);
+ dispatch(selectSpan({spanId}));
+ },
+ [dispatch]
+ );
+
+ const onSpanNavigation = useCallback(
+ (spanId: string) => {
+ dispatch(selectSpan({spanId}));
+ // TODO: Improve the method to search for the index
+ const index = filteredNodes.findIndex(node => node.data.id === spanId);
+ if (index !== -1) {
+ listRef?.current?.scrollToItem(index, 'start');
+ }
+ },
+ [dispatch, filteredNodes, listRef]
+ );
+
+ const onSpanCollapse = useCallback((spanId: string) => {
+ setCollapsedSpans(prevCollapsed => {
+ if (prevCollapsed.includes(spanId)) {
+ return without(prevCollapsed, spanId);
+ }
+ return [...prevCollapsed, spanId];
+ });
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ collapsedSpans,
+ getScale: getScale(),
+ matchedSpans,
+ onSpanClick,
+ onSpanCollapse,
+ onSpanNavigation,
+ selectedSpan,
+ spans: filteredNodes,
+ viewEnd: max,
+ viewStart: min,
+ }),
+ [
+ collapsedSpans,
+ filteredNodes,
+ getScale,
+ matchedSpans,
+ max,
+ min,
+ onSpanClick,
+ onSpanCollapse,
+ onSpanNavigation,
+ selectedSpan,
+ ]
+ );
+
+ return {children};
+};
+
+export default TimelineProvider;
diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts
new file mode 100644
index 0000000000..08d73185dd
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts
@@ -0,0 +1,150 @@
+import {Typography} from 'antd';
+import {SemanticGroupNames, SemanticGroupNamesToColor} from 'constants/SemanticGroupNames.constants';
+import styled, {css} from 'styled-components';
+
+export const Container = styled.div`
+ padding: 50px 24px 0 24px;
+`;
+
+export const Row = styled.div<{$isEven: boolean; $isMatched: boolean; $isSelected: boolean}>`
+ background-color: ${({theme, $isEven}) => ($isEven ? theme.color.background : theme.color.white)};
+ display: grid;
+ grid-template-columns: 300px 1fr;
+ grid-template-rows: 32px;
+ padding: 0px 16px;
+
+ :hover {
+ background-color: ${({theme}) => theme.color.backgroundInteractive};
+ }
+
+ ${({$isMatched}) =>
+ $isMatched &&
+ css`
+ background-color: ${({theme}) => theme.color.alertYellow};
+ `};
+
+ ${({$isSelected}) =>
+ $isSelected &&
+ css`
+ background: rgba(97, 23, 94, 0.1);
+
+ :hover {
+ background: rgba(97, 23, 94, 0.1);
+ }
+ `};
+`;
+
+export const Col = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 8px;
+`;
+
+export const ColDuration = styled.div`
+ overflow: hidden;
+ position: relative;
+`;
+
+export const Header = styled.div`
+ align-items: center;
+ display: flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+export const NameContainer = styled.div`
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+export const Separator = styled.div`
+ border-left: 1px solid rgb(222, 227, 236);
+ cursor: ew-resize;
+ height: 32px;
+ padding: 0px 3px;
+ width: 1px;
+`;
+
+export const Title = styled(Typography.Text)`
+ color: ${({theme}) => theme.color.text};
+ font-size: ${({theme}) => theme.size.sm};
+ font-weight: 400;
+`;
+
+export const Connector = styled.svg`
+ flex-shrink: 0;
+ overflow: hidden;
+ overflow-clip-margin: content-box;
+`;
+
+export const SpanBar = styled.div<{$type: SemanticGroupNames}>`
+ background-color: ${({$type}) => SemanticGroupNamesToColor[$type]};
+ border-radius: 3px;
+ height: 18px;
+ min-width: 2px;
+ position: absolute;
+ top: 7px;
+`;
+
+export const SpanBarLabel = styled.div<{$side: 'left' | 'right'}>`
+ color: ${({theme}) => theme.color.textSecondary};
+ font-size: ${({theme}) => theme.size.xs};
+ padding: 1px 4px 0 4px;
+ position: absolute;
+
+ ${({$side}) =>
+ $side === 'left'
+ ? css`
+ right: 100%;
+ `
+ : css`
+ left: 100%;
+ `};
+`;
+
+export const TextConnector = styled.text<{$isActive?: boolean}>`
+ fill: ${({theme, $isActive}) => ($isActive ? theme.color.white : theme.color.text)};
+ font-size: ${({theme}) => theme.size.xs};
+`;
+
+export const CircleDot = styled.circle`
+ fill: ${({theme}) => theme.color.textSecondary};
+ stroke-width: 2;
+ stroke: ${({theme}) => theme.color.white};
+`;
+
+export const LineBase = styled.line`
+ stroke: ${({theme}) => theme.color.textSecondary};
+`;
+
+export const RectBase = styled.rect<{$isActive?: boolean}>`
+ fill: ${({theme, $isActive}) => ($isActive ? theme.color.primary : theme.color.white)};
+ stroke: ${({theme}) => theme.color.textSecondary};
+`;
+
+export const RectBaseTransparent = styled(RectBase)`
+ cursor: pointer;
+ fill: transparent;
+`;
+
+export const HeaderRow = styled.div`
+ background-color: ${({theme}) => theme.color.white};
+ display: grid;
+ grid-template-columns: 300px 1fr;
+ grid-template-rows: 32px;
+ padding: 0px 16px;
+`;
+
+export const HeaderContent = styled.div`
+ align-items: center;
+ display: flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+`;
+
+export const HeaderTitle = styled(Typography.Title)`
+ && {
+ margin: 0;
+ }
+`;
diff --git a/web/src/components/Visualization/components/Timeline/TimelineV2.tsx b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx
new file mode 100644
index 0000000000..9797c82cd5
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/TimelineV2.tsx
@@ -0,0 +1,25 @@
+import {NodeTypesEnum} from 'constants/Visualization.constants';
+import Span from 'models/Span.model';
+import {useRef} from 'react';
+import {FixedSizeList as List} from 'react-window';
+import NavigationWrapper from './NavigationWrapper';
+import TimelineProvider from './Timeline.provider';
+import ListWrapper from './ListWrapper';
+
+export interface IProps {
+ nodeType: NodeTypesEnum;
+ spans: Span[];
+}
+
+const Timeline = ({nodeType, spans}: IProps) => {
+ const listRef = useRef(null);
+
+ return (
+
+
+
+
+ );
+};
+
+export default Timeline;
diff --git a/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx
new file mode 100644
index 0000000000..1f0228df1c
--- /dev/null
+++ b/web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx
@@ -0,0 +1,19 @@
+import useSpanData from 'hooks/useSpanData';
+// import Header from './Header';
+import BaseSpanNode from '../BaseSpanNode/BaseSpanNodeV2';
+import {IPropsComponent} from '../SpanNodeFactoryV2';
+
+const TraceSpanNode = (props: IPropsComponent) => {
+ const {node} = props;
+ const {span, analyzerErrors} = useSpanData(node.data.id);
+
+ return (
+ }
+ span={span}
+ />
+ );
+};
+
+export default TraceSpanNode;
diff --git a/web/src/services/Timeline.service.ts b/web/src/services/Timeline.service.ts
index 610f389dab..8b06096135 100644
--- a/web/src/services/Timeline.service.ts
+++ b/web/src/services/Timeline.service.ts
@@ -2,6 +2,8 @@ import {stratify} from '@visx/hierarchy';
import {NodeTypesEnum} from 'constants/Visualization.constants';
import {INodeDataSpan, TNode} from 'types/Timeline.types';
+export type TScaleFunction = (start: number, end: number) => {start: number; end: number};
+
function getHierarchyNodes(nodesData: INodeDataSpan[]) {
return stratify()
.id(d => d.id)
@@ -48,6 +50,22 @@ const TimelineService = () => ({
const endTimes = nodes.map(node => node.data.endTime);
return [Math.min(...startTimes), Math.max(...endTimes)];
},
+
+ createScaleFunc(viewRange: {min: number; max: number}): TScaleFunction {
+ const {min, max} = viewRange;
+ const viewWindow = max - min;
+
+ /**
+ * Scale function
+ * @param {number} start The start of the sub-range.
+ * @param {number} end The end of the sub-range.
+ * @return {Object} The resultant range.
+ */
+ return (start: number, end: number) => ({
+ start: (start - min) / viewWindow,
+ end: (end - min) / viewWindow,
+ });
+ },
});
export default TimelineService();
diff --git a/web/src/utils/Date.ts b/web/src/utils/Date.ts
index d534d4dbe3..19c73d2433 100644
--- a/web/src/utils/Date.ts
+++ b/web/src/utils/Date.ts
@@ -1,4 +1,21 @@
import {format, formatDistanceToNowStrict, isValid, parseISO} from 'date-fns';
+import dropWhile from 'lodash/dropWhile';
+import round from 'lodash/round';
+
+export const ONE_MILLISECOND = 1000 * 1;
+const ONE_SECOND = 1000 * ONE_MILLISECOND;
+const ONE_MINUTE = 60 * ONE_SECOND;
+const ONE_HOUR = 60 * ONE_MINUTE;
+const ONE_DAY = 24 * ONE_HOUR;
+
+const UNIT_STEPS: {unit: string; microseconds: number; ofPrevious: number}[] = [
+ {unit: 'd', microseconds: ONE_DAY, ofPrevious: 24},
+ {unit: 'h', microseconds: ONE_HOUR, ofPrevious: 60},
+ {unit: 'm', microseconds: ONE_MINUTE, ofPrevious: 60},
+ {unit: 's', microseconds: ONE_SECOND, ofPrevious: 1000},
+ {unit: 'ms', microseconds: ONE_MILLISECOND, ofPrevious: 1000},
+ {unit: 'μs', microseconds: 1, ofPrevious: 1000},
+];
const Date = {
format(date: string, dateFormat = "EEEE, yyyy/MM/dd 'at' HH:mm:ss") {
@@ -8,6 +25,7 @@ const Date = {
}
return format(isoDate, dateFormat);
},
+
getTimeAgo(date: string) {
const isoDate = parseISO(date);
if (!isValid(isoDate)) {
@@ -15,9 +33,35 @@ const Date = {
}
return formatDistanceToNowStrict(isoDate, {addSuffix: true});
},
+
isDefaultDate(date: string) {
return date === '0001-01-01T00:00:00Z';
},
+
+ /**
+ * Format duration for display.
+ *
+ * @param {number} duration - microseconds
+ * @return {string} formatted duration
+ */
+ formatDuration(duration: number): string {
+ // Drop all units that are too large except the last one
+ const [primaryUnit, secondaryUnit] = dropWhile(
+ UNIT_STEPS,
+ ({microseconds}, index) => index < UNIT_STEPS.length - 1 && microseconds > duration
+ );
+
+ if (primaryUnit.ofPrevious === 1000) {
+ // If the unit is decimal based, display as a decimal
+ return `${round(duration / primaryUnit.microseconds, 2)}${primaryUnit.unit}`;
+ }
+
+ const primaryValue = Math.floor(duration / primaryUnit.microseconds);
+ const primaryUnitString = `${primaryValue}${primaryUnit.unit}`;
+ const secondaryValue = Math.round((duration / secondaryUnit.microseconds) % primaryUnit.ofPrevious);
+ const secondaryUnitString = `${secondaryValue}${secondaryUnit.unit}`;
+ return secondaryValue === 0 ? primaryUnitString : `${primaryUnitString} ${secondaryUnitString}`;
+ },
};
export default Date;