# Dashboard UI Redesign Implementation Plan

> **Status: COMPLETE** — All 12 tasks implemented. See post-plan improvements below.

**Goal:** Redesign the dashboard from a dark uniform card grid to a Catppuccin Latte triage-optimized hybrid layout with sparklines, expandable cards, GitHub links, and URL filter persistence.

**Architecture:** Backend adds three new fields to the pipelines API response (`recentBuilds`, `githubUrl`, `buildingBuildNumber`) and removes the `isBuilding` duplication from `latestRun`. Frontend is rebuilt with sectioned layout (failing cards → issues cards → passing chips), new components for sparklines, stats ribbon, stage breakdown, and URL-based filtering. Catppuccin Latte colors defined as CSS custom properties and extended into Tailwind config.

**Tech Stack:** React 19, Tailwind CSS 3.4, Vite 6, Express, Drizzle ORM, SQLite, Vitest

**Spec:** `docs/superpowers/specs/2026-03-18-dashboard-ui-redesign-design.md`

### Post-Plan Improvements

Changes made after the 12-task plan was executed:

1. **Removed dark theme classes from `index.html`** — The `<body>` had `bg-gray-950 text-gray-100` from the old dark theme, overriding the new Catppuccin Latte background.
2. **Build numbers are clickable links** — The build number + relative time on failure/issue cards now links to the Jenkins pipeline URL.
3. **GitHub repo link shows `{org}/{repo}`** — Instead of generic "Repo" text, the pill shows the actual org/repo name (e.g. `theorchard/my-repo`) with the GitHub SVG logo.
4. **Per-stage URL extraction** — The poller now fetches console output per failed Playwright stage (via `execution/node/{nodeId}/wfapi/log`) instead of the whole build, so report URLs are associated with the correct stage. Report pills show the stage name. Falls back to full build console if per-stage fetching yields nothing.
5. **BUILDING badge is a link** — The "BUILDING #N" badge now links to the specific in-progress Jenkins build.

---

## File Map

### Backend (server/)
| File | Action | Responsibility |
|------|--------|---------------|
| `server/routes/pipelines.ts` | Modify | Add `recentBuilds`, `githubUrl`, `buildingBuildNumber`; remove `isBuilding` from `latestRun` |
| `server/routes/pipelines.test.ts` | Modify | Update existing tests, add tests for new fields |

### Frontend (client/src/)
| File | Action | Responsibility |
|------|--------|---------------|
| `client/src/types.ts` | Modify | Update `Pipeline`, `LatestRun` types; add `RecentBuild` |
| `client/src/index.css` | Modify | Catppuccin Latte CSS custom properties + shimmer animation |
| `client/tailwind.config.js` | Modify | Extend theme with Catppuccin color names |
| `client/src/App.tsx` | Modify | Switch background to Latte Base |
| `client/src/hooks/useUrlFilter.ts` | Create | Custom hook: read/write filter to URL query params |
| `client/src/components/Sparkline.tsx` | Create | Reusable sparkline bar chart component |
| `client/src/components/StatsRibbon.tsx` | Create | Clickable stat cards (Failing/Issues/Passing) |
| `client/src/components/PipelineCard.tsx` | Modify | Full redesign: left border, sparkline, pills, expand/collapse |
| `client/src/components/StageBreakdown.tsx` | Create | Expanded card stage list (fetches `/api/pipelines/:id/runs`) |
| `client/src/components/PassingChips.tsx` | Create | Collapsible mini-chips for green pipelines |
| `client/src/components/Dashboard.tsx` | Modify | New sectioned layout with all new components |
| `client/src/components/FilterBar.tsx` | Delete | Replaced by StatsRibbon |

---

## Task 1: Backend — Add `githubUrl` derivation

**Files:**
- Modify: `server/routes/pipelines.ts:7-21` (PipelineResponse type)
- Modify: `server/routes/pipelines.ts:23-105` (buildPipelinesResponse)
- Modify: `server/routes/pipelines.test.ts`

- [ ] **Step 1: Write test for `githubUrl` derivation**

Add to `server/routes/pipelines.test.ts` inside the `buildPipelinesResponse` describe block:

```typescript
it("derives githubUrl from Jenkins URL pattern", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "my-repo/master", jenkinsUrl: "https://pipeline.theorchard.io/job/theorchard/job/my-repo/job/master" })
    .returning()
    .get();

  db.insert(schema.runs)
    .values({ pipelineId: pipeline.id, buildNumber: 1, status: "green" })
    .run();

  const result = buildPipelinesResponse(db, "theorchard");
  expect(result.pipelines[0].githubUrl).toBe("https://github.com/theorchard/my-repo");
});

it("returns null githubUrl for unexpected Jenkins URL format", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "weird-pipeline", jenkinsUrl: "https://jenkins.example.com/job/standalone-job" })
    .returning()
    .get();

  const result = buildPipelinesResponse(db, "theorchard");
  expect(result.pipelines[0].githubUrl).toBeNull();
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test -- server/routes/pipelines.test.ts`
Expected: FAIL — `githubUrl` property does not exist on response

