story-198: distinguish work item types (story/bug/spike) in web UI

Add visual type indicators to pipeline stage panels so stories, bugs,
and spikes are distinguishable at a glance.

Squash merge of feature/story-198

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-26 15:32:16 +00:00
parent 32a9ef779a
commit b6fcc3ec93
2 changed files with 161 additions and 0 deletions

View File

@@ -101,4 +101,120 @@ describe("StagePanel", () => {
render(<StagePanel title="Upcoming" items={items} />); render(<StagePanel title="Upcoming" items={items} />);
expect(screen.getByText("Missing front matter")).toBeInTheDocument(); expect(screen.getByText("Missing front matter")).toBeInTheDocument();
}); });
it("shows STORY badge for story items", () => {
const items: PipelineStageItem[] = [
{
story_id: "10_story_some_feature",
name: "Some Feature",
error: null,
agent: null,
},
];
render(<StagePanel title="Upcoming" items={items} />);
expect(
screen.getByTestId("type-badge-10_story_some_feature"),
).toHaveTextContent("STORY");
});
it("shows BUG badge for bug items", () => {
const items: PipelineStageItem[] = [
{
story_id: "11_bug_broken_thing",
name: "Broken Thing",
error: null,
agent: null,
},
];
render(<StagePanel title="Current" items={items} />);
expect(
screen.getByTestId("type-badge-11_bug_broken_thing"),
).toHaveTextContent("BUG");
});
it("shows SPIKE badge for spike items", () => {
const items: PipelineStageItem[] = [
{
story_id: "12_spike_investigate_perf",
name: "Investigate Perf",
error: null,
agent: null,
},
];
render(<StagePanel title="QA" items={items} />);
expect(
screen.getByTestId("type-badge-12_spike_investigate_perf"),
).toHaveTextContent("SPIKE");
});
it("shows no badge for unrecognised type prefix", () => {
const items: PipelineStageItem[] = [
{
story_id: "13_task_do_something",
name: "Do Something",
error: null,
agent: null,
},
];
render(<StagePanel title="Done" items={items} />);
expect(
screen.queryByTestId("type-badge-13_task_do_something"),
).not.toBeInTheDocument();
});
it("applies green left border for story items", () => {
const items: PipelineStageItem[] = [
{
story_id: "20_story_green_border",
name: "Green Border",
error: null,
agent: null,
},
];
render(<StagePanel title="Upcoming" items={items} />);
const card = screen.getByTestId("card-20_story_green_border");
expect(card.style.borderLeft).toContain("rgb(63, 185, 80)");
});
it("applies red left border for bug items", () => {
const items: PipelineStageItem[] = [
{
story_id: "21_bug_red_border",
name: "Red Border",
error: null,
agent: null,
},
];
render(<StagePanel title="Current" items={items} />);
const card = screen.getByTestId("card-21_bug_red_border");
expect(card.style.borderLeft).toContain("rgb(248, 81, 73)");
});
it("applies blue left border for spike items", () => {
const items: PipelineStageItem[] = [
{
story_id: "22_spike_blue_border",
name: "Blue Border",
error: null,
agent: null,
},
];
render(<StagePanel title="QA" items={items} />);
const card = screen.getByTestId("card-22_spike_blue_border");
expect(card.style.borderLeft).toContain("rgb(88, 166, 255)");
});
it("applies neutral left border for unrecognised type", () => {
const items: PipelineStageItem[] = [
{
story_id: "23_task_neutral_border",
name: "Neutral Border",
error: null,
agent: null,
},
];
render(<StagePanel title="Done" items={items} />);
const card = screen.getByTestId("card-23_task_neutral_border");
expect(card.style.borderLeft).toContain("rgb(68, 68, 68)");
});
}); });

View File

@@ -4,6 +4,32 @@ import { useLozengeFly } from "./LozengeFlyContext";
const { useLayoutEffect, useRef } = React; const { useLayoutEffect, useRef } = React;
type WorkItemType = "story" | "bug" | "spike" | "unknown";
const TYPE_COLORS: Record<WorkItemType, string> = {
story: "#3fb950",
bug: "#f85149",
spike: "#58a6ff",
unknown: "#444",
};
const TYPE_LABELS: Record<WorkItemType, string | null> = {
story: "STORY",
bug: "BUG",
spike: "SPIKE",
unknown: null,
};
function getWorkItemType(storyId: string): WorkItemType {
const match = storyId.match(/^\d+_([a-z]+)_/);
if (!match) return "unknown";
const segment = match[1];
if (segment === "story" || segment === "bug" || segment === "spike") {
return segment;
}
return "unknown";
}
interface StagePanelProps { interface StagePanelProps {
title: string; title: string;
items: PipelineStageItem[]; items: PipelineStageItem[];
@@ -137,13 +163,18 @@ export function StagePanel({
> >
{items.map((item) => { {items.map((item) => {
const itemNumber = item.story_id.match(/^(\d+)/)?.[1]; const itemNumber = item.story_id.match(/^(\d+)/)?.[1];
const itemType = getWorkItemType(item.story_id);
const borderColor = TYPE_COLORS[itemType];
const typeLabel = TYPE_LABELS[itemType];
return ( return (
<div <div
key={`${title}-${item.story_id}`} key={`${title}-${item.story_id}`}
data-testid={`card-${item.story_id}`}
style={{ style={{
border: item.agent border: item.agent
? "1px solid #2a3a4a" ? "1px solid #2a3a4a"
: "1px solid #2a2a2a", : "1px solid #2a2a2a",
borderLeft: `3px solid ${borderColor}`,
borderRadius: "8px", borderRadius: "8px",
padding: "8px 12px", padding: "8px 12px",
background: item.agent ? "#161e2a" : "#191919", background: item.agent ? "#161e2a" : "#191919",
@@ -165,6 +196,20 @@ export function StagePanel({
#{itemNumber} #{itemNumber}
</span> </span>
)} )}
{typeLabel && (
<span
data-testid={`type-badge-${item.story_id}`}
style={{
fontSize: "0.7em",
fontWeight: 700,
color: borderColor,
marginRight: "8px",
letterSpacing: "0.05em",
}}
>
{typeLabel}
</span>
)}
{item.name ?? item.story_id} {item.name ?? item.story_id}
</div> </div>
{item.error && ( {item.error && (