[codex] Add work timeline page (#8938)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is where operators inspect company activity, agent
work, and issue progress.
> - Existing board views show individual issue and run details, but they
do not give operators a compact time-based picture of work across
agents.
> - A work timeline helps operators scan when agents worked, how
handoffs happened, and where overlapping work occurred.
> - This pull request adds a company-scoped Work Timeline page backed by
the existing API surface and renders the timeline as a custom SVG
Gantt-style view.
> - The benefit is faster operator understanding of multi-agent
execution without opening each issue thread individually.

## Linked Issues or Issue Description

No public GitHub issue exists for this change.

Subsystem affected:
- ui/ — React + Vite board UI

Problem or motivation:
- Operators can inspect individual issues and runs, but there is no
compact time-based view of company work across agents.
- This makes it harder to scan overlaps, handoffs, retries, and activity
windows without opening many issue threads.

Proposed solution:
- Add a company-scoped Work Timeline page in the board UI.
- Render agent and system run spans as a custom SVG Gantt-style chart
with packed overlap lanes.
- Show issue color identity, kickoff attribution, hover-revealed
delegation connectors, retry styling, zoom controls, a sticky actor
gutter, and a minimap brush.
- Keep human activity lightweight by showing human kickoff chips without
plotting standalone human event rows.

Alternatives considered:
- Add the same information to existing issue-list or run-list views.
That would preserve simpler UI, but it would not show temporal overlap
or handoff paths clearly.
- Build this as a plugin-only surface. That keeps core smaller, but the
board already has the company-scoped route, navigation, and API client
patterns needed for this operator workflow.

Roadmap alignment:
- `ROADMAP.md` does not list an existing duplicate work-timeline
milestone. This supports the broader operator visibility direction
around artifacts, enforced outcomes, and higher-autonomy execution.

Additional context:
- Storybook includes `Pages/Work Timeline` stories for hour/day zoom and
a human-activity sample so reviewers can inspect the component without a
live backend.

## What Changed

- Added the Work Timeline page, route, sidebar entry, API client, query
key, and company-prefixed route helper coverage.
- Added a pure timeline layout transform for row packing, issue colors,
kickoff attribution, connector calculation, tick selection, and duration
formatting.
- Added the custom SVG timeline chart with sticky actor gutter,
hover-revealed connectors, zoom controls, minimap brushing,
visible-range feedback, and issue navigation.
- Added Storybook coverage plus sample fixtures for the work timeline.
- Added and corrected focused UI tests covering layout, chart behavior,
routing, sidebar behavior, and collapsed-sidebar expectations.
- Addressed Greptile feedback for kickoff fallback ordering, minimap
range math, document drag listener cleanup, and stable default `now`
handling.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/timeline/layout.test.ts
src/components/timeline/WorkTimelineChart.test.tsx
src/pages/Timeline.test.tsx src/lib/company-routes.test.ts
src/components/Sidebar.test.tsx
src/components/RequestCollapsedSidebar.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `git merge-tree $(git merge-base HEAD origin/master) HEAD
origin/master | rg -n "<<<<<<<|changed in both|CONFLICT"` returned no
conflicts.
- Attempted Storybook screenshot capture with Playwright; Storybook ran
locally, but Chromium could not launch in this container because native
browser libraries such as `libatk-1.0.so.0` are unavailable and `npx
playwright install-deps chromium` requires interactive sudo.

## Risks

- Medium UI risk: this adds a substantial visual surface with custom SVG
interaction logic, so browser-level review is still useful for
responsive behavior and usability.
- Low backend risk: this PR only adds a UI client/page around the
existing timeline API contract and does not change database schema or
server routes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 via Codex coding agent, with repository file access, shell
command execution, GitHub connector access, and focused test execution.
Exact context-window details are not exposed in this runtime.

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-06 07:57:23 -05:00 committed by GitHub
parent 8a058f9d79
commit 518fc71cec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4435 additions and 17 deletions

View File

@ -52,5 +52,5 @@ describe("skills catalog package artifacts", () => {
expect(paths).toContain("catalog/bundled/software-development/github-pr-workflow/SKILL.md");
expect(paths).toContain("catalog/optional/browser/agent-browser/SKILL.md");
expect(paths).toContain("package.json");
}, 30_000);
}, 120_000);
});

View File