- [ ] **Step 3: Implement `githubUrl` derivation**

In `server/routes/pipelines.ts`, add a helper function before `buildPipelinesResponse`:

```typescript
function deriveGithubUrl(jenkinsUrl: string, githubOrg: string): string | null {
  // Pattern: {base}/job/{org}/job/{repo}/job/{branch}
  const match = jenkinsUrl.match(/\/job\/[^/]+\/job\/([^/]+)\/job\//);
  if (!match) return null;
  return `https://github.com/${githubOrg}/${match[1]}`;
}
```

Add `githubUrl` to `PipelineResponse` interface:

```typescript
export interface PipelineResponse {
  id: number;
  name: string;
  jenkinsUrl: string;
  githubUrl: string | null;  // NEW
  latestRun: { /* ... existing ... */ } | null;
  isBuilding: boolean;
}
```

Update `buildPipelinesResponse` signature to accept `githubOrg: string` parameter:

```typescript
export function buildPipelinesResponse(db: DB, githubOrg: string): { ... }
```

In `buildPipelinesResponse`, add `githubUrl: deriveGithubUrl(p.jenkinsUrl, githubOrg)` to every `pipelines.push(...)` call.

Update the route handler in `createPipelinesRouter` to accept and pass `githubOrg`:

```typescript
export function createPipelinesRouter(db: DB, githubOrg: string): Router {
  // ...
  router.get("/pipelines", (_req, res) => {
    const result = buildPipelinesResponse(db, githubOrg);
    res.json(result);
  });
```

Update `server/index.ts` where `createPipelinesRouter` is called to pass `config.github.org`.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test -- server/routes/pipelines.test.ts`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add server/routes/pipelines.ts server/routes/pipelines.test.ts
git commit -m "feat: derive githubUrl from Jenkins URL pattern"
```

---

## Task 2: Backend — Add `recentBuilds` field

**Files:**
- Modify: `server/routes/pipelines.ts`
- Modify: `server/routes/pipelines.test.ts`

- [ ] **Step 1: Write test for `recentBuilds`**

Add to `server/routes/pipelines.test.ts`:

```typescript
it("returns recentBuilds with last 10 completed runs", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/org/job/repo/job/main" })
    .returning()
    .get();

  // Insert 12 runs: 10 completed + 1 building + 1 extra old one
  for (let i = 1; i <= 12; i++) {
    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: i, status: i === 12 ? "building" : i % 3 === 0 ? "red" : "green" })
      .run();
  }

  const result = buildPipelinesResponse(db, "org");
  const recent = result.pipelines[0].recentBuilds;

  // Should have 10 most recent completed (builds 11 down to 2), excluding building #12
  expect(recent).toHaveLength(10);
  expect(recent[0].buildNumber).toBe(11); // most recent completed first
  expect(recent[9].buildNumber).toBe(2);
  // Should not include building runs
  expect(recent.every(r => r.status !== "building")).toBe(true);
});

it("returns empty recentBuilds when no completed runs", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "new-repo/main", jenkinsUrl: "https://j.example.com/job/org/job/new-repo/job/main" })
    .returning()
    .get();

  const result = buildPipelinesResponse(db, "org");
  expect(result.pipelines[0].recentBuilds).toEqual([]);
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test -- server/routes/pipelines.test.ts`
Expected: FAIL — `recentBuilds` not in response

- [ ] **Step 3: Implement `recentBuilds` query**

Add `recentBuilds` to `PipelineResponse`:

```typescript
recentBuilds: { buildNumber: number; status: string }[];
```

In `buildPipelinesResponse`, inside the `for (const p of allPipelines)` loop, add after the existing queries:

```typescript
const recentBuilds = db
  .select({ buildNumber: schema.runs.buildNumber, status: schema.runs.status })
  .from(schema.runs)
  .where(and(eq(schema.runs.pipelineId, p.id), ne(schema.runs.status, "building")))
  .orderBy(desc(schema.runs.buildNumber))
  .limit(10)
  .all();
```

Add `recentBuilds` to both `pipelines.push(...)` calls.

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test -- server/routes/pipelines.test.ts`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add server/routes/pipelines.ts server/routes/pipelines.test.ts
git commit -m "feat: add recentBuilds to pipelines API for sparklines"
```

---

## Task 3: Backend — Add `buildingBuildNumber` and remove `latestRun.isBuilding`

**Files:**
- Modify: `server/routes/pipelines.ts`
- Modify: `server/routes/pipelines.test.ts`

- [ ] **Step 1: Write test for `buildingBuildNumber`**

Add to `server/routes/pipelines.test.ts`:

```typescript
it("returns buildingBuildNumber when a build is in progress", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/org/job/repo/job/main" })
    .returning()
    .get();

  db.insert(schema.runs)
    .values({ pipelineId: pipeline.id, buildNumber: 10, status: "red" })
    .run();

  db.insert(schema.runs)
    .values({ pipelineId: pipeline.id, buildNumber: 11, status: "building" })
    .run();

  const result = buildPipelinesResponse(db, "org");
  expect(result.pipelines[0].buildingBuildNumber).toBe(11);
  expect(result.pipelines[0].isBuilding).toBe(true);
});

