+
{groupedItems.map((item, index) => (
))}
diff --git a/src/renderer/hooks/useElapsedTimer.ts b/src/renderer/hooks/useElapsedTimer.ts
index c62fede..5cfcb3a 100644
--- a/src/renderer/hooks/useElapsedTimer.ts
+++ b/src/renderer/hooks/useElapsedTimer.ts
@@ -1,39 +1,56 @@
import { useEffect, useState } from 'react';
-function formatElapsed(startMs: number): string {
- const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
+/** Format a millisecond duration as a human-readable elapsed string. */
+export function formatElapsedMs(durationMs: number): string {
+ const seconds = Math.max(0, Math.floor(durationMs / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(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.
- * 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 endMs = completedAt ? new Date(completedAt).getTime() : undefined;
- const [elapsed, setElapsed] = useState(() => {
- if (!startMs) return undefined;
- return formatElapsed(startMs);
- });
+ const computeElapsed = (): string | undefined => {
+ if (!startMs || Number.isNaN(startMs)) return undefined;
+ if (!active && endMs && !Number.isNaN(endMs)) {
+ return formatElapsedMs(endMs - startMs);
+ }
+ return formatElapsedMs(Date.now() - startMs);
+ };
+
+ const [elapsed, setElapsed] = useState(computeElapsed);
useEffect(() => {
- if (!startMs) {
+ if (!startMs || Number.isNaN(startMs)) {
setElapsed(undefined);
return;
}
- // Always sync to current value immediately
- setElapsed(formatElapsed(startMs));
+ // Sync immediately
+ setElapsed(computeElapsed());
if (!active) return;
- const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
+ const id = setInterval(() => setElapsed(formatElapsedMs(Date.now() - startMs)), 1000);
return () => clearInterval(id);
- }, [startMs, active]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [startMs, endMs, active]);
return elapsed;
}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index d338ef4..1f0a7c8 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -674,6 +674,23 @@ body {
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 ──────────────────────────────── */
@keyframes selection-checkbox-in {
diff --git a/tests/renderer/useElapsedTimer.test.ts b/tests/renderer/useElapsedTimer.test.ts
new file mode 100644
index 0000000..8ecf898
--- /dev/null
+++ b/tests/renderer/useElapsedTimer.test.ts
@@ -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');
+ });
+});