@ -8,6 +8,7 @@ import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Timeline } from "./pages/Timeline";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
@ -80,6 +81,7 @@ function boardRoutes() {
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="timeline" element={<Timeline />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />

View File

@ -0,0 +1,31 @@
import type { WorkTimelineResult } from "@paperclipai/shared";
import { api } from "./client";
export interface WorkTimelineParams {
from?: string;
to?: string;
/** lens: work kicked off / touched by this user. */
userId?: string;
goalId?: string;
projectId?: string;
issueId?: string;
limit?: number;
}
function query(params: WorkTimelineParams): string {
const search = new URLSearchParams();
if (params.from) search.set("from", params.from);
if (params.to) search.set("to", params.to);
if (params.userId) search.set("userId", params.userId);
if (params.goalId) search.set("goalId", params.goalId);
if (params.projectId) search.set("projectId", params.projectId);
if (params.issueId) search.set("issueId", params.issueId);
if (params.limit) search.set("limit", String(params.limit));
const qs = search.toString();
return qs ? `?${qs}` : "";
}
export const workTimelineApi = {
get: (companyId: string, params: WorkTimelineParams = {}) =>
api.get<WorkTimelineResult>(`/companies/${companyId}/timeline${query(params)}`),
};

View File

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
@ -30,11 +30,18 @@ function Harness({ onRoute }: { onRoute: boolean }) {
);
}
function render(onRoute: boolean): { root: Root; host: HTMLDivElement } {
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
flushSync(() => {});
}
async function render(onRoute: boolean): Promise<{ root: Root; host: HTMLDivElement }> {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() => root.render(<Harness onRoute={onRoute} />));
flushSync(() => root.render(<Harness onRoute={onRoute} />));
await flushReact();
return { root, host };
}
@ -68,45 +75,47 @@ describe("RequestCollapsedSidebar", () => {
afterEach(() => {
if (active) {
act(() => active!.root.unmount());
flushSync(() => active!.root.unmount());
active.host.remove();
active = null;
}
localStorage.clear();
});
it("requests collapsed while mounted when there is no user pin", () => {
active = render(true);
it("requests collapsed while mounted when there is no user pin", async () => {
active = await render(true);
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(true);
});
it("lets an explicit user pin override the route request", () => {
active = render(true);
it("lets an explicit user pin override the route request", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// User explicitly pins expanded — must win over the route's request.
act(() => capturedValue?.setCollapsed(false));
flushSync(() => capturedValue?.setCollapsed(false));
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
it("clears the request on unmount, restoring the global default", () => {
active = render(true);
it("clears the request on unmount, restoring the global default", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
act(() => active!.root.render(<Harness onRoute={false} />));
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(false);
});
it("keeps a user pin after navigating away (pin persists, request cleared)", () => {
active = render(true);
act(() => capturedValue?.setCollapsed(true));
it("keeps a user pin after navigating away (pin persists, request cleared)", async () => {
active = await render(true);
flushSync(() => capturedValue?.setCollapsed(true));
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
act(() => active!.root.render(<Harness onRoute={false} />));
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
// Route request gone, but the explicit collapsed pin still applies.
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(true);

View File

@ -310,6 +310,24 @@ describe("Sidebar", () => {
});
});
it("places Timeline in the Company section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
expect(workSection?.textContent).not.toContain("Timeline");
expect(companySection?.textContent).toContain("Timeline");
const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline");
expect(timelineLink?.getAttribute("href")).toBe("/timeline");
flushSync(() => {
root.unmount();
});
});
it("shows the Conference Room nav item when conference room chat is enabled (PAP-137)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,

View File

@ -18,6 +18,7 @@ import {
PanelLeftOpen,
Pin,
MessagesSquare,
GanttChartSquare,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { NavLink } from "@/lib/router";
@ -210,6 +211,7 @@ export function Sidebar() {
<SidebarSection label="Company">
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />

View File

@ -0,0 +1,454 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import type { ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkTimelineChart } from "./WorkTimelineChart";
import { computeLayout } from "@/lib/timeline/layout";
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/PAP/timeline" }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.restoreAllMocks();
});
function renderChart(
data: WorkTimelineResult,
props: Partial<ComponentProps<typeof WorkTimelineChart>> = {},
) {
flushSync(() => {
root.render(
<WorkTimelineChart
data={data}
zoom="hour"
nowMs={new Date("2026-07-02T12:00:00.000Z").getTime()}
{...props}
/>,
);
});
}
function timelineSample(): WorkTimelineResult {
return {
actors: [
{ id: "agent:codex", type: "agent", name: "CodexCoder" },
{ id: "agent:qa", type: "agent", name: "QA" },
],
spans: [
{
actorId: "agent:codex",
laneHint: null,
runId: "run-1",
issueId: "issue-1",
issueIdentifier: "PAP-12443",
issueTitle: "Work Timeline sticky gutter",
start: "2026-07-02T09:00:00.000Z",
end: "2026-07-02T10:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
{
actorId: "agent:qa",
laneHint: null,
runId: "run-2",
issueId: "issue-2",
issueIdentifier: "PAP-12426",
issueTitle: "QA validation",
start: "2026-07-02T11:00:00.000Z",
end: "2026-07-02T11:30:00.000Z",
status: "completed",
retryOfRunId: null,
},
],
events: [],
edges: [],
pagination: { limit: 200, offset: 0, totalIssues: 2, hasMore: false },
window: {
from: "2026-07-02T00:00:00.000Z",
to: "2026-07-03T00:00:00.000Z",
capped: false,
},
};
}
describe("WorkTimelineChart", () => {
it("renders date-aware AM/PM labels on the header axis", () => {
renderChart(timelineSample());
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']");
expect(timeAxis?.textContent).toContain("Jul 2");
expect(timeAxis?.textContent).toContain("AM");
expect(timeAxis?.textContent).not.toContain("09:00");
});
it("freezes the time axis over vertical scrolling while preserving horizontal alignment", async () => {
renderChart(timelineSample());
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
const timeAxis = container.querySelector<HTMLElement>("[data-testid='work-timeline-time-axis']")!;
const axisSvg = timeAxis.querySelector<SVGSVGElement>("svg")!;
expect(timeAxis.getAttribute("class")).toContain("absolute");
expect(timeAxis.getAttribute("class")).toContain("top-0");
expect(timeAxis.style.height).toBe("32px");
expect(axisSvg.style.transform).toBe("translateX(0px)");
flushSync(() => {
Object.defineProperty(scroller, "scrollTop", { configurable: true, value: 400 });
Object.defineProperty(scroller, "scrollLeft", { configurable: true, value: 240 });
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timeAxis.textContent).toContain("Jul 2");
expect(axisSvg.style.transform).toBe("translateX(-240px)");
});
it("renders actor labels in a sticky gutter outside the horizontally scrolling SVG", () => {
renderChart(timelineSample());
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']");
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute");
expect(scroller).not.toBeNull();
expect(gutter).not.toBeNull();
expect(chartSvg).not.toBeNull();
expect(gutter?.getAttribute("class")).toContain("sticky");
expect(gutter?.getAttribute("class")).toContain("left-0");
expect(gutter?.getAttribute("class")).not.toContain("top-0");
expect(gutter?.getAttribute("width")).toBe("176");
expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width"));
expect(gutter?.textContent).toContain("CodexCoder");
flushSync(() => {
scroller!.scrollLeft = 10_000;
scroller!.dispatchEvent(new Event("scroll", { bubbles: true }));
});
expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder");
});
it("does not render created diamonds or comment bubbles from instant events", () => {
const data = timelineSample();
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
data.events = [
{ actorId: "user:dotta", kind: "created", issueId: "issue-1", at: "2026-07-02T08:30:00.000Z" },
{ actorId: "user:dotta", kind: "commented", issueId: "issue-2", at: "2026-07-02T09:15:00.000Z" },
{ actorId: "user:dotta", kind: "approved", issueId: "issue-1", at: "2026-07-02T10:05:00.000Z" },
];
renderChart(data);
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
expect(gutter?.textContent).not.toContain("Dotta");
expect(container.querySelectorAll("[data-testid='timeline-event-marker']")).toHaveLength(0);
expect(container.querySelectorAll("[data-testid='timeline-comment-marker']")).toHaveLength(0);
});
it("keeps connectors hidden until hover, renders them orthogonally, and highlights the connected graph", async () => {
const data = timelineSample();
data.actors.push({ id: "agent:cto", type: "agent", name: "CTO" });
data.spans.push(
{
actorId: "agent:cto",
laneHint: null,
runId: "run-3",
issueId: "issue-3",
issueIdentifier: "PAP-12427",
issueTitle: "Follow-up validation",
start: "2026-07-02T11:45:00.000Z",
end: "2026-07-02T12:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
{
actorId: "agent:codex",
laneHint: null,
runId: "run-4",
issueId: "issue-4",
issueIdentifier: "PAP-12428",
issueTitle: "Unrelated work",
start: "2026-07-02T13:00:00.000Z",
end: "2026-07-02T14:00:00.000Z",
status: "completed",
retryOfRunId: null,
},
);
data.edges = [
{
fromActorId: "agent:codex",
toActorId: "agent:qa",
issueId: "issue-2",
at: "2026-07-02T10:45:00.000Z",
kind: "delegation",
},
{
fromActorId: "agent:qa",
toActorId: "agent:cto",
issueId: "issue-3",
at: "2026-07-02T11:35:00.000Z",
kind: "delegation",
},
];
renderChart(data);
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(0);
const hovered = container.querySelector<SVGGElement>("[data-run-id='run-2']")!;
flushSync(() => {
hovered.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: 100, clientY: 100 }));
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(container.querySelectorAll("[data-testid='timeline-connector']")).toHaveLength(2);
const connectorStrokePaths = Array.from(
container.querySelectorAll<SVGPathElement>("[data-testid='timeline-connector'] path[fill='none']"),
);
expect(connectorStrokePaths.map((path) => path.getAttribute("d"))).toEqual(
expect.arrayContaining([expect.stringMatching(/ V.+ H/)]),
);
expect(container.querySelector("[data-run-id='run-1']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-2']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-3']")?.getAttribute("data-connected-state")).toBe("connected");
expect(container.querySelector("[data-run-id='run-4']")?.getAttribute("data-connected-state")).toBe("faded");
const layout = computeLayout(data, {
gutter: 176,
rowH: 34,
barH: 15,
laneGap: 4,
pxPerMinute: 8,
nowMs: new Date("2026-07-02T12:00:00.000Z").getTime(),
});
expect(layout.connectors).toMatchObject([
{ sourceRunId: "run-1", targetRunId: "run-2", dashed: false },
{ sourceRunId: "run-2", targetRunId: "run-3", dashed: false },
]);
const bars = new Map(layout.rows.flatMap((row) => row.bars.map((bar) => [bar.span.runId, bar])));
expect(layout.connectors[0].x1).toBe(bars.get("run-1")?.x2);
expect(layout.connectors[0].x2).toBe(bars.get("run-2")?.x1);
});
it("renders kickoff chips for human users but not delegating agents", () => {
const data = timelineSample();
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" });
data.edges = [
{
fromActorId: "user:dotta",
toActorId: "agent:codex",
issueId: "issue-1",
at: "2026-07-02T08:45:00.000Z",
kind: "delegation",
},
{
fromActorId: "agent:codex",
toActorId: "agent:qa",
issueId: "issue-2",
at: "2026-07-02T10:45:00.000Z",
kind: "delegation",
},
];
renderChart(data);
const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']");
expect(kickoffChips).toHaveLength(1);
expect(kickoffChips[0].textContent).toContain("DO");
});
it("reserves normal wheel input for panning and uses modifier-wheel for continuous zoom", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const scroller = container.querySelector<HTMLElement>("[data-testid='work-timeline-scroll']")!;
flushSync(() => {
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).not.toHaveBeenCalled();
flushSync(() => {
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 80, ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).toHaveBeenCalledTimes(1);
});
it("opens task bars in a new company-prefixed window", () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
renderChart(timelineSample());
const bar = container.querySelector<SVGGElement>("[data-run-id='run-1']")!;
flushSync(() => {
bar.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(open).toHaveBeenCalledWith("/PAP/issues/issue-1", "_blank", "noopener,noreferrer");
});
it("lets minimap edge handles resize the visible range and update zoom", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 900,
bottom: 54,
width: 900,
height: 54,
toJSON: () => ({}),
});
flushSync(() => {
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
expect(onZoomScaleChange).toHaveBeenCalled();
});
it("cleans up chart drag listeners when unmounted mid-drag", () => {
const add = vi.spyOn(document, "addEventListener");
const remove = vi.spyOn(document, "removeEventListener");
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 1000,
bottom: 400,
width: 1000,
height: 400,
toJSON: () => ({}),
});
flushSync(() => {
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
});
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
flushSync(() => root.unmount());
root = createRoot(container);
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
});
it("cleans up minimap drag listeners when unmounted mid-drag", () => {
const add = vi.spyOn(document, "addEventListener");
const remove = vi.spyOn(document, "removeEventListener");
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 900,
bottom: 54,
width: 900,
height: 54,
toJSON: () => ({}),
});
flushSync(() => {
rightHandle.dispatchEvent(new MouseEvent("mousedown", { clientX: 300, bubbles: true, cancelable: true }));
});
expect(add).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(add).toHaveBeenCalledWith("mouseup", expect.any(Function));
flushSync(() => root.unmount());
root = createRoot(container);
expect(remove).toHaveBeenCalledWith("mousemove", expect.any(Function));
expect(remove).toHaveBeenCalledWith("mouseup", expect.any(Function));
});
it("keeps the default now timestamp stable across rerenders", () => {
const now = new Date("2026-07-02T12:00:00.000Z").getTime();
const later = new Date("2026-07-02T13:00:00.000Z").getTime();
let currentNow = now;
vi.spyOn(Date, "now").mockImplementation(() => currentNow);
const data = timelineSample();
data.spans[0] = {
...data.spans[0],
end: null,
status: "running",
};
renderChart(data, { nowMs: undefined });
const initialWidth = container
.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")
?.getAttribute("width");
currentNow = later;
renderChart(data, { nowMs: undefined });
expect(container.querySelector<SVGRectElement>("[data-run-id='run-1'] rect")?.getAttribute("width")).toBe(initialWidth);
});
it("lets dragging the chart grid select a time range to zoom into", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const chartSvg = container.querySelector<SVGSVGElement>("svg.absolute")!;
const width = Number(chartSvg.getAttribute("width") ?? "1000");
const height = Number(chartSvg.getAttribute("height") ?? "400");
vi.spyOn(chartSvg, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
});
flushSync(() => {
chartSvg.dispatchEvent(new MouseEvent("mousedown", { clientX: 260, bubbles: true, cancelable: true }));
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 520, bubbles: true, cancelable: true }));
});
expect(container.querySelector("[data-testid='timeline-drag-selection']")).not.toBeNull();
flushSync(() => {
document.dispatchEvent(new MouseEvent("mouseup", { clientX: 520, bubbles: true, cancelable: true }));
});
expect(onZoomScaleChange).toHaveBeenCalled();
expect(container.querySelector("[data-testid='timeline-drag-selection']")).toBeNull();
});
});

View File

@ -0,0 +1,902 @@
/**
* Work Timeline custom-SVG Gantt (board-locked Direction C, PAP-12422).
*
* Renders actor rows with concurrency sub-lanes, run bars (no issue IDs on the
* bar identity is the thin left colour tab; truncated title shows on hover),
* human kickoff chips at each bar's leading edge, straight
* hover-revealed agentagent delegation connectors (dashed for retries), an
* in-progress fade to "now", a hover tooltip, and a full-window mini-map with a
* draggable brush.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
import {
AXIS_H,
actorType,
barColor,
chooseTickStepMs,
computeLayout,
formatDuration,
isCancelledStatus,
shortLabel,
TIMELINE_COLORS,
type LayoutOptions,
type PositionedBar,
} from "@/lib/timeline/layout";
export type ZoomLevel = "hour" | "day" | "week";
const ZOOM_DURATION_MIN: Record<ZoomLevel, number> = {
hour: 60,
day: 24 * 60,
week: 7 * 24 * 60,
};
const MIN_PX_PER_MIN = 0.08;
const MAX_PX_PER_MIN = 12;
const DEFAULT_VIEWPORT_W = 960;
const MIN_MINIMAP_SELECTION_MS = 15 * 60 * 1000;
function plotViewportWidth(viewportWidth: number): number {
return Math.max(240, viewportWidth - GEOM.gutter - 24);
}
export function zoomScaleForLevel(level: ZoomLevel, viewportWidth = DEFAULT_VIEWPORT_W): number {
return clampZoomScale(plotViewportWidth(viewportWidth) / ZOOM_DURATION_MIN[level]);
}
export function nearestZoomForScale(pxPerMinute: number, viewportWidth = DEFAULT_VIEWPORT_W): ZoomLevel {
return (Object.entries(ZOOM_DURATION_MIN) as [ZoomLevel, number][]).reduce<ZoomLevel>((best, [level]) => (
Math.abs(zoomScaleForLevel(level, viewportWidth) - pxPerMinute)
< Math.abs(zoomScaleForLevel(best, viewportWidth) - pxPerMinute)
? level
: best
), "day");
}
export function clampZoomScale(pxPerMinute: number): number {
return Math.min(MAX_PX_PER_MIN, Math.max(MIN_PX_PER_MIN, pxPerMinute));
}
/** Pick an initial zoom whose plotted width comfortably fills a typical viewport. */
export function defaultZoomForWindow(fromMs: number, toMs: number): ZoomLevel {
const hours = (toMs - fromMs) / 3_600_000;
if (hours <= 4) return "hour";
if (hours <= 48) return "day";
return "week";
}
const GEOM: Omit<LayoutOptions, "pxPerMinute" | "nowMs"> = {
gutter: 176,
rowH: 34,
barH: 15,
laneGap: 4,
};
const AVATAR_R = 11;
const CHIP_R = 9;
interface TooltipState {
x: number;
y: number;
bar: PositionedBar;
connectorHint: string | null;
}
interface DragSelectionState {
anchorX: number;
currentX: number;
}
function fmtClock(ms: number): string {
const d = new Date(ms);
const hasMinutes = d.getMinutes() !== 0;
return d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: hasMinutes ? "2-digit" : undefined,
hour12: true,
});
}
function fmtTick(ms: number, stepMs: number): string {
const d = new Date(ms);
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
if (stepMs >= 24 * 60 * 60 * 1000) {
return date;
}
return `${date}, ${fmtClock(ms)}`;
}
export function formatVisibleDurationMinutes(minutes: number): string {
const rounded = Math.max(1, Math.round(minutes));
if (rounded >= 7 * 24 * 60 && rounded % (7 * 24 * 60) === 0) {
const weeks = rounded / (7 * 24 * 60);
return `${weeks} week${weeks === 1 ? "" : "s"} visible`;
}
if (rounded >= 24 * 60 && rounded % (24 * 60) === 0) {
const days = rounded / (24 * 60);
return `${days} day${days === 1 ? "" : "s"} visible`;
}
if (rounded >= 24 * 60) {
const days = Math.floor(rounded / (24 * 60));
const hours = Math.round((rounded % (24 * 60)) / 60);
return `${days}d${hours > 0 ? ` ${hours}h` : ""} visible`;
}
if (rounded >= 60 && rounded % 60 === 0) {
const hours = rounded / 60;
return `${hours} hour${hours === 1 ? "" : "s"} visible`;
}
if (rounded >= 60) return `${Math.floor(rounded / 60)}h ${rounded % 60}m visible`;
return `${rounded} minutes visible`;
}
function truncate(text: string, n = 42): string {
return text.length > n ? `${text.slice(0, n - 1)}` : text;
}
/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */
function AvatarGlyph({
cx,
cy,
r,
label,
type,
}: {
cx: number;
cy: number;
r: number;
label: string;
type: string;
}) {
const stroke = "var(--color-foreground)";
const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)";
return (
<g>
{type === "user" ? (
<rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} rx={3} fill={fill} stroke={stroke} strokeWidth={1.5} />
) : (
<circle
cx={cx}
cy={cy}
r={r}
fill={fill}
stroke={stroke}
strokeWidth={1.5}
strokeDasharray={type === "system" ? "3 2" : undefined}
/>
)}
<text x={cx} y={cy + 3.4} fontSize={r > 10 ? 9 : 8} textAnchor="middle" fill={stroke}>
{label}
</text>
</g>
);
}
export interface WorkTimelineChartProps {
data: WorkTimelineResult;
zoom: ZoomLevel;
zoomScale?: number;
onZoomScaleChange?: (nextScale: number, nextZoom: ZoomLevel) => void;
onVisibleRangeLabelChange?: (label: string) => void;
/** override "now" (tests / stories); defaults to Date.now(). */
nowMs?: number;
}
export function WorkTimelineChart({
data,
zoom,
zoomScale,
onZoomScaleChange,
onVisibleRangeLabelChange,
nowMs,
}: WorkTimelineChartProps) {
const location = useLocation();
const scrollRef = useRef<HTMLDivElement>(null);
const initialWindowKeyRef = useRef<string | null>(null);
const centerMsRef = useRef<number | null>(null);
const defaultNowRef = useRef<number | null>(null);
const documentDragCleanupRef = useRef<(() => void) | null>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [hoveredRunId, setHoveredRunId] = useState<string | null>(null);
const [scrollLeft, setScrollLeft] = useState(0);
const [viewportW, setViewportW] = useState(0);
const [dragSelection, setDragSelection] = useState<DragSelectionState | null>(null);
const clearDocumentDrag = () => {
documentDragCleanupRef.current?.();
documentDragCleanupRef.current = null;
};
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
clearDocumentDrag();
const handleUp = (event: MouseEvent) => {
clearDocumentDrag();
up(event);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", handleUp);
documentDragCleanupRef.current = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", handleUp);
};
};
useEffect(() => () => clearDocumentDrag(), []);
if (defaultNowRef.current == null) defaultNowRef.current = Date.now();
const now = nowMs ?? defaultNowRef.current;
const pxPerMinute = zoomScale ?? zoomScaleForLevel(zoom, viewportW || DEFAULT_VIEWPORT_W);
const layout = useMemo(
() => computeLayout(data, { ...GEOM, pxPerMinute, nowMs: now }),
[data, pxPerMinute, now],
);
const connectedRunIds = useMemo(() => {
if (!hoveredRunId) return null;
const connected = new Set([hoveredRunId]);
let changed = true;
while (changed) {
changed = false;
for (const c of layout.connectors) {
if (connected.has(c.sourceRunId) && !connected.has(c.targetRunId)) {
connected.add(c.targetRunId);
changed = true;
}
if (connected.has(c.targetRunId) && !connected.has(c.sourceRunId)) {
connected.add(c.sourceRunId);
changed = true;
}
}
}
return connected;
}, [hoveredRunId, layout.connectors]);
const visibleConnectors = useMemo(
() =>
connectedRunIds
? layout.connectors.filter((c) => connectedRunIds.has(c.sourceRunId) && connectedRunIds.has(c.targetRunId))
: [],
[connectedRunIds, layout.connectors],
);
const companyPrefix = extractCompanyPrefixFromPath(location.pathname);
const timeToScrollLeft = (ms: number, viewportWidth: number) => {
const x = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return Math.max(0, Math.min(layout.width - viewportWidth, x - viewportWidth / 2));
};
const scrollCenterMs = (el: HTMLDivElement) => {
const centerX = el.scrollLeft + el.clientWidth / 2;
return layout.fromMs + ((centerX - layout.gutter) / layout.pxPerMinute) * 60000;
};
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nextViewportW = el.clientWidth;
if (nextViewportW > 0 && nextViewportW !== viewportW) setViewportW(nextViewportW);
const windowKey = `${data.window.from}:${data.window.to}`;
if (initialWindowKeyRef.current !== windowKey) {
initialWindowKeyRef.current = windowKey;
const latest = Math.max(0, layout.width - nextViewportW);
el.scrollLeft = latest;
setScrollLeft(latest);
centerMsRef.current = scrollCenterMs(el);
return;
}
if (centerMsRef.current != null) {
const next = timeToScrollLeft(centerMsRef.current, nextViewportW);
el.scrollLeft = next;
setScrollLeft(next);
}
}, [data.window.from, data.window.to, layout.fromMs, layout.gutter, layout.pxPerMinute, layout.toMs, layout.width, viewportW]);
useEffect(() => {
if (!onVisibleRangeLabelChange) return;
const effectiveViewportW = viewportW || DEFAULT_VIEWPORT_W;
const minutes = plotViewportWidth(effectiveViewportW) / layout.pxPerMinute;
onVisibleRangeLabelChange(formatVisibleDurationMinutes(minutes));
}, [layout.pxPerMinute, onVisibleRangeLabelChange, viewportW]);
const stepMs = chooseTickStepMs(layout.pxPerMinute);
const ticks: number[] = [];
const startTick = Math.ceil(layout.fromMs / stepMs) * stepMs;
for (let ms = startTick; ms <= layout.toMs; ms += stepMs) ticks.push(ms);
const openIssue = (issueId: string) => {
const href = applyCompanyPrefix(`/issues/${encodeURIComponent(issueId)}`, companyPrefix);
window.open(href, "_blank", "noopener,noreferrer");
};
const updateVisibleRange = (fromMs: number, toMs: number) => {
if (!onZoomScaleChange) return;
const el = scrollRef.current;
const boundedFrom = Math.max(layout.fromMs, Math.min(layout.toMs, fromMs));
const boundedTo = Math.max(layout.fromMs, Math.min(layout.toMs, toMs));
const startMs = Math.min(boundedFrom, boundedTo);
const endMs = Math.max(boundedFrom, boundedTo);
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, endMs - startMs);
const centerMs = startMs + durationMs / 2;
const effectiveViewportW = el?.clientWidth || viewportW || DEFAULT_VIEWPORT_W;
const nextScale = clampZoomScale(plotViewportWidth(effectiveViewportW) / (durationMs / 60000));
centerMsRef.current = centerMs;
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, effectiveViewportW));
};
const svgXFromClientX = (clientX: number, el: SVGSVGElement) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return layout.gutter;
const x = ((clientX - rect.left) / rect.width) * layout.width;
return Math.max(layout.gutter, Math.min(layout.width - 40, x));
};
const msFromSvgX = (x: number) => (
layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000
);
const handlePlotMouseDown = (event: React.MouseEvent<SVGSVGElement>) => {
if (!onZoomScaleChange || event.button !== 0) return;
const el = event.currentTarget;
const startX = svgXFromClientX(event.clientX, el);
event.preventDefault();
setTooltip(null);
setHoveredRunId(null);
setDragSelection({ anchorX: startX, currentX: startX });
const move = (moveEvent: MouseEvent) => {
setDragSelection((prev) => prev && {
...prev,
currentX: svgXFromClientX(moveEvent.clientX, el),
});
};
const up = (upEvent: MouseEvent) => {
const endX = svgXFromClientX(upEvent.clientX, el);
setDragSelection(null);
if (Math.abs(endX - startX) < 8) return;
const fromMs = Math.min(msFromSvgX(startX), msFromSvgX(endX));
const toMs = Math.max(msFromSvgX(startX), msFromSvgX(endX));
updateVisibleRange(fromMs, toMs);
};
setDocumentDrag(move, up);
};
const connectorHintForBar = (bar: PositionedBar): string | null => {
const related = layout.connectors.filter((c) => c.sourceRunId === bar.span.runId || c.targetRunId === bar.span.runId);
if (related.length === 0) return null;
return related.some((c) => c.dashed)
? "dashed handoff: retry or changes requested"
: "solid handoff: delegation or assignment";
};
const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => {
setHoveredRunId(bar.span.runId);
setTooltip({ x: evt.clientX, y: evt.clientY, bar, connectorHint: connectorHintForBar(bar) });
};
const handleWheel = (evt: React.WheelEvent<HTMLDivElement>) => {
if (!onZoomScaleChange || !(evt.ctrlKey || evt.metaKey || evt.altKey)) return;
evt.preventDefault();
const el = scrollRef.current;
if (el) {
centerMsRef.current = scrollCenterMs(el);
}
const nextScale = clampZoomScale(layout.pxPerMinute * Math.exp(-evt.deltaY * 0.001));
onZoomScaleChange(nextScale, nearestZoomForScale(nextScale, el?.clientWidth ?? viewportW));
};
return (
<div className="relative">
<div
ref={scrollRef}
className="max-h-[70vh] overflow-auto"
data-testid="work-timeline-scroll"
onScroll={(e) => {
setScrollLeft(e.currentTarget.scrollLeft);
centerMsRef.current = scrollCenterMs(e.currentTarget);
}}
onWheel={handleWheel}
>
<div className="relative" style={{ width: layout.width, height: layout.height }}>
<ActorGutter rows={layout.rows} height={layout.height} />
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
className="absolute inset-0 block select-none"
onMouseDown={handlePlotMouseDown}
ref={(el) => {
if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth);
}}
>
<defs>
<linearGradient id="tl-fade" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="var(--color-foreground)" stopOpacity={0.28} />
<stop offset="100%" stopColor="var(--color-foreground)" stopOpacity={0} />
</linearGradient>
</defs>
{/* row backgrounds */}
{layout.rows.map((row, i) => (
<rect
key={`bg-${row.actor.id}`}
x={0}
y={row.y + AXIS_H}
width={layout.width}
height={row.h}
fill={i % 2 ? "var(--color-muted)" : "transparent"}
opacity={i % 2 ? 0.35 : 1}
/>
))}
{/* vertical gridlines */}
{ticks.map((ms) => {
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return (
<g key={`tick-${ms}`}>
<line x1={gx} y1={AXIS_H} x2={gx} y2={layout.height} stroke="var(--color-border)" strokeWidth={1} />
</g>
);
})}
{/* now line — teal "Signal" present marker */}
{now >= layout.fromMs && now <= layout.toMs && (
<line
x1={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
y1={AXIS_H}
x2={layout.gutter + ((now - layout.fromMs) / 60000) * layout.pxPerMinute}
y2={layout.height}
stroke={TIMELINE_COLORS.now}
strokeWidth={1.5}
strokeDasharray="2 3"
opacity={0.9}
/>
)}
{/* gutter divider + axis baseline */}
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={layout.height} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
{/* connectors (behind bars): hover reveals the connected handoff graph. */}
{visibleConnectors.map((c, i) => {
const y1 = c.y1 + AXIS_H;
const y2 = c.y2 + AXIS_H;
const arrow =
c.x2 >= c.x1
? `M${c.x2},${y2} l-10,-5 l0,10 z`
: `M${c.x2},${y2} l10,-5 l0,10 z`;
return (
<g key={`edge-${c.sourceRunId}-${c.targetRunId}-${i}`} data-testid="timeline-connector" opacity={0.86}>
<path
d={`M${c.x1},${y1} V${y2} H${c.x2}`}
fill="none"
stroke="var(--color-foreground)"
strokeWidth={2.2}
strokeDasharray={c.dashed ? "5 4" : undefined}
/>
<circle cx={c.x1} cy={y1} r={3.2} fill="var(--color-foreground)" />
<path d={arrow} fill="var(--color-foreground)" />
</g>
);
})}
{/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */}
{layout.rows.map((row) => {
const cy = row.y + AXIS_H + row.h / 2;
return (
<g key={`row-${row.actor.id}`}>
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 18)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{Array.from({ length: row.laneCount }).map((_, ln) => {
const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2;
return (
<line
key={`lane-${row.actor.id}-${ln}`}
x1={layout.gutter}
y1={ly}
x2={layout.width - 8}
y2={ly}
stroke="var(--color-border)"
strokeWidth={1}
strokeDasharray="2 4"
opacity={0.6}
/>
);
})}
{row.bars.map((bar) => {
const yTop = bar.yTop + AXIS_H;
const w = bar.x2 - bar.x1;
const cancelled = isCancelledStatus(bar.span.status);
const color = barColor(bar);
const connectedState =
connectedRunIds == null ? "idle" : connectedRunIds.has(bar.span.runId) ? "connected" : "faded";
const barOpacity =
connectedState === "idle"
? 0.88
: connectedState === "connected"
? 1
: 0.22;
return (
<g key={bar.span.runId} opacity={connectedState === "faded" ? 0.42 : 1}>
<g
className="cursor-pointer"
data-run-id={bar.span.runId}
data-connected-state={connectedState}
onMouseEnter={(e) => showTooltip(e, bar)}
onMouseOver={(e) => showTooltip(e, bar)}
onMouseMove={(e) => showTooltip(e, bar)}
onMouseLeave={() => {
setTooltip(null);
setHoveredRunId(null);
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => openIssue(bar.span.issueId)}
>
{/* "Signal" encoding: fill = how the run started (delegated /
automation); cancelled runs drop the fill and read as a
hollow dashed bar. */}
<rect
x={bar.x1}
y={yTop}
width={w}
height={bar.height}
rx={3}
fill={cancelled ? "transparent" : color}
stroke={cancelled ? TIMELINE_COLORS.cancelled : "var(--color-foreground)"}
strokeWidth={1.5}
strokeDasharray={cancelled ? "4 3" : undefined}
opacity={barOpacity}
/>
{/* in-progress fade to "now" */}
{bar.running && !cancelled && w > 8 && (
<rect x={bar.x2 - Math.min(w - 2, 26)} y={yTop + 1.5} width={Math.min(w - 2, 26)} height={bar.height - 3} fill="url(#tl-fade)" />
)}
</g>
{bar.kickoff && actorType(bar.kickoff) === "user" && (
<g className="pointer-events-none" data-testid="timeline-kickoff-chip">
<AvatarGlyph
cx={bar.x1}
cy={yTop + bar.height / 2}
r={CHIP_R}
label={shortLabel((bar.kickoff as WorkTimelineActor).name)}
type={actorType(bar.kickoff)}
/>
</g>
)}
</g>
);
})}
</g>
);
})}
{dragSelection && (
<rect
data-testid="timeline-drag-selection"
x={Math.min(dragSelection.anchorX, dragSelection.currentX)}
y={AXIS_H}
width={Math.abs(dragSelection.currentX - dragSelection.anchorX)}
height={layout.height - AXIS_H}
fill="var(--color-primary)"
opacity={0.16}
stroke="var(--color-primary)"
strokeWidth={1.5}
pointerEvents="none"
/>
)}
</svg>
</div>
</div>
<TimeAxisOverlay layout={layout} ticks={ticks} stepMs={stepMs} scrollLeft={scrollLeft} />
<MiniMap
layout={layout}
scrollRef={scrollRef}
viewportW={viewportW}
scrollLeft={scrollLeft}
onVisibleRangeChange={updateVisibleRange}
/>
{tooltip && <Tooltip tooltip={tooltip} now={now} />}
</div>
);
}
function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>["rows"]; height: number }) {
return (
<svg
aria-hidden="true"
data-testid="work-timeline-actor-gutter"
width={GEOM.gutter}
height={height}
viewBox={`0 0 ${GEOM.gutter} ${height}`}
className="sticky left-0 z-20 block bg-card"
>
<rect x={0} y={0} width={GEOM.gutter} height={height} fill="var(--color-card)" />
{rows.map((row, i) => {
const cy = row.y + AXIS_H + row.h / 2;
return (
<g key={`gutter-${row.actor.id}`}>
<rect
x={0}
y={row.y + AXIS_H}
width={GEOM.gutter}
height={row.h}
fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"}
opacity={i % 2 ? 0.35 : 1}
/>
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 16)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{/* "Signal" rail: run count + active time, right-aligned in the gutter. */}
<text
x={GEOM.gutter - 10}
y={cy + 11}
fontSize={10.5}
textAnchor="end"
fill="var(--color-muted-foreground)"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{row.runCount}× · {formatDuration(0, row.activeMs)}
</text>
</g>
);
})}
<line x1={GEOM.gutter} y1={0} x2={GEOM.gutter} y2={height} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={GEOM.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
);
}
function TimeAxisOverlay({
layout,
ticks,
stepMs,
scrollLeft,
}: {
layout: ReturnType<typeof computeLayout>;
ticks: number[];
stepMs: number;
scrollLeft: number;
}) {
return (
<div
aria-hidden="true"
data-testid="work-timeline-time-axis"
className="pointer-events-none absolute left-0 right-0 top-0 z-30 overflow-hidden bg-card"
style={{ height: AXIS_H }}
>
<svg
width={layout.width}
height={AXIS_H}
viewBox={`0 0 ${layout.width} ${AXIS_H}`}
className="block"
style={{ transform: `translateX(${-scrollLeft}px)` }}
>
<rect x={0} y={0} width={layout.width} height={AXIS_H} fill="var(--color-card)" />
{ticks.map((ms) => {
const gx = layout.gutter + ((ms - layout.fromMs) / 60000) * layout.pxPerMinute;
return (
<g key={`axis-tick-${ms}`}>
<line x1={gx} y1={AXIS_H - 7} x2={gx} y2={AXIS_H} stroke="var(--color-border)" strokeWidth={1} />
<text x={gx + 3} y={19} fontSize={11} fill="var(--color-muted-foreground)">
{fmtTick(ms, stepMs)}
</text>
</g>
);
})}
<line x1={0} y1={AXIS_H} x2={layout.width} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
<svg
width={layout.gutter}
height={AXIS_H}
viewBox={`0 0 ${layout.gutter} ${AXIS_H}`}
className="absolute left-0 top-0 block bg-card"
>
<rect x={0} y={0} width={layout.gutter} height={AXIS_H} fill="var(--color-card)" />
<line x1={layout.gutter} y1={0} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
<line x1={0} y1={AXIS_H} x2={layout.gutter} y2={AXIS_H} stroke="var(--color-foreground)" strokeWidth={1.5} />
</svg>
</div>
);
}
function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) {
const { bar } = tooltip;
const startMs = new Date(bar.span.start).getTime();
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : now;
const title = bar.span.issueTitle ?? bar.span.issueIdentifier ?? "run";
const left = Math.min(tooltip.x + 14, (typeof window !== "undefined" ? window.innerWidth : 1200) - 300);
return (
<div
className="pointer-events-none fixed z-50 max-w-[280px] rounded-md border border-foreground bg-card px-2.5 py-2 text-xs shadow-md"
style={{ left, top: tooltip.y + 14 }}
>
<div className="text-[13px] font-medium text-foreground">{truncate(title)}</div>
<div className="mt-0.5 text-muted-foreground">
{fmtClock(startMs)}{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "}
<span className="font-medium text-foreground">{bar.span.status}</span>
</div>
{bar.kickoff && (
<div className="text-muted-foreground">
kicked off by: {(bar.kickoff as WorkTimelineActor).name}
{bar.span.retryOfRunId ? " · retry" : ""}
</div>
)}
{tooltip.connectorHint && (
<div className="text-muted-foreground">{tooltip.connectorHint}</div>
)}
</div>
);
}
function MiniMap({
layout,
scrollRef,
viewportW,
scrollLeft,
onVisibleRangeChange,
}: {
layout: ReturnType<typeof computeLayout>;
scrollRef: React.RefObject<HTMLDivElement | null>;
viewportW: number;
scrollLeft: number;
onVisibleRangeChange: (fromMs: number, toMs: number) => void;
}) {
const documentDragCleanupRef = useRef<(() => void) | null>(null);
const W = Math.max(320, viewportW || 900);
const H = 54;
const pad = 8;
const spanMs = layout.toMs - layout.fromMs || 1;
const mx = (ms: number) => pad + ((ms - layout.fromMs) / spanMs) * (W - 2 * pad);
// one thin tick per run, stacked by row order
const rowIndex = new Map(layout.rows.map((r, i) => [r.actor.id, i]));
const laneH = (H - 2 * pad) / Math.max(1, layout.rows.length);
const timeAtX = (x: number) => {
const ms = layout.fromMs + ((x - layout.gutter) / layout.pxPerMinute) * 60000;
return Math.max(layout.fromMs, Math.min(layout.toMs, ms));
};
const visibleStartMs = timeAtX(scrollLeft + layout.gutter);
const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W));
const brushX = mx(visibleStartMs);
const brushW = Math.max(24, mx(visibleEndMs) - brushX);
const clearDocumentDrag = () => {
documentDragCleanupRef.current?.();
documentDragCleanupRef.current = null;
};
const setDocumentDrag = (move: (event: MouseEvent) => void, up: (event: MouseEvent) => void) => {
clearDocumentDrag();
const handleUp = (event: MouseEvent) => {
clearDocumentDrag();
up(event);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", handleUp);
documentDragCleanupRef.current = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", handleUp);
};
};
useEffect(() => () => clearDocumentDrag(), []);
const msAtClientX = (clientX: number, el: SVGSVGElement) => {
const rect = el.getBoundingClientRect();
const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad)));
return layout.fromMs + f * spanMs;
};
const seek = (clientX: number, el: SVGSVGElement) => {
const centerMs = msAtClientX(clientX, el);
if (scrollRef.current) {
scrollRef.current.scrollLeft = layout.gutter + ((centerMs - layout.fromMs) / 60000) * layout.pxPerMinute - scrollRef.current.clientWidth / 2;
}
};
const startRangeDrag = (mode: "left" | "right" | "move", event: React.MouseEvent<SVGElement>) => {
event.preventDefault();
event.stopPropagation();
const el = event.currentTarget.ownerSVGElement;
if (!el) return;
const startLeftMs = visibleStartMs;
const startRightMs = visibleEndMs;
const durationMs = Math.max(MIN_MINIMAP_SELECTION_MS, startRightMs - startLeftMs);
const move = (ev: MouseEvent) => {
const hitMs = msAtClientX(ev.clientX, el);
if (mode === "left") {
onVisibleRangeChange(Math.min(hitMs, startRightMs - MIN_MINIMAP_SELECTION_MS), startRightMs);
} else if (mode === "right") {
onVisibleRangeChange(startLeftMs, Math.max(hitMs, startLeftMs + MIN_MINIMAP_SELECTION_MS));
} else {
const nextFrom = Math.max(layout.fromMs, Math.min(layout.toMs - durationMs, hitMs - durationMs / 2));
onVisibleRangeChange(nextFrom, nextFrom + durationMs);
}
};
setDocumentDrag(move, () => {});
};
return (
<div className="mt-2 border-t border-border bg-card px-3.5 py-2">
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
className="block cursor-ew-resize"
onMouseDown={(e) => {
const el = e.currentTarget;
seek(e.clientX, el);
const move = (ev: MouseEvent) => seek(ev.clientX, el);
setDocumentDrag(move, () => {});
}}
>
<rect x={0} y={0} width={W} height={H} fill="var(--color-card)" stroke="var(--color-foreground)" strokeWidth={1.5} />
{layout.rows.flatMap((row) =>
row.bars.map((bar) => {
const startMs = new Date(bar.span.start).getTime();
const endMs = bar.span.end ? new Date(bar.span.end).getTime() : layout.toMs;
const yy = pad + (rowIndex.get(row.actor.id) ?? 0) * laneH;
return (
<rect
key={`mm-${bar.span.runId}`}
x={mx(startMs)}
y={yy + 1}
width={Math.max(2, mx(endMs) - mx(startMs))}
height={Math.max(2, laneH - 2)}
fill={isCancelledStatus(bar.span.status) ? TIMELINE_COLORS.cancelled : barColor(bar)}
opacity={isCancelledStatus(bar.span.status) ? 0.5 : 1}
/>
);
}),
)}
<rect
x={brushX}
y={1}
width={brushW}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.12}
stroke="var(--color-foreground)"
strokeWidth={1.5}
onMouseDown={(e) => startRangeDrag("move", e)}
/>
<rect
data-testid="timeline-minimap-left-handle"
x={brushX - 3}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
onMouseDown={(e) => startRangeDrag("left", e)}
/>
<rect
data-testid="timeline-minimap-right-handle"
x={brushX + brushW - 3}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
onMouseDown={(e) => startRangeDrag("right", e)}
/>
</svg>
</div>
);
}