it("returns null buildingBuildNumber when not building", () => {
  const pipeline = db
    .insert(schema.pipelines)
    .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/org/job/repo/job/main" })
    .returning()
    .get();

  db.insert(schema.runs)
    .values({ pipelineId: pipeline.id, buildNumber: 10, status: "green" })
    .run();

  const result = buildPipelinesResponse(db, "org");
  expect(result.pipelines[0].buildingBuildNumber).toBeNull();
  expect(result.pipelines[0].isBuilding).toBe(false);
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test -- server/routes/pipelines.test.ts`
Expected: FAIL — `buildingBuildNumber` not in response

- [ ] **Step 3: Implement `buildingBuildNumber` and remove `latestRun.isBuilding`**

Update `PipelineResponse` — remove `isBuilding` from inside `latestRun`, add `buildingBuildNumber` at top level:

```typescript
export interface PipelineResponse {
  id: number;
  name: string;
  jenkinsUrl: string;
  githubUrl: string | null;
  latestRun: {
    buildNumber: number;
    status: string;
    failedStages: string[];
    startedAt: number | null;
    durationMs: number | null;
    extractedUrls: { url: string; label: string | null; stageName: string }[];
  } | null;
  isBuilding: boolean;
  buildingBuildNumber: number | null;
  recentBuilds: { buildNumber: number; status: string }[];
}
```

In `buildPipelinesResponse`, replace `isBuilding` with:

```typescript
const buildingBuildNumber = buildingRun ? buildingRun.buildNumber : null;
```

Add `buildingBuildNumber` to both `pipelines.push(...)` calls. Remove `isBuilding` from inside the `latestRun` object.

- [ ] **Step 4: Update existing tests that reference `latestRun.isBuilding`**

In `server/routes/pipelines.test.ts`:
- Test "returns pipelines with latest completed run": change `expect(result.pipelines[0].latestRun?.isBuilding).toBe(false)` to `expect(result.pipelines[0].isBuilding).toBe(false)`
- Test "returns previous completed run when latest is building": change `expect(result.pipelines[0].latestRun?.isBuilding).toBe(true)` to `expect(result.pipelines[0].isBuilding).toBe(true)` and add `expect(result.pipelines[0].buildingBuildNumber).toBe(11)`

- [ ] **Step 5: Run all tests**

Run: `pnpm test`
Expected: All PASS

- [ ] **Step 6: Commit**

```bash
git add server/routes/pipelines.ts server/routes/pipelines.test.ts
git commit -m "feat: add buildingBuildNumber, remove latestRun.isBuilding duplication"
```

---

## Task 4: Frontend — Catppuccin Latte theme setup

**Files:**
- Modify: `client/src/index.css`
- Modify: `client/tailwind.config.js`
- Modify: `client/src/App.tsx`

- [ ] **Step 1: Add CSS custom properties to `index.css`**

Replace the contents of `client/src/index.css` with:

```css
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --ctp-rosewater: #dc8a78;
  --ctp-flamingo: #dd7878;
  --ctp-pink: #ea76cb;
  --ctp-mauve: #8839ef;
  --ctp-red: #d20f39;
  --ctp-maroon: #e64553;
  --ctp-peach: #fe640b;
  --ctp-yellow: #df8e1d;
  --ctp-green: #40a02b;
  --ctp-teal: #179299;
  --ctp-sky: #04a5e5;
  --ctp-sapphire: #209fb5;
  --ctp-blue: #1e66f5;
  --ctp-lavender: #7287fd;
  --ctp-text: #4c4f69;
  --ctp-subtext1: #5c5f77;
  --ctp-subtext0: #6c6f85;
  --ctp-overlay2: #7c7f93;
  --ctp-overlay1: #8c8fa1;
  --ctp-overlay0: #9ca0b0;
  --ctp-surface2: #acb0be;
  --ctp-surface1: #bcc0cc;
  --ctp-surface0: #ccd0da;
  --ctp-base: #eff1f5;
  --ctp-mantle: #e6e9ef;
  --ctp-crust: #dce0e8;
}

@keyframes shimmer {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(200%); }
}
```

- [ ] **Step 2: Extend Tailwind config with Catppuccin colors**

Replace `client/tailwind.config.js`:

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
        ctp: {
          rosewater: "var(--ctp-rosewater)",
          flamingo: "var(--ctp-flamingo)",
          pink: "var(--ctp-pink)",
          mauve: "var(--ctp-mauve)",
          red: "var(--ctp-red)",
          maroon: "var(--ctp-maroon)",
          peach: "var(--ctp-peach)",
          yellow: "var(--ctp-yellow)",
          green: "var(--ctp-green)",
          teal: "var(--ctp-teal)",
          sky: "var(--ctp-sky)",
          sapphire: "var(--ctp-sapphire)",
          blue: "var(--ctp-blue)",
          lavender: "var(--ctp-lavender)",
          text: "var(--ctp-text)",
          subtext1: "var(--ctp-subtext1)",
          subtext0: "var(--ctp-subtext0)",
          overlay2: "var(--ctp-overlay2)",
          overlay1: "var(--ctp-overlay1)",
          overlay0: "var(--ctp-overlay0)",
          surface2: "var(--ctp-surface2)",
          surface1: "var(--ctp-surface1)",
          surface0: "var(--ctp-surface0)",
          base: "var(--ctp-base)",
          mantle: "var(--ctp-mantle)",
          crust: "var(--ctp-crust)",
        },
      },
    },
  },
  plugins: [],
};
```

- [ ] **Step 3: Update `App.tsx` background**

Change `client/src/App.tsx`:

```tsx
import { Dashboard } from "./components/Dashboard";

export function App() {
  return (
    <div className="min-h-screen bg-ctp-base">
      <Dashboard />
    </div>
  );
}
```

- [ ] **Step 4: Verify app still builds**

Run: `cd client && npx vite build`
Expected: Build succeeds with no errors

- [ ] **Step 5: Commit**

```bash
git add client/src/index.css client/tailwind.config.js client/src/App.tsx
git commit -m "feat: add Catppuccin Latte theme with CSS custom properties and Tailwind integration"
```

---

## Task 5: Frontend — Update types and add `useUrlFilter` hook

**Files:**
- Modify: `client/src/types.ts`
- Create: `client/src/hooks/useUrlFilter.ts`

- [ ] **Step 1: Update `types.ts`**

Replace `client/src/types.ts`:

```typescript
export interface ExtractedUrl {
  url: string;
  label: string | null;
  stageName: string;
}

export interface LatestRun {
  buildNumber: number;
  status: "green" | "red" | "yellow";
  failedStages: string[];
  startedAt: number | null;
  durationMs: number | null;
  extractedUrls: ExtractedUrl[];
}

export interface RecentBuild {
  buildNumber: number;
  status: "green" | "red" | "yellow";
}

export interface Pipeline {
  id: number;
  name: string;
  jenkinsUrl: string;
  githubUrl: string | null;
  latestRun: LatestRun | null;
  isBuilding: boolean;
  buildingBuildNumber: number | null;
  recentBuilds: RecentBuild[];
}

export interface PipelinesResponse {
  pipelines: Pipeline[];
  lastPolledAt: number | null;
  healthy: boolean;
}

export type StatusFilter = "all" | "red" | "yellow" | "green";

export interface StageInfo {
  name: string;
  status: string;
  durationMs: number | null;
  isPlaywright: boolean;
}

export interface RunDetail {
  buildNumber: number;
  status: string;
  startedAt: number | null;
  durationMs: number | null;
  stages: StageInfo[];
  extractedUrls: { url: string; label: string | null }[];
}
```

- [ ] **Step 2: Create `useUrlFilter` hook**

Create `client/src/hooks/useUrlFilter.ts`:

```typescript
import { useState, useCallback } from "react";
import type { StatusFilter } from "../types";

const VALID_FILTERS: StatusFilter[] = ["all", "red", "yellow", "green"];

function readFilterFromUrl(): StatusFilter {
  const params = new URLSearchParams(window.location.search);
  const value = params.get("filter");
  if (value && VALID_FILTERS.includes(value as StatusFilter)) {
    return value as StatusFilter;
  }
  return "all";
}

function writeFilterToUrl(filter: StatusFilter): void {
  const params = new URLSearchParams(window.location.search);
  if (filter === "all") {
    params.delete("filter");
  } else {
    params.set("filter", filter);
  }
  const query = params.toString();
  const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
  window.history.replaceState(null, "", newUrl);
}

export function useUrlFilter() {
  const [filter, setFilterState] = useState<StatusFilter>(readFilterFromUrl);

  const setFilter = useCallback((newFilter: StatusFilter) => {
    setFilterState(newFilter);
    writeFilterToUrl(newFilter);
  }, []);

  return { filter, setFilter };
}
```

- [ ] **Step 3: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 4: Commit**

```bash
git add client/src/types.ts client/src/hooks/useUrlFilter.ts
git commit -m "feat: update frontend types for redesign and add useUrlFilter hook"
```

---

## Task 6: Frontend — Sparkline component

**Files:**
- Create: `client/src/components/Sparkline.tsx`

