mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 21:33:58 +02:00
fix: accurate elapsed timing and UX polish for activity panel
- Fix useElapsedTimer to use completedAt instead of Date.now() for finished runs, preventing wildly inaccurate durations (e.g. 9802m) when reopening old sessions - Extract formatElapsedMs as a shared pure formatter; reuse in SubagentActivityCard - Standardize icon column alignment across all activity row types using consistent w-4 + h-[18px] icon containers - Redesign collapsed header: status dot with pulse animation, bolder status label, tabular-nums on elapsed time, cleaner hierarchy - Polish expanded stream: timeline spine connecting activity items, more prominent intent dividers with pill-style background, tightened vertical rhythm - Add unit tests for formatElapsedMs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5,12 +5,12 @@ import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
|||||||
|
|
||||||
const COMPLETION_GRACE_MS = 3000;
|
const COMPLETION_GRACE_MS = 3000;
|
||||||
|
|
||||||
function formatElapsed(startedAt: string): string {
|
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
|
||||||
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
function formatElapsed(startedAt: string, endedAt?: string): string {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const endMs = endedAt ? new Date(endedAt).getTime() : Date.now();
|
||||||
const remainder = seconds % 60;
|
const durationMs = endMs - new Date(startedAt).getTime();
|
||||||
return `${minutes}m ${remainder}s`;
|
return formatElapsedMs(durationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
|
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
|
||||||
|
|||||||
@@ -153,12 +153,12 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
|
|||||||
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
|
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turn-activity-row flex gap-2 py-1">
|
<div className="turn-activity-row flex items-start gap-2 py-1">
|
||||||
<div className="mt-0.5 flex shrink-0 items-start">
|
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
|
||||||
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
|
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
|
<span className={`text-[12px] leading-[18px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -231,14 +231,14 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
|
|||||||
<div className="turn-activity-row py-0.5">
|
<div className="turn-activity-row py-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-start gap-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30 rounded px-1 -mx-1"
|
className="flex w-full items-start gap-2 rounded px-1 -mx-1 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30"
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 flex shrink-0 items-start">
|
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
|
||||||
<ToolCategoryIcon toolName={toolName} />
|
<ToolCategoryIcon toolName={toolName} />
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-0 flex-1 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
<span className="min-w-0 flex-1 text-[12px] leading-[18px] font-medium text-[var(--color-text-secondary)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -250,7 +250,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
|
|||||||
|
|
||||||
{/* Collapsed preview: show snippets inline */}
|
{/* Collapsed preview: show snippets inline */}
|
||||||
{!expanded && snippets.length > 0 && (
|
{!expanded && snippets.length > 0 && (
|
||||||
<div className="ml-5 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
|
<div className="ml-6 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
|
||||||
{snippets.slice(0, 6).map((s, i) => (
|
{snippets.slice(0, 6).map((s, i) => (
|
||||||
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
|
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
{s}
|
{s}
|
||||||
@@ -266,7 +266,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
|
|||||||
|
|
||||||
{/* Expanded: full per-event rows */}
|
{/* Expanded: full per-event rows */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="ml-5 border-l border-[var(--color-border)]/30 pl-2">
|
<div className="ml-6 border-l border-[var(--color-border)]/30 pl-2">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div key={event.id} className="py-0.5">
|
<div key={event.id} className="py-0.5">
|
||||||
<span className="text-[11px] text-[var(--color-text-secondary)]">
|
<span className="text-[11px] text-[var(--color-text-secondary)]">
|
||||||
@@ -285,7 +285,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
|
|||||||
|
|
||||||
{/* Aggregate file changes when collapsed */}
|
{/* Aggregate file changes when collapsed */}
|
||||||
{!expanded && hasFileChanges && (
|
{!expanded && hasFileChanges && (
|
||||||
<div className="ml-5 mt-0.5">
|
<div className="ml-6 mt-0.5">
|
||||||
{events
|
{events
|
||||||
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
|
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
|
||||||
.flatMap((e) => e.fileChanges!)
|
.flatMap((e) => e.fileChanges!)
|
||||||
@@ -304,9 +304,9 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
|
|||||||
|
|
||||||
function IntentDividerRow({ text }: { text: string }) {
|
function IntentDividerRow({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="turn-activity-row flex items-center gap-2 py-1.5" role="separator">
|
<div className="turn-activity-row flex items-center gap-2 py-2" role="separator">
|
||||||
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
||||||
<span className="shrink-0 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)]">
|
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)]/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)]">
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
|
||||||
@@ -322,8 +322,8 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
|
|||||||
if (message.pending && !message.content) return null;
|
if (message.pending && !message.content) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turn-activity-row flex gap-2 py-1">
|
<div className="turn-activity-row flex items-start gap-2 py-1">
|
||||||
<div className="mt-0.5 flex shrink-0 items-start">
|
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
|
||||||
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -349,8 +349,8 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="turn-activity-row py-0.5">
|
<div className="turn-activity-row py-0.5">
|
||||||
<div className="flex gap-2 py-1">
|
<div className="flex items-start gap-2 py-1">
|
||||||
<div className="mt-0.5 flex shrink-0 items-start">
|
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
|
||||||
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
<Brain className="size-3 text-[var(--color-accent-purple)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -370,7 +370,7 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="ml-5 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
|
<div className="ml-6 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
|
||||||
{visibleMessages.slice(0, -1).map((msg) => (
|
{visibleMessages.slice(0, -1).map((msg) => (
|
||||||
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
|
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
|
||||||
"{truncatePreview(msg.content, 140)}"
|
"{truncatePreview(msg.content, 140)}"
|
||||||
@@ -461,6 +461,7 @@ export function TurnActivityPanel({
|
|||||||
const elapsed = useElapsedTimer(
|
const elapsed = useElapsedTimer(
|
||||||
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
|
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
|
||||||
isActive,
|
isActive,
|
||||||
|
run?.completedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
@@ -516,17 +517,25 @@ export function TurnActivityPanel({
|
|||||||
: isCancelled
|
: isCancelled
|
||||||
? 'text-[var(--color-text-muted)]'
|
? 'text-[var(--color-text-muted)]'
|
||||||
: isActive
|
: isActive
|
||||||
? 'text-[var(--color-text-secondary)]'
|
? 'text-[var(--color-text-primary)]'
|
||||||
: 'text-[var(--color-text-secondary)]';
|
: 'text-[var(--color-text-secondary)]';
|
||||||
|
|
||||||
|
const statusDotClass = isFailed
|
||||||
|
? 'bg-[var(--color-status-error)]'
|
||||||
|
: isCancelled
|
||||||
|
? 'bg-[var(--color-text-muted)]'
|
||||||
|
: isActive
|
||||||
|
? 'bg-[var(--color-accent)]'
|
||||||
|
: 'bg-[var(--color-status-success)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
|
className={`turn-activity-enter overflow-hidden rounded-lg border transition-colors duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-[var(--color-accent)]/30'
|
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/[0.03]'
|
||||||
: isFailed
|
: isFailed
|
||||||
? 'border-[var(--color-status-error)]/20'
|
? 'border-[var(--color-status-error)]/20 bg-[var(--color-surface-1)]/60'
|
||||||
: 'border-[var(--color-border)]/50'
|
: 'border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Summary header */}
|
{/* Summary header */}
|
||||||
@@ -535,20 +544,23 @@ export function TurnActivityPanel({
|
|||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
|
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]/50"
|
||||||
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
|
{/* Status dot */}
|
||||||
|
<span className={`size-2 shrink-0 rounded-full ${statusDotClass} ${isActive ? 'animate-pulse' : ''}`} />
|
||||||
|
|
||||||
{isActive ? (
|
{/* Status label + elapsed */}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<span className={statusColorClass}>{summaryLabel}</span>
|
<span className={`text-[12px] font-semibold ${statusColorClass}`}>
|
||||||
<ActivityPulse />
|
{summaryLabel}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
{isActive && <ActivityPulse />}
|
||||||
<span className={statusColorClass}>{summaryLabel}</span>
|
{elapsed && (
|
||||||
)}
|
<span className="tabular-nums text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{isActive ? elapsed : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Intent / generated summary */}
|
{/* Intent / generated summary */}
|
||||||
{headerDetail && (
|
{headerDetail && (
|
||||||
@@ -560,7 +572,7 @@ export function TurnActivityPanel({
|
|||||||
|
|
||||||
{/* Inline counters */}
|
{/* Inline counters */}
|
||||||
{summaryParts.length > 0 && (
|
{summaryParts.length > 0 && (
|
||||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
<span className="shrink-0 tabular-nums text-[10px] text-[var(--color-text-muted)]">
|
||||||
{'· '}
|
{'· '}
|
||||||
{summaryParts.join(' · ')}
|
{summaryParts.join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
@@ -576,7 +588,7 @@ export function TurnActivityPanel({
|
|||||||
{/* Expanded activity stream — grouped */}
|
{/* Expanded activity stream — grouped */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
|
||||||
<div className="space-y-0.5">
|
<div className="activity-timeline-spine relative space-y-px">
|
||||||
{groupedItems.map((item, index) => (
|
{groupedItems.map((item, index) => (
|
||||||
<GroupedItemRow key={index} item={item} />
|
<GroupedItemRow key={index} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,39 +1,56 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
function formatElapsed(startMs: number): string {
|
/** Format a millisecond duration as a human-readable elapsed string. */
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
export function formatElapsedMs(durationMs: number): string {
|
||||||
|
const seconds = Math.max(0, Math.floor(durationMs / 1000));
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const remainder = seconds % 60;
|
const remainder = seconds % 60;
|
||||||
return `${minutes}m ${remainder}s`;
|
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a live-ticking elapsed-time string while `active` is true.
|
* Returns a live-ticking elapsed-time string while `active` is true.
|
||||||
* Freezes the display when `active` becomes false.
|
*
|
||||||
|
* When the timer is no longer active and `completedAt` is provided, the
|
||||||
|
* duration is computed as `completedAt − startedAt` — giving an accurate
|
||||||
|
* frozen value even when the session is reopened much later. Without
|
||||||
|
* `completedAt` the hook falls back to `Date.now() − startedAt` at the
|
||||||
|
* moment `active` becomes false.
|
||||||
*/
|
*/
|
||||||
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
|
export function useElapsedTimer(
|
||||||
|
startedAt: string | undefined,
|
||||||
|
active: boolean,
|
||||||
|
completedAt?: string,
|
||||||
|
): string | undefined {
|
||||||
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
|
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
|
||||||
|
const endMs = completedAt ? new Date(completedAt).getTime() : undefined;
|
||||||
|
|
||||||
const [elapsed, setElapsed] = useState<string | undefined>(() => {
|
const computeElapsed = (): string | undefined => {
|
||||||
if (!startMs) return undefined;
|
if (!startMs || Number.isNaN(startMs)) return undefined;
|
||||||
return formatElapsed(startMs);
|
if (!active && endMs && !Number.isNaN(endMs)) {
|
||||||
});
|
return formatElapsedMs(endMs - startMs);
|
||||||
|
}
|
||||||
|
return formatElapsedMs(Date.now() - startMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [elapsed, setElapsed] = useState<string | undefined>(computeElapsed);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!startMs) {
|
if (!startMs || Number.isNaN(startMs)) {
|
||||||
setElapsed(undefined);
|
setElapsed(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always sync to current value immediately
|
// Sync immediately
|
||||||
setElapsed(formatElapsed(startMs));
|
setElapsed(computeElapsed());
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
|
const id = setInterval(() => setElapsed(formatElapsedMs(Date.now() - startMs)), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [startMs, active]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [startMs, endMs, active]);
|
||||||
|
|
||||||
return elapsed;
|
return elapsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,6 +674,23 @@ body {
|
|||||||
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Timeline spine connecting activity items */
|
||||||
|
.activity-timeline-spine > .turn-activity-row:not([role="separator"]) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-timeline-spine > .turn-activity-row:not([role="separator"]):not(:last-child)::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 7px; /* center of the 16px (w-4) icon column */
|
||||||
|
top: 22px; /* below the icon center */
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
opacity: 0.25;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Batch selection animations ──────────────────────────────── */
|
/* ── Batch selection animations ──────────────────────────────── */
|
||||||
|
|
||||||
@keyframes selection-checkbox-in {
|
@keyframes selection-checkbox-in {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
|
||||||
|
|
||||||
|
describe('formatElapsedMs', () => {
|
||||||
|
test('formats sub-second durations as 0s', () => {
|
||||||
|
expect(formatElapsedMs(0)).toBe('0s');
|
||||||
|
expect(formatElapsedMs(500)).toBe('0s');
|
||||||
|
expect(formatElapsedMs(999)).toBe('0s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats seconds under a minute', () => {
|
||||||
|
expect(formatElapsedMs(1000)).toBe('1s');
|
||||||
|
expect(formatElapsedMs(5_000)).toBe('5s');
|
||||||
|
expect(formatElapsedMs(59_000)).toBe('59s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats minutes with remaining seconds', () => {
|
||||||
|
expect(formatElapsedMs(60_000)).toBe('1m');
|
||||||
|
expect(formatElapsedMs(90_000)).toBe('1m 30s');
|
||||||
|
expect(formatElapsedMs(125_000)).toBe('2m 5s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats exact minutes without trailing seconds', () => {
|
||||||
|
expect(formatElapsedMs(120_000)).toBe('2m');
|
||||||
|
expect(formatElapsedMs(300_000)).toBe('5m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps negative durations to 0s', () => {
|
||||||
|
expect(formatElapsedMs(-5000)).toBe('0s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats large durations correctly', () => {
|
||||||
|
// 2 hours, 30 minutes, 15 seconds = 9015s = 150m 15s
|
||||||
|
expect(formatElapsedMs(9_015_000)).toBe('150m 15s');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user