[codex] remove Work Timeline page from navigation (#8882)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The app shell exposes user-visible product surfaces through routes and the left navigation > - The Work Timeline frontend landed on `master` in related PR #8880 before the product surface was ready for general users > - The backend aggregation endpoint and shared DTOs can remain available for continued iteration without exposing the page in the main app > - This pull request removes the visible `/timeline` route, sidebar entry, page implementation, chart component, and Storybook fixtures > - The benefit is that users no longer see an unfinished Work Timeline page on `master`, while future work can continue from a preserved branch/workspace ## Linked Issues or Issue Description No public GitHub issue exists for this rollback request. Bug-style description: - **What happened:** A Work Timeline page was exposed in the app navigation before the surface was ready for users. - **Expected behavior:** Unready product work should not be visible from the default app shell on `master`. - **Steps to reproduce:** Open the app on current `master`; the sidebar includes a `Timeline` item that routes to `/timeline`. - **Paperclip version/commit:** `60f7fb422394c94618b8eef27ae032702004f544`. - **Deployment mode:** Local app / standard Paperclip app shell. - **Related public PR:** #8880. ## What Changed - Removed the `/timeline` route and `Timeline` page import from the app route table. - Removed the `Timeline` sidebar item and unused `GanttChartSquare` icon import. - Deleted the frontend Work Timeline API wrapper, chart component, layout helper, page, Storybook story, and sample fixtures. - Removed the now-unused `queryKeys.workTimeline` entry. - Left the backend timeline endpoint and shared DTOs intact so the data contract can continue to be developed off the preserved work. ## Verification - `pnpm --filter @paperclipai/ui typecheck` - Searched the frontend for stale `workTimeline`, `WorkTimeline`, `/timeline` route/sidebar, and `GanttChartSquare` references after deletion. ## Risks - Low runtime risk: this removes an app route and navigation entry for an unfinished surface. - Deep links to `/timeline` will now fall through to the app's existing not-found behavior. - The backend endpoint remains available; if the intent was to remove the API too, that should be handled in a separate, explicit PR. ## Model Used OpenAI Codex coding agent, GPT-5-based model, with shell/tool use for repository inspection, code editing, git, and GitHub CLI operations. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
69c55d465d
commit
ec92728536
|
|
@ -8,7 +8,6 @@ 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,7 +79,6 @@ 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 />} />
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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)}`),
|
||||
};
|
||||
|
|
@ -18,7 +18,6 @@ import {
|
|||
PanelLeftOpen,
|
||||
Pin,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
|
|
@ -181,7 +180,6 @@ export function Sidebar() {
|
|||
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
|
||||
) : null}
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
||||
{showWorkspacesLink ? (
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
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";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
function renderChart(data: WorkTimelineResult) {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<WorkTimelineChart
|
||||
data={data}
|
||||
zoom="hour"
|
||||
colorMode="issue"
|
||||
nowMs={new Date("2026-07-02T12:00:00.000Z").getTime()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 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("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("renders a human row with diamond event markers when the payload carries 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);
|
||||
|
||||
// Dotta gets a row in the gutter…
|
||||
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
|
||||
expect(gutter?.textContent).toContain("Dotta");
|
||||
// …and her three instant events render as clickable diamond marker paths.
|
||||
const markers = container.querySelectorAll("svg.absolute path.cursor-pointer");
|
||||
expect(markers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
/**
|
||||
* 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),
|
||||
* kickoff avatar chips at each bar's leading edge (incl. humans), straight
|
||||
* agent→agent 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 { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import type {
|
||||
TimelineEventKind,
|
||||
WorkTimelineActor,
|
||||
WorkTimelineResult,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
AXIS_H,
|
||||
actorType,
|
||||
chooseTickStepMs,
|
||||
computeLayout,
|
||||
formatDuration,
|
||||
issueColor,
|
||||
shortLabel,
|
||||
type ColorMode,
|
||||
type LayoutOptions,
|
||||
type PositionedBar,
|
||||
type PositionedMarker,
|
||||
} from "@/lib/timeline/layout";
|
||||
|
||||
export type ZoomLevel = "hour" | "day" | "week";
|
||||
|
||||
const ZOOM_PX_PER_MIN: Record<ZoomLevel, number> = {
|
||||
hour: 8,
|
||||
day: 1.6,
|
||||
week: 0.32,
|
||||
};
|
||||
|
||||
/** 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;
|
||||
const MARKER_R = 5.5;
|
||||
|
||||
/**
|
||||
* Per-kind styling for instant event markers (diamonds). Each kind gets a
|
||||
* distinct fill + verb so created / commented / approved / delegated / assigned
|
||||
* read apart at a glance; the hues sit mid-lightness so they hold on light+dark.
|
||||
*/
|
||||
const EVENT_STYLE: Record<TimelineEventKind, { fill: string; verb: string }> = {
|
||||
created: { fill: "hsl(145 55% 42%)", verb: "created" },
|
||||
commented: { fill: "hsl(212 62% 54%)", verb: "commented on" },
|
||||
approved: { fill: "hsl(265 52% 60%)", verb: "approved" },
|
||||
delegated: { fill: "hsl(28 78% 52%)", verb: "delegated" },
|
||||
assigned: { fill: "hsl(190 58% 44%)", verb: "assigned" },
|
||||
};
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
bar: PositionedBar;
|
||||
}
|
||||
|
||||
interface MarkerTooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
marker: PositionedMarker;
|
||||
/** resolved issue label (identifier/title) or the raw id as a fallback. */
|
||||
issueLabel: string;
|
||||
}
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtTick(ms: number, stepMs: number): string {
|
||||
const d = new Date(ms);
|
||||
if (stepMs >= 24 * 60 * 60 * 1000) {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
return fmtClock(ms);
|
||||
}
|
||||
|
||||
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;
|
||||
colorMode: ColorMode;
|
||||
/** override "now" (tests / stories); defaults to Date.now(). */
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimelineChartProps) {
|
||||
const navigate = useNavigate();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [markerTooltip, setMarkerTooltip] = useState<MarkerTooltipState | null>(null);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [viewportW, setViewportW] = useState(0);
|
||||
|
||||
const now = nowMs ?? Date.now();
|
||||
const layout = useMemo(
|
||||
() => computeLayout(data, { ...GEOM, pxPerMinute: ZOOM_PX_PER_MIN[zoom], nowMs: now }),
|
||||
[data, zoom, now],
|
||||
);
|
||||
|
||||
// Resolve an event's issue to its human label (identifier/title) via the legend
|
||||
// hue map, falling back to the raw id for issues that have no run in-window.
|
||||
const issueLabelById = useMemo(
|
||||
() => new Map(layout.issues.map((i) => [i.key, i.label])),
|
||||
[layout.issues],
|
||||
);
|
||||
|
||||
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 barFill = (bar: PositionedBar): string => {
|
||||
if (colorMode === "status") {
|
||||
if (bar.running) return "url(#tl-hatchV)";
|
||||
if (bar.span.status.includes("change") || bar.span.status.includes("fail") || bar.span.status === "blocked")
|
||||
return "url(#tl-hatchD)";
|
||||
return "var(--color-card)";
|
||||
}
|
||||
return "var(--color-card)";
|
||||
};
|
||||
|
||||
const openIssue = (issueId: string) => navigate(`/issues/${issueId}`);
|
||||
|
||||
const showTooltip = (evt: React.MouseEvent, bar: PositionedBar) => {
|
||||
setTooltip({ x: evt.clientX, y: evt.clientY, bar });
|
||||
};
|
||||
|
||||
const showMarkerTooltip = (evt: React.MouseEvent, marker: PositionedMarker) => {
|
||||
const issueLabel = issueLabelById.get(marker.event.issueId) ?? marker.event.issueId;
|
||||
setMarkerTooltip({ x: evt.clientX, y: evt.clientY, marker, issueLabel });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto overflow-y-hidden"
|
||||
data-testid="work-timeline-scroll"
|
||||
onScroll={(e) => setScrollLeft(e.currentTarget.scrollLeft)}
|
||||
>
|
||||
<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"
|
||||
ref={(el) => {
|
||||
if (el && viewportW === 0 && scrollRef.current) setViewportW(scrollRef.current.clientWidth);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="tl-hatchV" width={5} height={6} patternUnits="userSpaceOnUse">
|
||||
<rect width={5} height={6} fill="var(--color-card)" />
|
||||
<line x1={0} y1={0} x2={0} y2={6} stroke="var(--color-foreground)" strokeWidth={2} />
|
||||
</pattern>
|
||||
<pattern id="tl-hatchD" width={6} height={6} patternUnits="userSpaceOnUse">
|
||||
<rect width={6} height={6} fill="var(--color-card)" />
|
||||
<path d="M0,6 l6,-6" stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
</pattern>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* gridlines + time labels */}
|
||||
{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} />
|
||||
<text x={gx + 3} y={14} fontSize={11} fill="var(--color-muted-foreground)">
|
||||
{fmtTick(ms, stepMs)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* now line */}
|
||||
{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="var(--color-primary)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 3"
|
||||
opacity={0.7}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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) */}
|
||||
{layout.connectors.map((c, i) => {
|
||||
const ang = (Math.atan2(c.y2 - c.y1, c.x2 - c.x1) * 180) / Math.PI;
|
||||
return (
|
||||
<g key={`edge-${i}`} opacity={0.55}>
|
||||
<line
|
||||
x1={c.x1}
|
||||
y1={c.y1 + AXIS_H}
|
||||
x2={c.x2}
|
||||
y2={c.y2 + AXIS_H}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={1.6}
|
||||
strokeDasharray={c.dashed ? "5 4" : undefined}
|
||||
/>
|
||||
<circle cx={c.x1} cy={c.y1 + AXIS_H} r={2.2} fill="var(--color-foreground)" />
|
||||
<path
|
||||
d={`M${c.x2},${c.y2 + AXIS_H} l-8,-4 l0,8 z`}
|
||||
fill="var(--color-foreground)"
|
||||
transform={`rotate(${ang} ${c.x2} ${c.y2 + AXIS_H})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* rows: gutter avatar/label, lane baselines, bars, 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 hue = issueColor(bar.span.issueId);
|
||||
return (
|
||||
<g key={bar.span.runId}>
|
||||
<g
|
||||
className="cursor-pointer"
|
||||
onMouseMove={(e) => showTooltip(e, bar)}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onClick={() => openIssue(bar.span.issueId)}
|
||||
>
|
||||
<rect
|
||||
x={bar.x1}
|
||||
y={yTop}
|
||||
width={w}
|
||||
height={bar.height}
|
||||
rx={3}
|
||||
fill={barFill(bar)}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{/* left colour tab = issue identity (no textual ID on the bar) */}
|
||||
<rect x={bar.x1} y={yTop} width={3.5} height={bar.height} fill={hue} />
|
||||
{/* in-progress fade to "now" */}
|
||||
{bar.running && 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 && (
|
||||
<g className="pointer-events-none">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* instant event markers — diamonds at x(event.at) on this row */}
|
||||
{row.markers.map((marker) => {
|
||||
const style = EVENT_STYLE[marker.event.kind];
|
||||
const mx = marker.x;
|
||||
const my = marker.yc + AXIS_H;
|
||||
return (
|
||||
<path
|
||||
key={`ev-${row.actor.id}-${marker.event.kind}-${marker.event.issueId}-${marker.event.at}`}
|
||||
className="cursor-pointer"
|
||||
d={`M ${mx} ${my - MARKER_R} L ${mx + MARKER_R} ${my} L ${mx} ${my + MARKER_R} L ${mx - MARKER_R} ${my} Z`}
|
||||
fill={style?.fill ?? "var(--color-primary)"}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={1.2}
|
||||
onMouseMove={(e) => showMarkerTooltip(e, marker)}
|
||||
onMouseLeave={() => setMarkerTooltip(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MiniMap layout={layout} scrollRef={scrollRef} viewportW={viewportW} scrollLeft={scrollLeft} />
|
||||
|
||||
{tooltip && <Tooltip tooltip={tooltip} now={now} />}
|
||||
{markerTooltip && <MarkerTooltip tooltip={markerTooltip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerTooltip({ tooltip }: { tooltip: MarkerTooltipState }) {
|
||||
const { marker, issueLabel } = tooltip;
|
||||
const style = EVENT_STYLE[marker.event.kind];
|
||||
const atMs = new Date(marker.event.at).getTime();
|
||||
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="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rotate-45 border border-foreground"
|
||||
style={{ backgroundColor: style?.fill ?? "var(--color-primary)" }}
|
||||
/>
|
||||
<span className="capitalize">{style?.verb ?? marker.event.kind}</span>
|
||||
<span className="font-normal text-muted-foreground">{truncate(issueLabel, 28)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{fmtClock(atMs)}</div>
|
||||
</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 top-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, 18)}
|
||||
</text>
|
||||
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
|
||||
{row.actor.type}
|
||||
</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 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>
|
||||
)}
|
||||
<div className="mt-1 text-foreground">click → open task</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMap({
|
||||
layout,
|
||||
scrollRef,
|
||||
viewportW,
|
||||
scrollLeft,
|
||||
}: {
|
||||
layout: ReturnType<typeof computeLayout>;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewportW: number;
|
||||
scrollLeft: number;
|
||||
}) {
|
||||
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 frac = layout.width > 0 ? (viewportW || W) / layout.width : 1;
|
||||
const brushW = Math.max(24, Math.min(1, frac) * (W - 2 * pad));
|
||||
const brushX = pad + (layout.width > 0 ? scrollLeft / layout.width : 0) * (W - 2 * pad);
|
||||
|
||||
const seek = (clientX: number, el: SVGSVGElement) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const f = Math.min(1, Math.max(0, (clientX - rect.left - pad) / (W - 2 * pad)));
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = f * layout.width - scrollRef.current.clientWidth / 2;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
const up = () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
};
|
||||
document.addEventListener("mousemove", move);
|
||||
document.addEventListener("mouseup", up);
|
||||
}}
|
||||
>
|
||||
<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={issueColor(bar.span.issueId)}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
<rect
|
||||
x={brushX}
|
||||
y={1}
|
||||
width={brushW}
|
||||
height={H - 2}
|
||||
fill="var(--color-foreground)"
|
||||
opacity={0.12}
|
||||
stroke="var(--color-foreground)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -251,7 +251,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
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("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("gives a human actor a row driven purely by their in-window events", () => {
|
||||
const layout = computeLayout(withUserEvents(), OPTS);
|
||||
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta");
|
||||
expect(dotta).toBeDefined();
|
||||
expect(dotta!.actor.type).toBe("user");
|
||||
expect(dotta!.bars).toHaveLength(0); // humans have no runs
|
||||
});
|
||||
|
||||
it("positions instant markers at x(event.at) on the owning row, time-ordered", () => {
|
||||
const layout = computeLayout(withUserEvents(), OPTS);
|
||||
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta")!;
|
||||
expect(dotta.markers).toHaveLength(3);
|
||||
expect(dotta.markers.map((m) => m.event.kind)).toEqual(["created", "commented", "approved"]);
|
||||
const xs = dotta.markers.map((m) => m.x);
|
||||
expect(xs).toEqual([...xs].sort((a, b) => a - b)); // monotonic in time
|
||||
for (const m of dotta.markers) expect(m.yc).toBeCloseTo(dotta.y + dotta.h / 2);
|
||||
});
|
||||
|
||||
it("keeps humans marker-only: no run bars, no connectors target them", () => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
/**
|
||||
* 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 agent→agent 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 type ColorMode = "issue" | "status";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* An instant human/actor action (issue created, comment, approval, delegation,
|
||||
* assignment) — plotted as a diamond marker at its timestamp on the actor's row.
|
||||
* Humans have no "runs", so these markers are their only presence on the chart.
|
||||
*/
|
||||
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[];
|
||||
/** instant event markers (issue created / comment / approval / …) on this row. */
|
||||
markers: PositionedMarker[];
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
/** 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 legend + hue map. */
|
||||
issues: { key: string; label: string; color: string }[];
|
||||
}
|
||||
|
||||
export const AXIS_H = 22;
|
||||
const RUNNING_STATUSES = new Set(["running", "in_progress", "queued", "pending"]);
|
||||
|
||||
export function isRunningStatus(status: string): boolean {
|
||||
return RUNNING_STATUSES.has(status);
|
||||
}
|
||||
|
||||
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 : (start - at) + 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 activity gets a row — agents/system via their
|
||||
// runs (spans), and humans (type "user") via their instant events, since humans
|
||||
// have no "runs" and would otherwise never appear. Order by first activity so the
|
||||
// eye follows the delegation chain top-to-bottom.
|
||||
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));
|
||||
for (const e of result.events) noteActivity(e.actorId, new Date(e.at).getTime());
|
||||
|
||||
// Instant events grouped by the actor who performed them.
|
||||
const eventsByActor = new Map<string, WorkTimelineEvent[]>();
|
||||
for (const e of result.events) {
|
||||
const arr = eventsByActor.get(e.actorId);
|
||||
if (arr) arr.push(e);
|
||||
else eventsByActor.set(e.actorId, [e]);
|
||||
}
|
||||
|
||||
const rowActors = result.actors
|
||||
.filter((a) => firstActivity.has(a.id)) // drop idle actors with no run/event in-window
|
||||
.sort((a, b) => (firstActivity.get(a.id)! - firstActivity.get(b.id)!));
|
||||
|
||||
// Issue hue map (ordered by first appearance) for the "by issue" color mode + legend.
|
||||
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;
|
||||
});
|
||||
|
||||
// Instant markers for this actor, positioned at their timestamp on this row.
|
||||
const markers: PositionedMarker[] = (eventsByActor.get(actor.id) ?? [])
|
||||
.slice()
|
||||
.sort((p, q) => new Date(p.at).getTime() - new Date(q.at).getTime())
|
||||
.map((event) => ({
|
||||
event,
|
||||
x: x(new Date(event.at).getTime()),
|
||||
yc: y + h / 2,
|
||||
}));
|
||||
|
||||
rows.push({ actor, y, h, laneCount, bars, markers });
|
||||
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,
|
||||
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`;
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
/**
|
||||
* 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 } from "lucide-react";
|
||||
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { WorkTimelineChart, defaultZoomForWindow, type ZoomLevel } from "@/components/timeline/WorkTimelineChart";
|
||||
import { issueColor, type ColorMode } from "@/lib/timeline/layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EVERYONE = "__everyone__";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [zoom, setZoom] = useState<ZoomLevel>("day");
|
||||
const zoomTouched = useRef(false);
|
||||
const setZoomManual = (z: ZoomLevel) => {
|
||||
zoomTouched.current = true;
|
||||
setZoom(z);
|
||||
};
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("issue");
|
||||
const [lensUserId, setLensUserId] = useState<string>(EVERYONE);
|
||||
// Union of users discovered across fetches so the lens list stays stable.
|
||||
const [knownUsers, setKnownUsers] = useState<WorkTimelineActor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Timeline" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const params: WorkTimelineParams = useMemo(
|
||||
() => (lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }),
|
||||
[lensUserId],
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId),
|
||||
queryFn: () => workTimelineApi.get(selectedCompanyId!, params),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || zoomTouched.current) return;
|
||||
setZoom(defaultZoomForWindow(new Date(data.window.from).getTime(), new Date(data.window.to).getTime()));
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setKnownUsers((prev) => {
|
||||
const byId = new Map(prev.map((u) => [u.id, u]));
|
||||
for (const a of data.actors) if (a.type === "user") byId.set(a.id, a);
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={GanttChartSquare} message="Select a company to view its work timeline." />;
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
A Gantt view of who did what, when. Rows are actors; bars are heartbeat runs colored by task;
|
||||
the avatar chip at a bar's leading edge is who kicked it off; straight lines are agent→agent
|
||||
delegation. Hover a bar for its task & timing; click to open the task.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Zoom
|
||||
<Segmented
|
||||
value={zoom}
|
||||
onChange={setZoomManual}
|
||||
options={[
|
||||
{ value: "hour", label: "Hour" },
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Report for
|
||||
<Select value={lensUserId} onValueChange={setLensUserId}>
|
||||
<SelectTrigger className="h-8 w-[220px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={EVERYONE}>Everyone (company)</SelectItem>
|
||||
{knownUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} — work kicked off
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Color
|
||||
<Segmented
|
||||
value={colorMode}
|
||||
onChange={setColorMode}
|
||||
options={[
|
||||
{ value: "issue", label: "By task" },
|
||||
{ value: "status", label: "By status" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{header}
|
||||
{toolbar}
|
||||
|
||||
{isLoading && <PageSkeleton />}
|
||||
|
||||
{error && (
|
||||
<EmptyState
|
||||
icon={GanttChartSquare}
|
||||
message="Couldn't load the timeline. The aggregation endpoint may be unavailable."
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && !isLoading && (
|
||||
data.spans.length === 0 ? (
|
||||
<EmptyState icon={GanttChartSquare} message="No activity in this window for the selected lens." />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Legend data={data} colorMode={colorMode} />
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<WorkTimelineChart data={data} zoom={zoom} colorMode={colorMode} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: ColorMode }) {
|
||||
if (colorMode === "status") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-4 border border-foreground bg-card" /> done
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-4 border border-foreground"
|
||||
style={{ background: "repeating-linear-gradient(90deg, var(--color-foreground) 0 2px, transparent 2px 5px)" }}
|
||||
/>{" "}
|
||||
in progress
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-4 border border-foreground"
|
||||
style={{ background: "repeating-linear-gradient(45deg, var(--color-foreground) 0 2px, transparent 2px 6px)" }}
|
||||
/>{" "}
|
||||
changes/blocked
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const issues = Array.from(
|
||||
new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(),
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
{issues.slice(0, 12).map(([id, label]) => (
|
||||
<span key={id} className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-4 border border-foreground" style={{ borderLeft: `4px solid ${issueColor(id)}` }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{issues.length > 12 && <span>+{issues.length - 12} more</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -1,164 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { WorkTimelineChart, type ZoomLevel } from "@/components/timeline/WorkTimelineChart";
|
||||
import { issueColor, type ColorMode } from "@/lib/timeline/layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
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:00–16:00Z) captured straight from the live
|
||||
// `/timeline` endpoint that DOES carry human events — Dotta's created / commented /
|
||||
// approved / delegated actions render as instant diamond markers on her own row.
|
||||
const humanSample = humanSampleJson as unknown as WorkTimelineResult;
|
||||
// The fixture is a real slice of PAP company activity (2026-07-02 14:00–15: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 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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineHarness({
|
||||
initialZoom = "day" as ZoomLevel,
|
||||
initialColor = "issue" as ColorMode,
|
||||
data = sample,
|
||||
now = NOW,
|
||||
}: {
|
||||
initialZoom?: ZoomLevel;
|
||||
initialColor?: ColorMode;
|
||||
data?: WorkTimelineResult;
|
||||
now?: number;
|
||||
}) {
|
||||
const [zoom, setZoom] = useState<ZoomLevel>(initialZoom);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>(initialColor);
|
||||
const issues = Array.from(
|
||||
new Map(data.spans.map((s) => [s.issueId, s.issueIdentifier ?? s.issueTitle ?? "task"])).entries(),
|
||||
);
|
||||
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>
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
A Gantt view of who did what, when — real PAP activity (2026-07-02, 14:00–15:50Z). Rows are actors; bars are
|
||||
heartbeat runs colored by task; the avatar chip at a bar's leading edge is who kicked it off; straight lines
|
||||
are agent→agent delegation. Hover a bar for its task & timing; click to open the task.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Zoom
|
||||
<Segmented
|
||||
value={zoom}
|
||||
onChange={setZoom}
|
||||
options={[
|
||||
{ value: "hour", label: "Hour" },
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Color
|
||||
<Segmented
|
||||
value={colorMode}
|
||||
onChange={setColorMode}
|
||||
options={[
|
||||
{ value: "issue", label: "By task" },
|
||||
{ value: "status", label: "By status" },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground">
|
||||
{colorMode === "issue"
|
||||
? issues.slice(0, 10).map(([id, label]) => (
|
||||
<span key={id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-4 border border-foreground"
|
||||
style={{ borderLeft: `4px solid ${issueColor(id)}` }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))
|
||||
: (
|
||||
<>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-4 border border-foreground bg-card" /> done
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-4 border border-foreground"
|
||||
style={{ background: "repeating-linear-gradient(90deg, var(--color-foreground) 0 2px, transparent 2px 5px)" }}
|
||||
/>{" "}
|
||||
in progress
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<WorkTimelineChart data={data} zoom={zoom} colorMode={colorMode} nowMs={now} />
|
||||
</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 HourByTask: Story = { args: { initialZoom: "hour", initialColor: "issue" } };
|
||||
export const DayZoom: Story = { args: { initialZoom: "day", initialColor: "issue" } };
|
||||
export const ByStatus: Story = { args: { initialZoom: "hour", initialColor: "status" } };
|
||||
// Live slice that carries human events — Dotta gets a row with diamond markers
|
||||
// for her created / commented / approved / delegated actions (PAP-12444).
|
||||
export const WithHumanMarkers: Story = {
|
||||
args: {
|
||||
initialZoom: "hour",
|
||||
initialColor: "issue",
|
||||
data: humanSample,
|
||||
now: new Date("2026-07-02T16:00:00.000Z").getTime(),
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue