mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
PAP-12435 Work Timeline — routine badge + default self-lens (QA plan deviations)
QA Phase D (PAP-12426) validated the Work Timeline against live PAP data and
found two deviations from the approved plan (PAP-12405). Both fixed here.
Finding 1 — routine-fired runs now carry a distinct hexagon "Routine" badge on
the bar's leading edge (with a circular-arrow glyph) instead of a normal kickoff
avatar, plus a matching tooltip line. `isRoutineRun()` (layout.ts) treats
invocationSource ∈ {automation, timer} as routine/automation-fired.
Finding 2 — the lens now defaults to the viewer's own kicked-off work (resolved
from the auth session) instead of whole-company. The company fetch is gated
until the lens resolves; the dropdown is seeded with the viewer ("(you)") so
their option is present before they appear as an actor, and "Everyone (company)"
remains one click away.
Tests: isRoutineRun unit coverage (automation/timer true; assignment/on_demand/
null false) and a chart render test asserting exactly one badge for the
automation run. 17/17 timeline tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
60f7fb4223
commit
1bdae0caab
5 files changed
+167
-14
No files matched your search
@@ -108,6 +108,17 @@ describe("WorkTimelineChart", () => {
|
||||
expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder");
|
||||
});
|
||||
|
||||
it("marks routine/automation-fired runs with a hexagon badge, not other runs", () => {
|
||||
const data = timelineSample();
|
||||
data.spans[0].invocationSource = "automation"; // routine-fired → badge
|
||||
data.spans[1].invocationSource = "assignment"; // normal kickoff → no badge
|
||||
renderChart(data);
|
||||
|
||||
// exactly one routine badge (hexagon polygon) is drawn, for the automation run.
|
||||
const badges = container.querySelectorAll("svg.absolute polygon");
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
chooseTickStepMs,
|
||||
computeLayout,
|
||||
formatDuration,
|
||||
isRoutineRun,
|
||||
issueColor,
|
||||
shortLabel,
|
||||
type ColorMode,
|
||||
@@ -53,6 +54,8 @@ const GEOM: Omit<LayoutOptions, "pxPerMinute" | "nowMs"> = {
|
||||
const AVATAR_R = 11;
|
||||
const CHIP_R = 9;
|
||||
const MARKER_R = 5.5;
|
||||
/** Distinct accent for the routine badge — reads apart from issue hues on light+dark. */
|
||||
const ROUTINE_FILL = "hsl(265 52% 60%)";
|
||||
|
||||
/**
|
||||
* Per-kind styling for instant event markers (diamonds). Each kind gets a
|
||||
@@ -136,6 +139,37 @@ function AvatarGlyph({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hexagon badge marking a routine/automation-fired run at the bar's leading
|
||||
* edge — deliberately a different shape from the square (human) / circle (agent)
|
||||
* avatar chips so routine runs are distinguishable at a glance (PAP-12435). A
|
||||
* small circular-arrow glyph inside reinforces "recurring / automated".
|
||||
*/
|
||||
function RoutineBadge({ cx, cy, r = CHIP_R }: { cx: number; cy: number; r?: number }) {
|
||||
const points = Array.from({ length: 6 }, (_, k) => {
|
||||
const a = (Math.PI / 3) * k; // flat-top hexagon
|
||||
return `${(cx + r * Math.cos(a)).toFixed(2)},${(cy + r * Math.sin(a)).toFixed(2)}`;
|
||||
}).join(" ");
|
||||
const ar = r * 0.44;
|
||||
return (
|
||||
<g>
|
||||
<polygon points={points} fill={ROUTINE_FILL} stroke="var(--color-foreground)" strokeWidth={1.5} />
|
||||
{/* circular-arrow glyph (open loop + arrowhead) = "routine / recurring" */}
|
||||
<path
|
||||
d={`M ${(cx + ar).toFixed(2)} ${cy.toFixed(2)} A ${ar} ${ar} 0 1 1 ${(cx - ar * 0.5).toFixed(2)} ${(cy - ar * 0.87).toFixed(2)}`}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth={1.3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={`M ${(cx - ar * 0.5).toFixed(2)} ${(cy - ar * 1.5).toFixed(2)} L ${(cx - ar * 0.5).toFixed(2)} ${(cy - ar * 0.3).toFixed(2)} L ${(cx - ar * 1.5).toFixed(2)} ${(cy - ar * 0.9).toFixed(2)} Z`}
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkTimelineChartProps {
|
||||
data: WorkTimelineResult;
|
||||
zoom: ZoomLevel;
|
||||
@@ -353,7 +387,11 @@ export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimeline
|
||||
<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 && (
|
||||
{isRoutineRun(bar.span) ? (
|
||||
<g className="pointer-events-none">
|
||||
<RoutineBadge cx={bar.x1} cy={yTop + bar.height / 2} />
|
||||
</g>
|
||||
) : bar.kickoff ? (
|
||||
<g className="pointer-events-none">
|
||||
<AvatarGlyph
|
||||
cx={bar.x1}
|
||||
@@ -363,7 +401,7 @@ export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimeline
|
||||
type={actorType(bar.kickoff)}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -479,12 +517,23 @@ function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) {
|
||||
{fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "}
|
||||
<span className="font-medium text-foreground">{bar.span.status}</span>
|
||||
</div>
|
||||
{bar.kickoff && (
|
||||
{isRoutineRun(bar.span) ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5"
|
||||
style={{
|
||||
backgroundColor: ROUTINE_FILL,
|
||||
clipPath: "polygon(100% 50%, 75% 93%, 25% 93%, 0% 50%, 25% 7%, 75% 7%)",
|
||||
}}
|
||||
/>
|
||||
fired by routine{bar.span.retryOfRunId ? " · retry" : ""}
|
||||
</div>
|
||||
) : bar.kickoff ? (
|
||||
<div className="text-muted-foreground">
|
||||
kicked off by: {(bar.kickoff as WorkTimelineActor).name}
|
||||
{bar.span.retryOfRunId ? " · retry" : ""}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<div className="mt-1 text-foreground">click → open task</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkTimelineResult } from "@paperclipai/shared";
|
||||
import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout";
|
||||
import {
|
||||
chooseTickStepMs,
|
||||
computeLayout,
|
||||
isRoutineRun,
|
||||
issueColor,
|
||||
shortLabel,
|
||||
type LayoutOptions,
|
||||
} from "./layout";
|
||||
|
||||
const DAY = "2026-07-02";
|
||||
const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`;
|
||||
@@ -194,4 +201,24 @@ describe("helpers", () => {
|
||||
it("chooseTickStepMs grows the step as the view zooms out", () => {
|
||||
expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4));
|
||||
});
|
||||
|
||||
it("isRoutineRun flags automation/timer-fired runs but not human kickoffs", () => {
|
||||
const span = (invocationSource: string | null): WorkTimelineResult["spans"][number] => ({
|
||||
actorId: "agent:x",
|
||||
laneHint: invocationSource,
|
||||
runId: "r",
|
||||
issueId: "i",
|
||||
issueIdentifier: "PAP-1",
|
||||
issueTitle: "t",
|
||||
start: t("09:00"),
|
||||
end: t("09:10"),
|
||||
status: "completed",
|
||||
invocationSource,
|
||||
});
|
||||
expect(isRoutineRun(span("automation"))).toBe(true);
|
||||
expect(isRoutineRun(span("timer"))).toBe(true);
|
||||
expect(isRoutineRun(span("assignment"))).toBe(false);
|
||||
expect(isRoutineRun(span("on_demand"))).toBe(false);
|
||||
expect(isRoutineRun(span(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,18 @@ export function isRunningStatus(status: string): boolean {
|
||||
return RUNNING_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invocation sources that mean "a routine/automation fired this run" rather than
|
||||
* a human/agent kickoff. Runs with these sources get a distinct "Routine" badge
|
||||
* on the bar's leading edge instead of a kickoff avatar (plan §actors, PAP-12435).
|
||||
*/
|
||||
const ROUTINE_INVOCATION_SOURCES = new Set(["automation", "timer"]);
|
||||
|
||||
/** True when a run was fired by a routine/automation rather than an assignment or on-demand kickoff. */
|
||||
export function isRoutineRun(span: WorkTimelineSpan): boolean {
|
||||
return span.invocationSource != null && ROUTINE_INVOCATION_SOURCES.has(span.invocationSource);
|
||||
}
|
||||
|
||||
export function actorType(actor: WorkTimelineActor | undefined): string {
|
||||
return actor?.type ?? "system";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { authApi } from "@/api/auth";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -71,23 +72,57 @@ export function Timeline() {
|
||||
setZoom(z);
|
||||
};
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("issue");
|
||||
const [lensUserId, setLensUserId] = useState<string>(EVERYONE);
|
||||
// Lens defaults to the viewer's own kicked-off work (plan §Default lens,
|
||||
// PAP-12435); resolved from the session once it loads. `null` = not yet resolved
|
||||
// so we don't fire a whole-company fetch before knowing the viewer.
|
||||
const [lensUserId, setLensUserId] = useState<string | null>(null);
|
||||
const lensTouched = useRef(false);
|
||||
const setLensManual = (v: string) => {
|
||||
lensTouched.current = true;
|
||||
setLensUserId(v);
|
||||
};
|
||||
// Union of users discovered across fetches so the lens list stays stable.
|
||||
const [knownUsers, setKnownUsers] = useState<WorkTimelineActor[]>([]);
|
||||
|
||||
const { data: session, isLoading: sessionLoading } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const viewerLensId = session?.user?.id ? `user:${session.user.id}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Timeline" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
// Resolve the initial lens once the session settles: the viewer's own work if
|
||||
// we can identify them, otherwise fall back to whole-company.
|
||||
useEffect(() => {
|
||||
if (lensTouched.current || lensUserId !== null || sessionLoading) return;
|
||||
setLensUserId(viewerLensId ?? EVERYONE);
|
||||
}, [sessionLoading, viewerLensId, lensUserId]);
|
||||
|
||||
// Seed the lens dropdown with the viewer so their own option is always present,
|
||||
// even before they show up as an actor in a fetched window.
|
||||
useEffect(() => {
|
||||
if (!session?.user?.id) return;
|
||||
const id = `user:${session.user.id}`;
|
||||
setKnownUsers((prev) =>
|
||||
prev.some((u) => u.id === id)
|
||||
? prev
|
||||
: [{ id, type: "user", name: session.user.name ?? "You" }, ...prev],
|
||||
);
|
||||
}, [session]);
|
||||
|
||||
const params: WorkTimelineParams = useMemo(
|
||||
() => (lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }),
|
||||
() => (!lensUserId || lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }),
|
||||
[lensUserId],
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId),
|
||||
queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId ?? undefined),
|
||||
queryFn: () => workTimelineApi.get(selectedCompanyId!, params),
|
||||
enabled: !!selectedCompanyId,
|
||||
enabled: !!selectedCompanyId && lensUserId !== null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,8 +151,9 @@ export function Timeline() {
|
||||
</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.
|
||||
the avatar chip at a bar's leading edge is who kicked it off (a hexagon badge marks
|
||||
routine-fired runs); straight lines are agent→agent delegation. Hover a bar for its task &
|
||||
timing; click to open the task.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -138,7 +174,7 @@ export function Timeline() {
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Report for
|
||||
<Select value={lensUserId} onValueChange={setLensUserId}>
|
||||
<Select value={lensUserId ?? EVERYONE} onValueChange={setLensManual}>
|
||||
<SelectTrigger className="h-8 w-[220px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -146,7 +182,7 @@ export function Timeline() {
|
||||
<SelectItem value={EVERYONE}>Everyone (company)</SelectItem>
|
||||
{knownUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} — work kicked off
|
||||
{u.id === viewerLensId ? `${u.name} (you) — work kicked off` : `${u.name} — work kicked off`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -171,7 +207,7 @@ export function Timeline() {
|
||||
{header}
|
||||
{toolbar}
|
||||
|
||||
{isLoading && <PageSkeleton />}
|
||||
{(isLoading || lensUserId === null) && <PageSkeleton />}
|
||||
|
||||
{error && (
|
||||
<EmptyState
|
||||
@@ -201,6 +237,22 @@ export function Timeline() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Purple hexagon chip mirroring the routine badge drawn on routine-fired bars. */
|
||||
function RoutineLegendChip() {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-3 w-3"
|
||||
style={{
|
||||
backgroundColor: "hsl(265 52% 60%)",
|
||||
clipPath: "polygon(100% 50%, 75% 93%, 25% 93%, 0% 50%, 25% 7%, 75% 7%)",
|
||||
}}
|
||||
/>
|
||||
routine-fired
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: ColorMode }) {
|
||||
if (colorMode === "status") {
|
||||
return (
|
||||
@@ -222,6 +274,7 @@ function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: Colo
|
||||
/>{" "}
|
||||
changes/blocked
|
||||
</span>
|
||||
<RoutineLegendChip />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +290,7 @@ function Legend({ data, colorMode }: { data: WorkTimelineResult; colorMode: Colo
|
||||
</span>
|
||||
))}
|
||||
{issues.length > 12 && <span>+{issues.length - 12} more</span>}
|
||||
<RoutineLegendChip />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user