From 562567fcd62ef0118b19cf2d69e66f94c22f9a60 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:59:24 -0500 Subject: [PATCH] [codex] Improve work timeline activity story (#9222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The work timeline helps operators understand when agent and user activity actually happened across a project. > - The timeline view needs clearer interaction context so activity is easier to inspect and reason about. > - The existing story coverage did not fully exercise the denser activity states needed to review this UI safely. > - This pull request expands the work timeline data shape, service behavior, UI rendering, tests, and Storybook story so the activity timeline is easier to verify. > - The benefit is a more inspectable timeline for project activity, backed by targeted server and UI coverage. ## Linked Issues or Issue Description No public GitHub issue found, so this PR describes the feature inline following the feature request template. **Subsystem affected** Cross-cutting: `server/`, `packages/shared`, and `ui/`. **Problem or motivation** Project operators need a clearer timeline view that shows when work activity happened, how much agent time is represented inside the selected window, and enough realistic activity states for safe visual review. Sparse mock data and unbounded summary calculations make it harder to trust the timeline when inspecting historical or capped windows. **Proposed solution** Enrich the work timeline activity data returned by the service, render clearer top-level timeline summary stats, clamp duration calculations to the returned window, prorate token totals for partially visible spans, and add Storybook/test coverage with realistic timeline activity data. **Alternatives considered** Keeping the existing sparse timeline story was considered, but it would leave dense activity layouts and selected-window summary behavior under-reviewed. Counting full span usage for partially visible spans was also considered, but it makes historical windows report activity outside the displayed range. **Roadmap alignment** Searched `ROADMAP.md` for timeline/activity references and found no conflicting planned core work. **Additional context** This PR does not include migrations and does not commit generated design screenshots or images. ## What Changed - Extended shared work timeline activity types and server timeline service behavior. - Updated the timeline page and work timeline chart for richer activity rendering. - Clamped timeline runtime summary calculations to the returned window and prorated summary token usage for clipped spans. - Added and updated targeted server/UI tests for timeline activity behavior. - Added Storybook timeline mock coverage and Storybook preview setup needed by the story. ## Verification - `git rebase origin/master` completed cleanly after fetching `paperclipai/paperclip:master`. - `git diff --check origin/master...HEAD` - `pnpm exec vitest run server/src/__tests__/work-timeline-service.test.ts ui/src/components/timeline/WorkTimelineChart.test.tsx ui/src/pages/Timeline.test.tsx` — latest run: 3 files passed, 28 tests passed. - Greptile review completed at 5/5 with no unresolved Greptile threads after fixes. - GitHub checks completed green on the latest head SHA; Storybook visual regression was skipped by the workflow. - `pnpm check:token-gates` currently fails locally on existing `origin/master` violations in `ui/src/components/ActivityCharts.tsx` and `ui/src/components/IssueRecoveryActionCard.tsx`; this PR does not modify those files. ## Risks Low to moderate risk. The change affects the work timeline service response shape and timeline UI rendering, so regressions would likely show up as missing/incorrect timeline activity display. Targeted service and UI tests cover the changed behavior. No migrations are included. ## Model Used OpenAI Codex running GPT-5 as a tool-enabled coding agent with local shell and GitHub CLI access. Exact runtime model ID/context-window size was not exposed by the environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/types/work-timeline.ts | 6 + .../__tests__/work-timeline-service.test.ts | 9 +- server/src/services/work-timeline.ts | 38 +++ .../timeline/WorkTimelineChart.test.tsx | 18 +- .../components/timeline/WorkTimelineChart.tsx | 88 ++++--- ui/src/pages/Timeline.test.tsx | 158 +++++++++++++ ui/src/pages/Timeline.tsx | 222 ++++++++++++++---- ui/storybook/.storybook/main.ts | 2 +- ui/storybook/.storybook/preview.tsx | 48 ++++ .../stories/work-timeline.stories.tsx | 60 ++++- 10 files changed, 559 insertions(+), 90 deletions(-) diff --git a/packages/shared/src/types/work-timeline.ts b/packages/shared/src/types/work-timeline.ts index 01b3838277..7973100ddc 100644 --- a/packages/shared/src/types/work-timeline.ts +++ b/packages/shared/src/types/work-timeline.ts @@ -33,6 +33,12 @@ export interface WorkTimelineSpan { retryOfRunId?: string | null; continuationAttempt?: number; invocationSource?: string | null; + usage?: { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + totalTokens: number; + } | null; } export interface WorkTimelineEvent { diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 8825b805e1..5fd9330809 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -175,6 +175,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { invocationSource: "issue_assigned", startedAt: new Date("2026-03-01T12:00:00Z"), finishedAt: null, + usageJson: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50 }, contextSnapshot: { issueId: childIssueId }, }, { @@ -237,7 +238,13 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"])); expect(result.spans).toEqual(expect.arrayContaining([ - expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }), + expect.objectContaining({ + runId: contextRunId, + issueId: childIssueId, + end: null, + status: "running", + usage: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50, totalTokens: 200 }, + }), expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }), ])); expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([ diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 56756b6a57..46d36976da 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -73,6 +73,8 @@ type IssueRow = { createdAt: Date; }; +type RunUsage = NonNullable; + const DEFAULT_LIMIT = 200; const MAX_LIMIT = 500; const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000; @@ -119,6 +121,39 @@ function readString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function readNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function readUsageToken(source: Record, ...keys: string[]) { + for (const key of keys) { + const value = readNumber(source[key]); + if (value != null) return Math.max(0, Math.floor(value)); + } + return 0; +} + +function normalizeRunUsage(usageJson: unknown): RunUsage | null { + if (!usageJson || typeof usageJson !== "object" || Array.isArray(usageJson)) return null; + const source = usageJson as Record; + const inputTokens = readUsageToken(source, "inputTokens", "input_tokens", "rawInputTokens", "raw_input_tokens"); + const cachedInputTokens = readUsageToken( + source, + "cachedInputTokens", + "cached_input_tokens", + "cacheReadInputTokens", + "cache_read_input_tokens", + ); + const outputTokens = readUsageToken(source, "outputTokens", "output_tokens", "rawOutputTokens", "raw_output_tokens"); + const totalTokens = inputTokens + cachedInputTokens + outputTokens; + return totalTokens > 0 ? { inputTokens, cachedInputTokens, outputTokens, totalTokens } : null; +} + function maybeUuidList(ids: Iterable) { return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0))); } @@ -508,6 +543,7 @@ export function workTimelineService(db: Db) { retryOfRunId: heartbeatRuns.retryOfRunId, continuationAttempt: heartbeatRuns.continuationAttempt, invocationSource: heartbeatRuns.invocationSource, + usageJson: heartbeatRuns.usageJson, }) .from(heartbeatRuns) .where( @@ -529,6 +565,7 @@ export function workTimelineService(db: Db) { retryOfRunId: heartbeatRuns.retryOfRunId, continuationAttempt: heartbeatRuns.continuationAttempt, invocationSource: heartbeatRuns.invocationSource, + usageJson: heartbeatRuns.usageJson, }) .from(activityLog) .innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id)) @@ -639,6 +676,7 @@ export function workTimelineService(db: Db) { retryOfRunId: row.retryOfRunId ?? null, continuationAttempt: row.continuationAttempt, invocationSource: row.invocationSource ?? null, + usage: normalizeRunUsage(row.usageJson), }); } diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx index 627f399f42..11178c4901 100644 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -139,6 +139,8 @@ describe("WorkTimelineChart", () => { expect(gutter?.getAttribute("width")).toBe("176"); expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width")); expect(gutter?.textContent).toContain("CodexCoder"); + expect(gutter?.textContent).not.toContain("agent"); + expect(gutter?.textContent).not.toContain("×"); flushSync(() => { scroller!.scrollLeft = 10_000; @@ -323,7 +325,7 @@ describe("WorkTimelineChart", () => { const onZoomScaleChange = vi.fn(); renderChart(timelineSample(), { onZoomScaleChange }); - const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; const minimap = rightHandle.ownerSVGElement!; vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({ x: 0, @@ -346,6 +348,18 @@ describe("WorkTimelineChart", () => { expect(onZoomScaleChange).toHaveBeenCalled(); }); + it("shows grab-handle affordances on minimap selection edges", () => { + renderChart(timelineSample(), { onZoomScaleChange: vi.fn() }); + + const leftHandle = container.querySelector("[data-testid='timeline-minimap-left-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + + expect(leftHandle.getAttribute("class")).toContain("cursor-grab"); + expect(rightHandle.getAttribute("class")).toContain("cursor-grab"); + expect(leftHandle.querySelectorAll("line")).toHaveLength(3); + expect(leftHandle.textContent).toContain("Drag left edge"); + }); + it("cleans up chart drag listeners when unmounted mid-drag", () => { const add = vi.spyOn(document, "addEventListener"); const remove = vi.spyOn(document, "removeEventListener"); @@ -383,7 +397,7 @@ describe("WorkTimelineChart", () => { const remove = vi.spyOn(document, "removeEventListener"); renderChart(timelineSample(), { onZoomScaleChange: vi.fn() }); - const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; + const rightHandle = container.querySelector("[data-testid='timeline-minimap-right-handle']")!; const minimap = rightHandle.ownerSVGElement!; vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({ x: 0, diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index cd1f6f2403..3f223797de 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -533,12 +533,9 @@ export function WorkTimelineChart({ return ( - + {truncate(row.actor.name, 18)} - - {row.actor.type} - {Array.from({ length: row.laneCount }).map((_, ln) => { const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2; @@ -682,23 +679,9 @@ function ActorGutter({ rows, height }: { rows: ReturnType[ opacity={i % 2 ? 0.35 : 1} /> - + {truncate(row.actor.name, 16)} - - {row.actor.type} - - {/* "Signal" rail: run count + active time, right-aligned in the gutter. */} - - {row.runCount}× · {formatDuration(0, row.activeMs)} - ); })} @@ -822,6 +805,7 @@ function MiniMap({ const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W)); const brushX = mx(visibleStartMs); const brushW = Math.max(24, mx(visibleEndMs) - brushX); + const handleW = 14; const clearDocumentDrag = () => { documentDragCleanupRef.current?.(); @@ -886,7 +870,7 @@ function MiniMap({ width={W} height={H} viewBox={`0 0 ${W} ${H}`} - className="block cursor-ew-resize" + className="block cursor-grab active:cursor-grabbing" onMouseDown={(e) => { const el = e.currentTarget; seek(e.clientX, el); @@ -924,27 +908,67 @@ function MiniMap({ strokeWidth={1.5} onMouseDown={(e) => startRangeDrag("move", e)} /> - startRangeDrag("left", e)} /> - startRangeDrag("right", e)} /> ); } + +function MiniMapHandle({ + x, + y, + width, + height, + testId, + label, + onMouseDown, +}: { + x: number; + y: number; + width: number; + height: number; + testId: string; + label: string; + onMouseDown: (event: React.MouseEvent) => void; +}) { + const left = x - width / 2; + const gripTop = y + height / 2 - 7; + return ( + + {label} + + + + + + ); +} diff --git a/ui/src/pages/Timeline.test.tsx b/ui/src/pages/Timeline.test.tsx index cfe8943338..f60972b076 100644 --- a/ui/src/pages/Timeline.test.tsx +++ b/ui/src/pages/Timeline.test.tsx @@ -24,6 +24,10 @@ vi.mock("@/api/workTimeline", () => ({ workTimelineApi: mockWorkTimelineApi, })); +vi.mock("@/lib/router", () => ({ + useLocation: () => ({ pathname: "/PAP/timeline" }), +})); + vi.mock("@/components/RequestCollapsedSidebar", () => ({ RequestCollapsedSidebar: () =>
, })); @@ -46,6 +50,65 @@ const emptyTimeline: WorkTimelineResult = { }, }; +const populatedTimeline: WorkTimelineResult = { + actors: [ + { id: "agent:codex", type: "agent", name: "CodexCoder", avatar: "code" }, + { id: "agent:qa", type: "agent", name: "QA", avatar: "shield" }, + { id: "user:board", type: "user", name: "Board Operator", avatar: "/avatar.png" }, + ], + spans: [ + { + actorId: "agent:codex", + laneHint: "assignment", + runId: "run-1", + issueId: "issue-1", + issueIdentifier: "PAP-1", + issueTitle: "Implement timeline stats", + start: "2026-07-02T10:00:00.000Z", + end: "2026-07-02T10:30:00.000Z", + status: "succeeded", + retryOfRunId: null, + usage: { + inputTokens: 1_000, + cachedInputTokens: 0, + outputTokens: 500, + totalTokens: 1_500, + }, + }, + { + actorId: "agent:qa", + laneHint: "assignment", + runId: "run-2", + issueId: "issue-2", + issueIdentifier: "PAP-2", + issueTitle: "Verify timeline stats", + start: "2026-07-02T11:00:00.000Z", + end: "2026-07-02T11:15:00.000Z", + status: "succeeded", + retryOfRunId: null, + usage: { + inputTokens: 900, + cachedInputTokens: 100, + outputTokens: 500, + totalTokens: 1_500, + }, + }, + ], + events: [], + edges: [], + pagination: { + limit: 100, + offset: 0, + totalIssues: 2, + hasMore: false, + }, + window: { + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-02T23:59:59.999Z", + capped: false, + }, +}; + async function flushReact() { for (let index = 0; index < 3; index += 1) { await Promise.resolve(); @@ -114,6 +177,101 @@ describe("Timeline", () => { expect(container.textContent).not.toContain("visible"); }); + it("renders top timeline stats and keeps range controls in the chart footer", async () => { + mockWorkTimelineApi.get.mockResolvedValue(populatedTimeline); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Runs"); + expect(container.textContent).toContain("Agents"); + expect(container.textContent).toContain("Run time"); + expect(container.textContent).toContain("Tokens used"); + expect(container.textContent).toContain("45m"); + expect(container.textContent).toContain("3K"); + + const footer = Array.from(container.querySelectorAll("div")).find((element) => + element.textContent?.includes("2 runs") && element.textContent.includes("Range"), + ); + expect(footer).not.toBeUndefined(); + }); + + it("clamps open run summary time to the returned timeline window", async () => { + mockWorkTimelineApi.get.mockResolvedValue({ + ...populatedTimeline, + spans: [ + { + ...populatedTimeline.spans[0], + start: "2026-07-02T00:00:00.000Z", + end: null, + }, + ], + window: { + from: "2026-07-02T00:00:00.000Z", + to: "2026-07-02T02:00:00.000Z", + capped: false, + }, + }); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Run time"); + expect(container.textContent).toContain("2h 0m"); + }); + + it("prorates summary tokens to the returned timeline window for clipped spans", async () => { + mockWorkTimelineApi.get.mockResolvedValue({ + ...populatedTimeline, + spans: [ + { + ...populatedTimeline.spans[0], + start: "2026-07-02T00:00:00.000Z", + end: "2026-07-02T04:00:00.000Z", + usage: { + inputTokens: 2_000, + cachedInputTokens: 0, + outputTokens: 2_000, + totalTokens: 4_000, + }, + }, + ], + window: { + from: "2026-07-02T02:00:00.000Z", + to: "2026-07-02T04:00:00.000Z", + capped: false, + }, + }); + root = createRoot(container); + + flushSync(() => { + root?.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Tokens used"); + expect(container.textContent).toContain("2K"); + expect(container.textContent).not.toContain("4K"); + }); + it("requests the company timeline without a user lens parameter", async () => { root = createRoot(container); diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx index 8183a34fbc..9039a734a3 100644 --- a/ui/src/pages/Timeline.tsx +++ b/ui/src/pages/Timeline.tsx @@ -7,7 +7,8 @@ */ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react"; +import { Bot, Clock3, Coins, GanttChartSquare, Minus, Plus, RotateCcw, type LucideIcon } from "lucide-react"; +import type { WorkTimelineResult } from "@paperclipai/shared"; import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; @@ -25,7 +26,7 @@ import { type ZoomLevel, zoomScaleForLevel, } from "@/components/timeline/WorkTimelineChart"; -import { TIMELINE_COLORS } from "@/lib/timeline/layout"; +import { formatDuration, TIMELINE_COLORS } from "@/lib/timeline/layout"; import { cn } from "@/lib/utils"; type RangePreset = "today" | "7d" | "30d" | "custom"; @@ -66,6 +67,72 @@ function rangeError(range: DateRangeState): string | null { return null; } +function formatInteger(value: number): string { + return new Intl.NumberFormat("en-US").format(value); +} + +function formatCompactInteger(value: number): string { + return new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +function spanStartMs(span: WorkTimelineResult["spans"][number]) { + return new Date(span.start).getTime(); +} + +function spanEndMs(span: WorkTimelineResult["spans"][number], fallbackEndMs: number) { + return span.end ? new Date(span.end).getTime() : fallbackEndMs; +} + +function spanWindowOverlap( + span: WorkTimelineResult["spans"][number], + windowFromMs: number, + windowToMs: number, +) { + const rawStartMs = spanStartMs(span); + const rawEndMs = spanEndMs(span, windowToMs); + const startMs = Math.max(rawStartMs, windowFromMs); + const endMs = Math.min(rawEndMs, windowToMs); + return { + clippedMs: Math.max(0, endMs - startMs), + rawMs: Math.max(0, rawEndMs - rawStartMs), + }; +} + +function spanWindowTokens(span: WorkTimelineResult["spans"][number], rawMs: number, clippedMs: number) { + const totalTokens = span.usage?.totalTokens ?? 0; + if (totalTokens <= 0 || clippedMs <= 0) return 0; + if (rawMs <= 0 || clippedMs >= rawMs) return totalTokens; + return Math.round(totalTokens * (clippedMs / rawMs)); +} + +function timelineSummary(data: WorkTimelineResult) { + const actorById = new Map(data.actors.map((actor) => [actor.id, actor])); + const activeAgentIds = new Set(); + const windowFromMs = new Date(data.window.from).getTime(); + const windowToMs = new Date(data.window.to).getTime(); + let activeMs = 0; + let totalTokens = 0; + + for (const span of data.spans) { + if (actorById.get(span.actorId)?.type === "agent") { + activeAgentIds.add(span.actorId); + } + const overlap = spanWindowOverlap(span, windowFromMs, windowToMs); + activeMs += overlap.clippedMs; + totalTokens += spanWindowTokens(span, overlap.rawMs, overlap.clippedMs); + } + + return { + runs: data.spans.length, + agents: activeAgentIds.size, + activeMs, + totalTokens, + }; +} + function Segmented({ value, options, @@ -125,6 +192,40 @@ function TimelineLegend() { ); } +function TimelineSummaryStats({ + summary, +}: { + summary: ReturnType; +}) { + const stats: { label: string; value: string; icon: LucideIcon }[] = [ + { label: "Runs", value: formatInteger(summary.runs), icon: GanttChartSquare }, + { label: "Agents", value: formatInteger(summary.agents), icon: Bot }, + { label: "Run time", value: formatDuration(0, summary.activeMs), icon: Clock3 }, + { + label: "Tokens used", + value: summary.totalTokens > 0 ? formatCompactInteger(summary.totalTokens) : "Not tracked", + icon: Coins, + }, + ]; + + return ( +
+ {stats.map((stat) => { + const Icon = stat.icon; + return ( +
+
+ + {stat.label} +
+
{stat.value}
+
+ ); + })} +
+ ); +} + export function Timeline() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -191,46 +292,52 @@ export function Timeline() { setZoomScale(undefined); }; + const summary = data ? timelineSummary(data) : null; + + const rangeControls = ( + + ); + const toolbar = ( -
- -
+
+ {summary && } +