- [ ] **Step 1: Create `Sparkline.tsx`**

Create `client/src/components/Sparkline.tsx`:

```tsx
import type { RecentBuild } from "../types";

const STATUS_COLORS: Record<string, string> = {
  red: "bg-ctp-red",
  yellow: "bg-ctp-yellow",
  green: "bg-ctp-green",
};

interface SparklineProps {
  builds: RecentBuild[];
}

export function Sparkline({ builds }: SparklineProps) {
  if (builds.length === 0) return null;

  return (
    <div className="flex items-end gap-px h-3.5">
      {builds.map((b) => (
        <div
          key={b.buildNumber}
          className={`w-1 h-3.5 rounded-t-sm ${STATUS_COLORS[b.status] ?? "bg-ctp-overlay0"}`}
        />
      ))}
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 3: Commit**

```bash
git add client/src/components/Sparkline.tsx
git commit -m "feat: add Sparkline component for build history visualization"
```

---

## Task 7: Frontend — StatsRibbon component

**Files:**
- Create: `client/src/components/StatsRibbon.tsx`

- [ ] **Step 1: Create `StatsRibbon.tsx`**

Create `client/src/components/StatsRibbon.tsx`:

```tsx
import type { Pipeline, StatusFilter } from "../types";

interface StatsRibbonProps {
  pipelines: Pipeline[];
  activeFilter: StatusFilter;
  onFilterChange: (filter: StatusFilter) => void;
}

const stats: {
  key: StatusFilter;
  label: string;
  bg: string;
  border: string;
  textColor: string;
}[] = [
  {
    key: "red",
    label: "FAILING",
    bg: "rgba(210,15,57,0.08)",
    border: "rgba(210,15,57,0.15)",
    textColor: "text-ctp-red",
  },
  {
    key: "yellow",
    label: "ISSUES",
    bg: "rgba(223,142,29,0.08)",
    border: "rgba(223,142,29,0.15)",
    textColor: "text-ctp-yellow",
  },
  {
    key: "green",
    label: "PASSING",
    bg: "rgba(64,160,43,0.06)",
    border: "rgba(64,160,43,0.1)",
    textColor: "text-ctp-green",
  },
];

