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:
@@ -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)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user