View File

@ -72,6 +72,13 @@ describe("company routes", () => {
expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts");
});
it("treats /timeline as a board route that needs a company prefix", () => {
expect(isBoardPathWithoutPrefix("/timeline")).toBe(true);
expect(extractCompanyPrefixFromPath("/timeline")).toBeNull();
expect(applyCompanyPrefix("/timeline", "PAP")).toBe("/PAP/timeline");
expect(toCompanyRelativePath("/PAP/timeline")).toBe("/timeline");
});
it("preserves artifact deep-link anchors when applying the company prefix", () => {
expect(applyCompanyPrefix("/issues/PAP-10205#work-product-wp-1", "PAP")).toBe(
"/PAP/issues/PAP-10205#work-product-wp-1",

View File

@ -24,6 +24,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"design-guide",
"search",
"settings",
"timeline",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);

View File

@ -255,6 +255,7 @@ export const queryKeys = {
["company-search", companyId, q, scope, limit, offset] as const,
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
workTimeline: (companyId: string, lens?: string) => ["work-timeline", companyId, lens ?? "all"] as const,
userProfile: (companyId: string, userSlug: string) =>
["user-profile", companyId, userSlug] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,

View File

@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout";
const DAY = "2026-07-02";
const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`;
// A trimmed version of the board's locked-design sample (PAP-12422), expressed
// in the real endpoint contract: actor→actor edges rather than run→run.
function sample(): WorkTimelineResult {
const actors = [
{ id: "user:dotta", type: "user" as const, name: "dotta" },
{ id: "agent:ceo", type: "agent" as const, name: "CEO" },
{ id: "agent:cto", type: "agent" as const, name: "CTO Architect" },
{ id: "agent:ux", type: "agent" as const, name: "UXDesigner" },
{ id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" },
{ id: "agent:codex", type: "agent" as const, name: "CodexCoder" },
{ id: "agent:qa", type: "agent" as const, name: "QA" },
{ id: "system:routine", type: "system" as const, name: "Circleback" },
];
const span = (
runId: string,
actorId: string,
issueId: string,
identifier: string,
start: string,
end: string | null,
status: string,
retryOfRunId: string | null = null,
): WorkTimelineResult["spans"][number] => ({
actorId,
laneHint: null,
runId,
issueId,
issueIdentifier: identifier,
issueTitle: `${identifier} title`,
start: t(start),
end: end ? t(end) : null,
status,
retryOfRunId,
});
const spans = [
span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"),
span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"),
span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"),
span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"),
span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"),
span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane
span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"),
span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry
span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"),
];
const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({
fromActorId: from,
toActorId: to,
issueId,
at: t(at),
kind: "delegation",
});
const edges = [
edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line
edge("agent:ceo", "agent:cto", "i-405", "09:13"),
edge("agent:cto", "agent:ux", "i-422", "10:09"),
edge("agent:cto", "agent:codex", "i-423", "10:09"),
edge("agent:senior", "agent:codex", "i-075", "10:21"),
edge("agent:codex", "agent:qa", "i-423", "11:39"),
edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed)
];
return {
actors,
spans,
events: [],
edges,
pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false },
window: { from: t("09:00"), to: t("20:15"), capped: false },
};
}
const OPTS: LayoutOptions = {
pxPerMinute: 1.7,
gutter: 172,
rowH: 34,
barH: 15,
laneGap: 4,
nowMs: new Date(t("13:00")).getTime(),
};
describe("computeLayout", () => {
it("excludes humans from rows but keeps agents and system actors", () => {
const layout = computeLayout(sample(), OPTS);
const rowActorTypes = layout.rows.map((r) => r.actor.type);
expect(rowActorTypes).not.toContain("user");
expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine");
expect(layout.rows).toHaveLength(7); // 6 agents + 1 system
});
it("orders rows by first activity", () => {
const layout = computeLayout(sample(), OPTS);
expect(layout.rows[0].actor.id).toBe("agent:ceo");
expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine");
});
it("packs overlapping runs into concurrency sub-lanes", () => {
const layout = computeLayout(sample(), OPTS);
const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!;
expect(codex.laneCount).toBe(2); // r5 and r6 overlap
const lanesTop = new Set(codex.bars.map((b) => b.yTop));
expect(lanesTop.size).toBeGreaterThan(1);
});
it("derives the kickoff actor for a run (incl. human) from edges", () => {
const layout = computeLayout(sample(), OPTS);
const codexApi = layout.rows
.find((r) => r.actor.id === "agent:codex")!
.bars.find((b) => b.span.runId === "r5")!;
expect(codexApi.kickoff?.id).toBe("agent:cto");
const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0];
expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip
});
it("prefers the nearest post-start kickoff edge when no prior edge exists", () => {
const data = sample();
data.spans.push({
actorId: "agent:ux",
laneHint: null,
runId: "late-edge-run",
issueId: "i-late",
issueIdentifier: "PAP-99999",
issueTitle: "Late kickoff fallback",
start: t("14:00"),
end: t("14:10"),
status: "completed",
retryOfRunId: null,
});
data.edges.push(
{ fromActorId: "agent:qa", toActorId: "agent:ux", issueId: "i-late", at: t("14:30"), kind: "delegation" },
{ fromActorId: "agent:cto", toActorId: "agent:ux", issueId: "i-late", at: t("14:02"), kind: "delegation" },
);
const layout = computeLayout(data, OPTS);
const lateRun = layout.rows
.find((r) => r.actor.id === "agent:ux")!
.bars.find((b) => b.span.runId === "late-edge-run")!;
expect(lateRun.kickoff?.id).toBe("agent:cto");
});
it("draws agent→agent connectors only, dashing retries, never from a human", () => {
const layout = computeLayout(sample(), OPTS);
// 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line.
expect(layout.connectors.length).toBe(6);
const dashed = layout.connectors.filter((c) => c.dashed);
expect(dashed).toHaveLength(1); // only the QA→codex retry hop
// every connector is left-to-right (source trailing edge → target leading edge)
for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1);
});
it("extends in-progress runs to now and clamps sub-minute bars", () => {
const layout = computeLayout(sample(), OPTS);
const running = layout.rows
.find((r) => r.actor.id === "agent:ux")!
.bars.find((b) => b.span.runId === "r4")!;
expect(running.running).toBe(true);
expect(running.x2).toBeGreaterThan(running.x1 + 3);
});
it("produces a deterministic issue hue + legend", () => {
const layout = computeLayout(sample(), OPTS);
expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286
expect(issueColor("i-405")).toBe(issueColor("i-405"));
});
});
describe("human activity markers", () => {
const withUserEvents = (): WorkTimelineResult => ({
...sample(),
events: [
{ actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") },
{ actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") },
{ actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") },
],
});
it("does not create a marker-only human row from instant events", () => {
const layout = computeLayout(withUserEvents(), OPTS);
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta");
expect(dotta).toBeUndefined();
});
it("does not plot instant markers on run rows", () => {
const layout = computeLayout(withUserEvents(), OPTS);
expect(layout.rows.flatMap((r) => r.markers)).toHaveLength(0);
});
it("keeps human events visual-only as kickoff chips, not connector targets", () => {
const layout = computeLayout(withUserEvents(), OPTS);
// dotta→ceo human kickoff still draws no line; agent→agent count unchanged.
expect(layout.connectors.length).toBe(6);
});
it("still excludes a human with no in-window events", () => {
const layout = computeLayout(sample(), OPTS); // events: []
expect(layout.rows.map((r) => r.actor.type)).not.toContain("user");
});
});
describe("helpers", () => {
it("shortLabel builds 2-char initials", () => {
expect(shortLabel("CodexCoder")).toBe("CO");
expect(shortLabel("CTO Architect")).toBe("CA");
});
it("chooseTickStepMs grows the step as the view zooms out", () => {
expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4));
});
});

View File

@ -0,0 +1,365 @@
/**
* Work Timeline layout pure transform from the Phase B endpoint contract
* (`WorkTimelineResult`) into a renderable view model for the custom-SVG Gantt.
*
* Ports the board-locked "Direction C" logic (PAP-12422): agent/system rows only
* (humans never get a row), overlapping runs packed into concurrency sub-lanes,
* a kickoff actor derived per run (shown as an avatar chip may be a human),
* and straight agentagent delegation connectors from a source bar's trailing
* edge to a target bar's leading edge (dashed for retries / changes-requested).
*
* Everything here is deterministic given (result, options) so it can be unit
* tested without a DOM.
*/
import type {
WorkTimelineActor,
WorkTimelineEdge,
WorkTimelineEvent,
WorkTimelineResult,
WorkTimelineSpan,
} from "@paperclipai/shared";
export interface LayoutOptions {
/** px per minute along the x axis (set by the zoom level). */
pxPerMinute: number;
/** width of the left actor gutter in px. */
gutter: number;
/** row height in px. */
rowH: number;
/** bar height in px. */
barH: number;
/** vertical gap between concurrency sub-lanes in px. */
laneGap: number;
/** wall-clock "now" in ms, used to close in-progress runs. */
nowMs: number;
}
export interface PositionedBar {
span: WorkTimelineSpan;
/** leading (start) x in px. */
x1: number;
/** trailing (end) x in px. */
x2: number;
/** vertical center of the bar in px. */
yc: number;
/** top of the bar in px. */
yTop: number;
height: number;
running: boolean;
/** the actor who kicked this run off, if resolvable (may be a human/user). */
kickoff: WorkTimelineActor | null;
}
/**
* Instant human/actor actions are deliberately not plotted in the main chart.
* The shape remains in the layout model so call sites do not need special cases,
* but rows now stay focused on actors with actual run participation.
*/
export interface PositionedMarker {
event: WorkTimelineEvent;
/** x position (px) of the marker centre = x(event.at). */
x: number;
/** vertical centre of the marker in px (row-relative, excludes axis offset). */
yc: number;
}
export interface ActorRow {
actor: WorkTimelineActor;
/** top of the row (excluding axis offset) in px. */
y: number;
/** row height in px. */
h: number;
laneCount: number;
bars: PositionedBar[];
/** reserved for instant event markers; currently empty by design. */
markers: PositionedMarker[];
/** number of runs plotted on this row (the "Signal" rail count). */
runCount: number;
/** total active run time on this row in ms (the "Signal" rail active-time). */
activeMs: number;
}
export interface Connector {
x1: number;
y1: number;
x2: number;
y2: number;
sourceRunId: string;
targetRunId: string;
/** dashed = a return / retry (changes-requested) hop. */
dashed: boolean;
}
export interface TimelineLayout {
rows: ActorRow[];
connectors: Connector[];
/** full inner width of the chart (gutter + plotted time + pad). */
width: number;
/** full height of the chart including the axis strip. */
height: number;
/** domain start (ms). */
fromMs: number;
/** domain end (ms). */
toMs: number;
gutter: number;
pxPerMinute: number;
/** ordered list of distinct issue keys present for the task hue map. */
issues: { key: string; label: string; color: string }[];
}
export const AXIS_H = 32;
const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]);
const CANCELLED_STATUSES = new Set(["cancelled", "canceled", "aborted", "skipped"]);
export function isRunningStatus(status: string): boolean {
return RUNNING_STATUSES.has(status);
}
export function isCancelledStatus(status: string): boolean {
return CANCELLED_STATUSES.has(status);
}
/**
* "Signal" encoding (PAP-12694, board-picked): colour spends on ONE meaning
* how the run started instead of a per-issue hash rainbow. Delegated runs
* (kicked off by another actor) read blue; automation/self-started runs read
* amber. The blue/amber pair is colour-blind-safe and holds contrast on both
* light and dark backgrounds so the chart screenshots cleanly. Cancelled runs
* drop their fill entirely (rendered as a hollow dashed bar) and a teal "now"
* line marks the present.
*/
export const TIMELINE_COLORS = {
delegated: "#5b9bf6",
automation: "#f4b740",
/** stroke/ink for a hollow, cancelled bar. */
cancelled: "#9aa3ad",
now: "#2dd4bf",
} as const;
export type RunSourceKind = "delegated" | "automation";
/** How a run was started: delegated (has a kickoff actor) vs. automation/self. */
export function barSourceKind(bar: PositionedBar): RunSourceKind {
return bar.kickoff ? "delegated" : "automation";
}
/** Source colour for a bar under the "Signal" encoding. */
export function barColor(bar: PositionedBar): string {
return TIMELINE_COLORS[barSourceKind(bar)];
}
export function actorType(actor: WorkTimelineActor | undefined): string {
return actor?.type ?? "system";
}
/** Deterministic, stable hue per issue that reads on both light and dark. */
export function issueColor(key: string): string {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffffff;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue} 62% 52%)`;
}
/** 2-char initials for an SVG avatar chip. */
export function shortLabel(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return (name.slice(0, 2) || "?").toUpperCase();
}
function spanStartMs(s: WorkTimelineSpan): number {
return new Date(s.start).getTime();
}
function spanEndMs(s: WorkTimelineSpan, nowMs: number): number {
const raw = s.end ? new Date(s.end).getTime() : nowMs;
return raw;
}
/**
* Resolve the kickoff actor for a run: the source of the delegation/assignment
* edge that points at this run's actor on this run's issue, closest at-or-before
* the run start (falling back to the nearest such edge). Mirrors the design's
* "avatar chip at the leading edge = who kicked it off".
*/
function resolveKickoff(
span: WorkTimelineSpan,
edges: WorkTimelineEdge[],
actorById: Map<string, WorkTimelineActor>,
): WorkTimelineActor | null {
const start = spanStartMs(span);
let best: { edge: WorkTimelineEdge; delta: number } | null = null;
for (const e of edges) {
if (e.toActorId !== span.actorId || e.issueId !== span.issueId) continue;
if (e.fromActorId === span.actorId) continue; // self-kickoff is not a delegation
const at = new Date(e.at).getTime();
// Prefer edges at-or-before the run start; otherwise smallest absolute gap.
const delta = at <= start ? start - at : (at - start) + 1e12;
if (!best || delta < best.delta) best = { edge: e, delta };
}
if (!best) return null;
return actorById.get(best.edge.fromActorId) ?? null;
}
export function computeLayout(result: WorkTimelineResult, opts: LayoutOptions): TimelineLayout {
const { pxPerMinute, gutter, rowH, barH, laneGap, nowMs } = opts;
const fromMs = new Date(result.window.from).getTime();
const toMs = new Date(result.window.to).getTime();
const actorById = new Map(result.actors.map((a) => [a.id, a]));
const x = (ms: number) => gutter + ((ms - fromMs) / 60000) * pxPerMinute;
// Rows: any actor with in-window runs gets a row. Event-only comments/creates
// do not create marker-only rows because they make the chart noisy.
const firstActivity = new Map<string, number>();
const noteActivity = (actorId: string, t: number) => {
const cur = firstActivity.get(actorId);
if (cur === undefined || t < cur) firstActivity.set(actorId, t);
};
for (const s of result.spans) noteActivity(s.actorId, spanStartMs(s));
const rowActors = result.actors
.filter((a) => firstActivity.has(a.id)) // drop actors with no run in-window
.sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!));
// Issue hue map (ordered by first appearance) for the task color map.
const issueOrder: string[] = [];
const issueLabel = new Map<string, string>();
for (const s of result.spans) {
const key = s.issueId;
if (!issueLabel.has(key)) {
issueOrder.push(key);
issueLabel.set(key, s.issueIdentifier ?? s.issueTitle ?? "issue");
}
}
const issues = issueOrder.map((key) => ({ key, label: issueLabel.get(key)!, color: issueColor(key) }));
const barIndex = new Map<string, PositionedBar>(); // runId -> bar
const rows: ActorRow[] = [];
let y = 0;
for (const actor of rowActors) {
const runs = result.spans
.filter((s) => s.actorId === actor.id)
.sort((p, q) => spanStartMs(p) - spanStartMs(q));
// Greedy pack overlapping runs into sub-lanes.
const laneEnds: number[] = [];
const laneOf = new Map<string, number>();
for (const r of runs) {
const rs = spanStartMs(r);
const re = spanEndMs(r, nowMs);
let placed = -1;
for (let ln = 0; ln < laneEnds.length; ln++) {
if (laneEnds[ln] <= rs) {
placed = ln;
break;
}
}
if (placed === -1) {
placed = laneEnds.length;
laneEnds.push(re);
} else {
laneEnds[placed] = re;
}
laneOf.set(r.runId, placed);
}
const laneCount = Math.max(1, laneEnds.length);
const h = Math.max(rowH, laneCount * (barH + laneGap) + 8);
const bars: PositionedBar[] = runs.map((r) => {
const lane = laneOf.get(r.runId) ?? 0;
const laneTop = y + 6 + lane * (barH + laneGap);
const x1 = x(spanStartMs(r));
const x2raw = x(spanEndMs(r, nowMs));
const x2 = Math.max(x1 + 3, x2raw); // clamp sub-minute runs to a visible min width
const bar: PositionedBar = {
span: r,
x1,
x2,
yTop: laneTop,
yc: laneTop + barH / 2,
height: barH,
running: isRunningStatus(r.status),
kickoff: resolveKickoff(r, result.edges, actorById),
};
barIndex.set(r.runId, bar);
return bar;
});
const markers: PositionedMarker[] = [];
const activeMs = runs.reduce((sum, r) => sum + Math.max(0, spanEndMs(r, nowMs) - spanStartMs(r)), 0);
rows.push({ actor, y, h, laneCount, bars, markers, runCount: runs.length, activeMs });
y += h;
}
// Connectors: straight agent→agent lines, connected at both ends. For each bar
// with an agent/system kickoff, connect from the kickoff actor's nearest
// preceding bar (same issue preferred) to this bar's leading edge.
const connectors: Connector[] = [];
for (const row of rows) {
for (const bar of row.bars) {
const k = bar.kickoff;
if (!k || k.type === "user") continue; // agent→agent only; humans stay as chips
const source = nearestSourceBar(k.id, bar, barIndex, nowMs);
if (!source) continue;
connectors.push({
x1: source.x2,
y1: source.yc,
x2: bar.x1,
y2: bar.yc,
sourceRunId: source.span.runId,
targetRunId: bar.span.runId,
dashed: Boolean(bar.span.retryOfRunId),
});
}
}
const width = gutter + ((toMs - fromMs) / 60000) * pxPerMinute + 40;
const height = y + AXIS_H;
return { rows, connectors, width, height, fromMs, toMs, gutter, pxPerMinute, issues };
}
/** The kickoff actor's bar that best precedes `target` (same issue preferred). */
function nearestSourceBar(
kickoffActorId: string,
target: PositionedBar,
barIndex: Map<string, PositionedBar>,
nowMs: number,
): PositionedBar | null {
const targetStart = spanStartMs(target.span);
let sameIssue: PositionedBar | null = null;
let anyIssue: PositionedBar | null = null;
for (const bar of barIndex.values()) {
if (bar.span.actorId !== kickoffActorId) continue;
if (bar === target) continue;
const end = spanEndMs(bar.span, nowMs);
if (end > targetStart + 1) continue; // must precede (small tolerance)
if (bar.span.issueId === target.span.issueId) {
if (!sameIssue || spanEndMs(sameIssue.span, nowMs) < end) sameIssue = bar;
}
if (!anyIssue || spanEndMs(anyIssue.span, nowMs) < end) anyIssue = bar;
}
return sameIssue ?? anyIssue;
}
/** Choose a "nice" gridline step (ms) targeting ~120px between labels. */
export function chooseTickStepMs(pxPerMinute: number): number {
const targetPx = 120;
const minutesPerTarget = targetPx / pxPerMinute;
const steps = [15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080]; // minutes
for (const m of steps) {
if (m >= minutesPerTarget) return m * 60000;
}
return steps[steps.length - 1] * 60000;
}
export function formatDuration(startMs: number, endMs: number): string {
const mins = Math.max(0, Math.round((endMs - startMs) / 60000));
if (mins >= 1440) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`;
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
return `${mins}m`;
}

View File

@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Timeline } from "./Timeline";
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockWorkTimelineApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("@/api/workTimeline", () => ({
workTimelineApi: mockWorkTimelineApi,
}));
vi.mock("@/components/RequestCollapsedSidebar", () => ({
RequestCollapsedSidebar: () => <div data-testid="request-collapsed-sidebar" />,
}));
const emptyTimeline: WorkTimelineResult = {
actors: [],
spans: [],
events: [],
edges: [],
pagination: {
limit: 100,
offset: 0,
totalIssues: 0,
hasMore: false,
},
window: {
from: "2026-07-01T00:00:00.000Z",
to: "2026-07-07T23:59:59.999Z",
capped: false,
},
};
async function flushReact() {
for (let index = 0; index < 3; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("Timeline", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockWorkTimelineApi.get.mockResolvedValue(emptyTimeline);
});
afterEach(() => {
if (root) {
flushSync(() => root?.unmount());
}
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("requests the collapsed app sidebar by default", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-testid="request-collapsed-sidebar"]')).not.toBeNull();
});
it("renders range controls plus icon zoom controls without the user lens selector or visible-duration readout", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Range");
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Reset zoom"]')).not.toBeNull();
expect(container.textContent).not.toContain("Everyone");
expect(container.textContent).not.toContain("work kicked off");
expect(container.textContent).not.toContain("visible");
});
it("requests the company timeline without a user lens parameter", async () => {
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockWorkTimelineApi.get).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
from: expect.any(String),
to: expect.any(String),
}),
);
expect(mockWorkTimelineApi.get.mock.calls[0]?.[1]).not.toHaveProperty("userId");
});
});

318
ui/src/pages/Timeline.tsx Normal file
View File

@ -0,0 +1,318 @@
/**
* Work Timeline page (PAP-12424 / Phase C of PAP-12405).
*
* A Gantt-style view of company actor activity built on the Phase B endpoint
* (`GET /companies/:companyId/timeline`). Rendering is the board-locked
* Direction C (PAP-12422): dense rows, mini-map brush, custom inline SVG.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react";
import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { EmptyState } from "@/components/EmptyState";
import { PageSkeleton } from "@/components/PageSkeleton";
import { RequestCollapsedSidebar } from "@/components/RequestCollapsedSidebar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
WorkTimelineChart,
clampZoomScale,
defaultZoomForWindow,
nearestZoomForScale,
type ZoomLevel,
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { TIMELINE_COLORS } from "@/lib/timeline/layout";
import { cn } from "@/lib/utils";
type RangePreset = "today" | "7d" | "30d" | "custom";
interface DateRangeState {
fromDate: string;
toDate: string;
}
function dateInputValue(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function presetRange(preset: Exclude<RangePreset, "custom">, now = new Date()): DateRangeState {
const from = new Date(now);
const to = new Date(now);
if (preset === "today") {
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
} else {
from.setDate(from.getDate() - (preset === "7d" ? 6 : 29));
}
return { fromDate: dateInputValue(from), toDate: dateInputValue(to) };
}
function rangeWindow(range: DateRangeState): Pick<WorkTimelineParams, "from" | "to"> | null {
if (!range.fromDate || !range.toDate) return null;
const from = new Date(`${range.fromDate}T00:00:00`);
const to = new Date(`${range.toDate}T23:59:59.999`);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from > to) return null;
return { from: from.toISOString(), to: to.toISOString() };
}
function rangeError(range: DateRangeState): string | null {
if (!range.fromDate || !range.toDate) return "Choose a start and end date.";
if (!rangeWindow(range)) return "Start date must be before end date.";
return null;
}
function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className="inline-flex overflow-hidden rounded-md border border-border">
{options.map((opt, i) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"px-3 py-1.5 text-xs transition-colors",
i > 0 && "border-l border-border",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-card text-foreground hover:bg-muted",
)}
>
{opt.label}
</button>
))}
</div>
);
}
/** Encoding key for the "Signal" timeline: colour = how each run started. */
function TimelineLegend() {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3.5 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.delegated }} />
Delegated
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm" style={{ backgroundColor: TIMELINE_COLORS.automation }} />
Automation
</span>
<span className="flex items-center gap-1.5">
<span
className="h-2.5 w-4 rounded-sm border border-dashed bg-transparent"
style={{ borderColor: TIMELINE_COLORS.cancelled }}
/>
Cancelled
</span>
<span className="flex items-center gap-1.5">
<span className="h-3.5 w-0.5" style={{ backgroundColor: TIMELINE_COLORS.now }} />
Now
</span>
</div>
);
}
export function Timeline() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [zoom, setZoom] = useState<ZoomLevel>("day");
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
const zoomTouched = useRef(false);
const [rangePreset, setRangePreset] = useState<RangePreset>("7d");
const [dateRange, setDateRange] = useState<DateRangeState>(() => presetRange("7d"));
useEffect(() => {
setBreadcrumbs([{ label: "Timeline" }]);
}, [setBreadcrumbs]);
const dateRangeError = rangeError(dateRange);
const params: WorkTimelineParams | null = useMemo(() => {
const window = rangeWindow(dateRange);
if (!window) return null;
return window;
}, [dateRange]);
const { data, isLoading, error } = useQuery({
queryKey: [...queryKeys.workTimeline(selectedCompanyId ?? ""), dateRange.fromDate, dateRange.toDate],
queryFn: () => workTimelineApi.get(selectedCompanyId!, params!),
enabled: !!selectedCompanyId && !!params,
});
useEffect(() => {
if (!data || zoomTouched.current) return;
const defaultZoom = defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime());
setZoom(defaultZoom);
setZoomScale(undefined);
}, [data]);
if (!selectedCompanyId) {
return (
<>
<RequestCollapsedSidebar />
<EmptyState icon={GanttChartSquare} message="Select a company to view its work timeline." />
</>
);
}
const header = (
<div className="flex items-center gap-2">
<GanttChartSquare className="h-6 w-6 text-muted-foreground" />
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
</div>
);
const adjustZoom = (factor: number) => {
zoomTouched.current = true;
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
setZoomScale(nextScale);
setZoom(nearestZoomForScale(nextScale));
};
const resetZoom = () => {
zoomTouched.current = true;
if (data) {
setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime()));
} else {
setZoom("day");
}
setZoomScale(undefined);
};
const toolbar = (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
Range
<Segmented
value={rangePreset}
onChange={(preset) => {
if (preset === "custom") return;
setRangePreset(preset);
setDateRange(presetRange(preset));
}}
options={[
{ value: "today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
]}
/>
<Input
type="date"
value={dateRange.fromDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, fromDate: event.target.value }));
}}
className="h-8 w-[150px] text-xs"
aria-label="Timeline start date"
/>
<span>to</span>
<Input
type="date"
value={dateRange.toDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, toDate: event.target.value }));
}}
className="h-8 w-[150px] text-xs"
aria-label="Timeline end date"
/>
</label>
<div className="ml-auto flex items-center gap-1" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(0.8)}
aria-label="Zoom out"
title="Zoom out"
>
<Minus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(1.25)}
aria-label="Zoom in"
title="Zoom in"
>
<Plus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={resetZoom}
aria-label="Reset zoom"
title="Reset zoom"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
);
return (
<div className="space-y-6">
<RequestCollapsedSidebar />
{header}
{toolbar}
{isLoading && <PageSkeleton />}
{dateRangeError && (
<EmptyState
icon={GanttChartSquare}
message={dateRangeError}
/>
)}
{error && (
<EmptyState
icon={GanttChartSquare}
message="Couldn't load the timeline. The aggregation endpoint may be unavailable."
/>
)}
{data && !isLoading && !dateRangeError && (
data.spans.length === 0 ? (
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
) : (
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card">
<TimelineLegend />
<WorkTimelineChart
data={data}
zoom={zoom}
zoomScale={zoomScale}
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
zoomTouched.current = true;
setZoomScale(nextScale);
setZoom(nextZoom);
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "}
{new Date(data.window.from).toLocaleString()} {new Date(data.window.to).toLocaleString()}
{data.window.capped ? " · window capped" : ""}
</p>
</div>
)
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,129 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { Minus, Plus, RotateCcw } from "lucide-react";
import {
WorkTimelineChart,
clampZoomScale,
nearestZoomForScale,
type ZoomLevel,
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { Button } from "@/components/ui/button";
import sampleJson from "../fixtures/workTimeline.sample.json";
import humanSampleJson from "../fixtures/workTimeline.human.sample.json";
const sample = sampleJson as unknown as WorkTimelineResult;
// A second real slice (2026-07-02 14:0016:00Z) captured straight from the live
// `/timeline` endpoint that DOES carry human events — Dotta's created / commented /
// approved / delegated actions provide human participation and kickoff context.
const humanSample = humanSampleJson as unknown as WorkTimelineResult;
// The fixture is a real slice of PAP company activity (2026-07-02 14:0015:50Z);
// pin "now" to the window end so in-progress runs fade correctly.
const NOW = new Date("2026-07-02T15:45:00.000Z").getTime();
function TimelineHarness({
initialZoom = "day" as ZoomLevel,
data = sample,
now = NOW,
}: {
initialZoom?: ZoomLevel;
data?: WorkTimelineResult;
now?: number;
}) {
const [zoom, setZoom] = useState<ZoomLevel>(initialZoom);
const [zoomScale, setZoomScale] = useState<number | undefined>(undefined);
const adjustZoom = (factor: number) => {
const nextScale = clampZoomScale((zoomScale ?? zoomScaleForLevel(zoom)) * factor);
setZoomScale(nextScale);
setZoom(nearestZoomForScale(nextScale));
};
const resetZoom = () => {
setZoom(initialZoom);
setZoomScale(undefined);
};
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<div className="space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Work Timeline</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-1" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(0.8)}
aria-label="Zoom out"
title="Zoom out"
>
<Minus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={() => adjustZoom(1.25)}
aria-label="Zoom in"
title="Zoom in"
>
<Plus className="h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="icon-xs"
onClick={resetZoom}
aria-label="Reset zoom"
title="Reset zoom"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card">
<WorkTimelineChart
data={data}
zoom={zoom}
zoomScale={zoomScale}
nowMs={now}
onZoomScaleChange={(nextScale, nextZoom = nearestZoomForScale(nextScale)) => {
setZoomScale(nextScale);
setZoom(nextZoom);
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{data.spans.length} runs · {data.actors.length} actors · {data.events.length} human/instant events · real
company data
</p>
</div>
</div>
</div>
);
}
const meta: Meta<typeof TimelineHarness> = {
title: "Pages/Work Timeline",
component: TimelineHarness,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof TimelineHarness>;
export const HourZoom: Story = { args: { initialZoom: "hour" } };
export const DayZoom: Story = { args: { initialZoom: "day" } };
// Live slice that carries human-originated activity and delegation context.
export const WithHumanActivity: Story = {
args: {
initialZoom: "hour",
data: humanSample,
now: new Date("2026-07-02T16:00:00.000Z").getTime(),
},
};