export function StatsRibbon({ pipelines, activeFilter, onFilterChange }: StatsRibbonProps) {
  const counts: Record<string, number> = { red: 0, yellow: 0, green: 0 };
  for (const p of pipelines) {
    const status = p.latestRun?.status ?? "green";
    counts[status] = (counts[status] ?? 0) + 1;
  }

  return (
    <div className="flex gap-2 mb-4">
      {stats.map((s) => {
        const isActive = activeFilter === s.key;
        return (
          <button
            key={s.key}
            onClick={() => onFilterChange(isActive ? "all" : s.key)}
            className={`flex-1 rounded-lg px-3.5 py-2.5 text-left transition-all cursor-pointer`}
            style={{
              background: s.bg,
              borderWidth: "1px",
              borderStyle: "solid",
              borderColor: s.border,
              ...(isActive ? { outline: `2px solid ${s.border}`, outlineOffset: "1px" } : {}),
            }}
          >
            <div className={`text-[22px] font-bold ${s.textColor}`}>{counts[s.key]}</div>
            <div className="text-[10px] text-ctp-subtext0 uppercase tracking-wider">{s.label}</div>
          </button>
        );
      })}
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 3: Commit**

```bash
git add client/src/components/StatsRibbon.tsx
git commit -m "feat: add StatsRibbon component with clickable filter cards"
```

---

## Task 8: Frontend — StageBreakdown component

**Files:**
- Create: `client/src/components/StageBreakdown.tsx`

- [ ] **Step 1: Create `StageBreakdown.tsx`**

Create `client/src/components/StageBreakdown.tsx`:

```tsx
import { useState, useEffect } from "react";
import type { StageInfo } from "../types";

interface StageBreakdownProps {
  pipelineId: number;
}

function formatDuration(ms: number | null): string {
  if (ms === null) return "—";
  if (ms < 1000) return `${ms}ms`;
  const seconds = Math.floor(ms / 1000);
  if (seconds < 60) return `${seconds}s`;
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes}m ${remainingSeconds}s`;
}

export function StageBreakdown({ pipelineId }: StageBreakdownProps) {
  const [stages, setStages] = useState<StageInfo[] | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/pipelines/${pipelineId}/runs`)
      .then((res) => res.json())
      .then((data) => {
        if (cancelled) return;
        // Get stages from the latest run
        const latestRun = data.runs?.[0];
        setStages(latestRun?.stages ?? []);
        setLoading(false);
      })
      .catch(() => {
        if (!cancelled) setLoading(false);
      });

    return () => { cancelled = true; };
  }, [pipelineId]);

  if (loading) {
    return <div className="text-[11px] text-ctp-overlay1 py-2">Loading stages...</div>;
  }

  if (!stages || stages.length === 0) {
    return <div className="text-[11px] text-ctp-overlay1 py-2">No stage data</div>;
  }

  return (
    <div className="mt-2.5">
      <div className="text-[10px] text-ctp-overlay2 uppercase tracking-wider mb-1.5">
        Stage Breakdown
      </div>
      <div className="flex flex-col gap-0.5">
        {stages.map((stage) => {
          const isFailed = stage.status === "FAILURE" || stage.status === "FAILED";
          return (
            <div key={stage.name} className="flex items-center gap-2 text-[11px]">
              <span className={`w-2.5 text-center ${isFailed ? "text-ctp-red font-bold" : "text-ctp-green"}`}>
                {isFailed ? "✗" : "✓"}
              </span>
              <span className={`flex-1 ${isFailed ? "text-ctp-red font-medium" : "text-ctp-subtext1"}`}>
                {stage.name}
              </span>
              <span className="text-[10px] text-ctp-overlay1">
                {formatDuration(stage.durationMs)}
              </span>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 3: Commit**

```bash
git add client/src/components/StageBreakdown.tsx
git commit -m "feat: add StageBreakdown component for expanded card view"
```

---

## Task 9: Frontend — Redesign PipelineCard

**Files:**
- Modify: `client/src/components/PipelineCard.tsx`

- [ ] **Step 1: Rewrite `PipelineCard.tsx`**

Replace `client/src/components/PipelineCard.tsx` with:

```tsx
import type { Pipeline } from "../types";
import { Sparkline } from "./Sparkline";
import { StageBreakdown } from "./StageBreakdown";

interface PipelineCardProps {
  pipeline: Pipeline;
  isExpanded: boolean;
  onToggleExpand: () => void;
  statusColor: "red" | "yellow";
}

function relativeTime(timestamp: number | null): string {
  if (!timestamp) return "";
  const diff = Date.now() - timestamp;
  const minutes = Math.floor(diff / 60000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
}

function formatDuration(ms: number | null): string {
  if (ms === null) return "";
  const seconds = Math.floor(ms / 1000);
  if (seconds < 60) return `${seconds}s`;
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes}m ${remainingSeconds}s`;
}

const COLOR_MAP = {
  red: {
    border: "border-l-ctp-red",
    name: "text-ctp-red",
  },
  yellow: {
    border: "border-l-ctp-yellow",
    name: "text-ctp-yellow",
  },
};

export function PipelineCard({ pipeline, isExpanded, onToggleExpand, statusColor }: PipelineCardProps) {
  const { latestRun, isBuilding, buildingBuildNumber, recentBuilds, githubUrl } = pipeline;
  const colors = COLOR_MAP[statusColor];

  return (
    <div
      onClick={onToggleExpand}
      className={`bg-ctp-mantle rounded-lg border-l-[3px] ${colors.border} cursor-pointer relative overflow-hidden transition-shadow ${
        isExpanded ? "col-span-2 shadow-[0_2px_8px_rgba(0,0,0,0.06)]" : ""
      }`}
    >
      {/* Building shimmer bar */}
      {isBuilding && (
        <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-ctp-surface0 overflow-hidden">
          <div
            className="absolute w-[40%] h-full"
            style={{
              background: "linear-gradient(90deg, transparent, var(--ctp-blue), transparent)",
              animation: "shimmer 1.5s infinite",
            }}
          />
        </div>
      )}

      <div className="p-3.5">
        {/* Row 1: Name + meta */}
        <div className="flex items-center justify-between mb-1.5">
          <div className={`font-semibold text-xs ${colors.name} truncate`}>
            {pipeline.name}
          </div>
          <div className="flex items-center gap-1.5 shrink-0">
            {isBuilding && (
              <span className="text-ctp-blue text-[9px] font-semibold bg-[rgba(30,102,245,0.1)] px-1.5 py-0.5 rounded">
                BUILDING #{buildingBuildNumber}
              </span>
            )}
            <span className="text-[10px] text-ctp-overlay1">
              #{latestRun?.buildNumber ?? "—"} · {relativeTime(latestRun?.startedAt ?? null)}
            </span>
          </div>
        </div>

        {/* Row 2: Failed stages */}
        {latestRun?.failedStages && latestRun.failedStages.length > 0 && (
          <div className="text-[11px] text-ctp-subtext0 mb-2">
            {latestRun.failedStages.join(", ")} FAILED
          </div>
        )}

        {/* Row 3: Sparkline */}
        {recentBuilds.length > 0 && (
          <div className="mb-2">
            <Sparkline builds={recentBuilds} />
          </div>
        )}

        {/* Row 4: Link pills */}
        <div className="flex flex-wrap gap-1">
          {latestRun?.extractedUrls?.map((u, i) => (
            <a
              key={i}
              href={u.url}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
              className="inline-flex items-center gap-1 text-[10px] text-ctp-blue bg-[rgba(30,102,245,0.06)] border border-[rgba(30,102,245,0.1)] rounded px-1.5 py-0.5 hover:bg-[rgba(30,102,245,0.12)] transition-colors"
            >
              ↗ {u.label || "Report"}
            </a>
          ))}
          {githubUrl && (
            <a
              href={githubUrl}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
              className="inline-flex items-center gap-1 text-[10px] text-ctp-subtext0 bg-[rgba(76,79,105,0.06)] border border-[rgba(76,79,105,0.1)] rounded px-1.5 py-0.5 hover:bg-[rgba(76,79,105,0.12)] transition-colors"
            >
              ⌂ Repo
            </a>
          )}
        </div>

        {/* Expanded: duration + stage breakdown */}
        {isExpanded && (
          <>
            {latestRun?.durationMs && (
              <div className="text-[10px] text-ctp-overlay1 mt-2">
                Total: {formatDuration(latestRun.durationMs)}
              </div>
            )}
            <StageBreakdown pipelineId={pipeline.id} />
          </>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 3: Commit**

```bash
git add client/src/components/PipelineCard.tsx
git commit -m "feat: redesign PipelineCard with Catppuccin theme, sparklines, pills, and expand"
```

---

## Task 10: Frontend — PassingChips component

**Files:**
- Create: `client/src/components/PassingChips.tsx`

- [ ] **Step 1: Create `PassingChips.tsx`**

Create `client/src/components/PassingChips.tsx`:

```tsx
import { useState } from "react";
import type { Pipeline } from "../types";

interface PassingChipsProps {
  pipelines: Pipeline[];
}

const INITIAL_VISIBLE = 20;

export function PassingChips({ pipelines }: PassingChipsProps) {
  const [expanded, setExpanded] = useState(false);

  if (pipelines.length === 0) return null;

  const visible = expanded ? pipelines : pipelines.slice(0, INITIAL_VISIBLE);
  const remaining = pipelines.length - INITIAL_VISIBLE;

  return (
    <div>
      <button
        onClick={() => setExpanded(!expanded)}
        className="flex items-center gap-2 mb-1 cursor-pointer"
      >
        <span className="text-[11px] font-semibold text-ctp-green uppercase tracking-wider">
          Passing
        </span>
        <span className="text-[11px] text-ctp-overlay1">
          {pipelines.length} pipelines {expanded ? "▴" : "▾"}
        </span>
      </button>
      <div className="flex flex-wrap gap-1">
        {visible.map((p) => (
          <a
            key={p.id}
            href={p.jenkinsUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="bg-ctp-mantle rounded border border-ctp-surface0 px-2 py-0.5 text-[10px] text-ctp-subtext0 hover:bg-ctp-surface0 transition-colors inline-flex items-center gap-1"
          >
            {p.name}
            {p.isBuilding && <span className="text-ctp-blue">⟳</span>}
            <span className="text-ctp-overlay0">#{p.latestRun?.buildNumber ?? "—"}</span>
          </a>
        ))}
        {!expanded && remaining > 0 && (
          <button
            onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
            className="bg-ctp-mantle rounded border border-ctp-surface0 px-2 py-0.5 text-[10px] text-ctp-overlay0 hover:bg-ctp-surface0 transition-colors cursor-pointer"
          >
            +{remaining} more
          </button>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds

- [ ] **Step 3: Commit**

```bash
git add client/src/components/PassingChips.tsx
git commit -m "feat: add PassingChips component for collapsible green pipeline display"
```

---

## Task 11: Frontend — Rewrite Dashboard with new layout

**Files:**
- Modify: `client/src/components/Dashboard.tsx`
- Delete: `client/src/components/FilterBar.tsx`

- [ ] **Step 1: Rewrite `Dashboard.tsx`**

Replace `client/src/components/Dashboard.tsx` with:

```tsx
import { useState } from "react";
import { usePipelines } from "../hooks/usePipelines";
import { useUrlFilter } from "../hooks/useUrlFilter";
import { StatsRibbon } from "./StatsRibbon";
import { PipelineCard } from "./PipelineCard";
import { PassingChips } from "./PassingChips";
import type { Pipeline } from "../types";

function relativeTime(timestamp: number | null): string {
  if (!timestamp) return "";
  const diff = Date.now() - timestamp;
  const seconds = Math.floor(diff / 1000);
  if (seconds < 60) return `${seconds}s ago`;
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes}m ago`;
  return `${Math.floor(minutes / 60)}h ago`;
}

function groupByStatus(pipelines: Pipeline[]) {
  const red: Pipeline[] = [];
  const yellow: Pipeline[] = [];
  const green: Pipeline[] = [];

  for (const p of pipelines) {
    const status = p.latestRun?.status ?? "green";
    if (status === "red") red.push(p);
    else if (status === "yellow") yellow.push(p);
    else green.push(p);
  }

  return { red, yellow, green };
}

export function Dashboard() {
  const { data, error, loading } = usePipelines();
  const { filter, setFilter } = useUrlFilter();
  const [search, setSearch] = useState("");
  const [expandedId, setExpandedId] = useState<number | null>(null);

  if (loading && !data) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-ctp-overlay1">Loading pipelines...</p>
      </div>
    );
  }

  if (error && !data) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-ctp-red">Error: {error}</p>
      </div>
    );
  }

  const allPipelines = data?.pipelines ?? [];

  // Apply search filter
  const searched = search
    ? allPipelines.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
    : allPipelines;

  const groups = groupByStatus(searched);

  const handleToggleExpand = (id: number) => {
    setExpandedId(expandedId === id ? null : id);
  };

  const showRed = filter === "all" || filter === "red";
  const showYellow = filter === "all" || filter === "yellow";
  const showGreen = filter === "all" || filter === "green";

  return (
    <div className="max-w-7xl mx-auto px-4 py-6">
      {/* Header */}
      <header className="mb-4 flex items-center justify-between">
        <h1 className="text-[15px] font-semibold text-ctp-text">Pipeline Monitor</h1>
        <div className="flex items-center gap-3 text-[11px] text-ctp-overlay1">
          <input
            type="text"
            placeholder="Search pipelines..."
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            className="bg-ctp-surface0 border border-ctp-surface1 rounded-md px-2.5 py-1 text-[11px] text-ctp-text w-44 outline-none focus:border-ctp-blue transition-colors placeholder:text-ctp-overlay0"
          />
          {data?.lastPolledAt && (
            <span>Polled {relativeTime(data.lastPolledAt)}</span>
          )}
          <span className={data?.healthy ? "text-ctp-green" : "text-ctp-red"}>
            {data?.healthy ? "● Connected" : "● Disconnected"}
          </span>
        </div>
      </header>

      {/* Stats Ribbon */}
      <StatsRibbon
        pipelines={allPipelines}
        activeFilter={filter}
        onFilterChange={setFilter}
      />

      {/* Failing Section */}
      {showRed && groups.red.length > 0 && (
        <section className="mb-4">
          <div className="text-[11px] font-semibold text-ctp-red uppercase tracking-wider mb-1">
            Failing
          </div>
          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2.5">
            {groups.red.map((p) => (
              <PipelineCard
                key={p.id}
                pipeline={p}
                statusColor="red"
                isExpanded={expandedId === p.id}
                onToggleExpand={() => handleToggleExpand(p.id)}
              />
            ))}
          </div>
        </section>
      )}

      {/* Issues Section */}
      {showYellow && groups.yellow.length > 0 && (
        <section className="mb-4">
          <div className="text-[11px] font-semibold text-ctp-yellow uppercase tracking-wider mb-1">
            Other Issues
          </div>
          <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2.5">
            {groups.yellow.map((p) => (
              <PipelineCard
                key={p.id}
                pipeline={p}
                statusColor="yellow"
                isExpanded={expandedId === p.id}
                onToggleExpand={() => handleToggleExpand(p.id)}
              />
            ))}
          </div>
        </section>
      )}

      {/* Passing Section */}
      {showGreen && (
        <section>
          <PassingChips pipelines={groups.green} />
        </section>
      )}

      {/* Empty state */}
      {searched.length === 0 && (
        <div className="text-center py-12 text-ctp-overlay1">
          {allPipelines.length === 0
            ? "No pipelines configured. Run `pnpm discover` to find pipelines."
            : "No pipelines match your search."}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Delete `FilterBar.tsx`**

```bash
rm client/src/components/FilterBar.tsx
```

- [ ] **Step 3: Verify build**

Run: `cd client && npx vite build`
Expected: Build succeeds with no errors

- [ ] **Step 4: Visual smoke test**

Run: `pnpm dev`
Open http://localhost:5173 — verify:
- Light Catppuccin Latte background
- Stats ribbon shows counts
- Failing cards have red left border, sparklines, pill links
- Passing section shows as chips
- Search filters pipelines
- Clicking a stat card filters by status and updates URL
- Clicking a failure card expands to show stage breakdown
- Building pipelines show shimmer bar + badge in their status section

- [ ] **Step 5: Commit**

```bash
git add client/src/components/Dashboard.tsx
git rm client/src/components/FilterBar.tsx
git commit -m "feat: rewrite Dashboard with triage-optimized sectioned layout"
```

---

## Task 12: Run full test suite and fix any issues

**Files:**
- Potentially any of the above

- [ ] **Step 1: Run all backend tests**

Run: `pnpm test`
Expected: All tests pass. If any fail, fix them.

- [ ] **Step 2: Run frontend build**

Run: `cd client && npx vite build`
Expected: Clean build with no warnings

- [ ] **Step 3: Final commit if any fixes needed**

```bash
git add -A
git commit -m "fix: resolve test/build issues from UI redesign"
```
