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:
DottaandClaude Opus 4.8 committed 2026-07-03 00:49:59 +00:00
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>
);
+28 -1
View File
@@ -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);
});
});
+12
View File
@@ -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";
}
+63 -9
View File
@@ -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 &amp; 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 &amp;
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>
);
}