feat(FN-4199): complete Step 3 — add capacity risk banner
Fusion-Task-Id: FN-4199 Fusion-Task-Lineage: fd530f3d-ef2e-4684-ab05-5aaab6856f15
This commit is contained in:
49
packages/dashboard/app/components/CapacityRiskBanner.css
Normal file
49
packages/dashboard/app/components/CapacityRiskBanner.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.capacity-risk-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-inline-start: var(--space-xs) solid var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||
}
|
||||
|
||||
.capacity-risk-banner__content {
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.capacity-risk-banner__dismiss {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.capacity-risk-banner__dismiss:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.capacity-risk-banner__dismiss:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.capacity-risk-banner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.capacity-risk-banner__dismiss {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
32
packages/dashboard/app/components/CapacityRiskBanner.tsx
Normal file
32
packages/dashboard/app/components/CapacityRiskBanner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CapacityRiskSignal } from "@fusion/core";
|
||||
import { X } from "lucide-react";
|
||||
import "./CapacityRiskBanner.css";
|
||||
|
||||
interface CapacityRiskBannerProps {
|
||||
signal: CapacityRiskSignal | null;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function CapacityRiskBanner({ signal, onDismiss }: CapacityRiskBannerProps) {
|
||||
if (!signal || !signal.atRisk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`capacity-risk-banner${onDismiss ? " capacity-risk-banner--dismissible" : ""}`} role="status" aria-live="polite">
|
||||
<div className="capacity-risk-banner__content">
|
||||
<strong>Capacity risk:</strong> Todo {signal.todoCount} (threshold {signal.threshold}) · In Progress {signal.inProgressCount} · In Review {signal.inReviewCount} · Idle agents {signal.idleNonEphemeralAgentCount}
|
||||
</div>
|
||||
{onDismiss ? (
|
||||
<button
|
||||
type="button"
|
||||
className="capacity-risk-banner__dismiss touch-target"
|
||||
aria-label="Dismiss capacity warning"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CapacityRiskBanner } from "../CapacityRiskBanner";
|
||||
|
||||
describe("CapacityRiskBanner", () => {
|
||||
it("renders nothing when signal is null", () => {
|
||||
const { container } = render(<CapacityRiskBanner signal={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when signal is not at risk", () => {
|
||||
const { container } = render(
|
||||
<CapacityRiskBanner
|
||||
signal={{
|
||||
atRisk: false,
|
||||
todoCount: 10,
|
||||
inProgressCount: 2,
|
||||
inReviewCount: 1,
|
||||
idleNonEphemeralAgentCount: 1,
|
||||
threshold: 20,
|
||||
reason: "ok",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders counts and threshold when at risk", () => {
|
||||
render(
|
||||
<CapacityRiskBanner
|
||||
signal={{
|
||||
atRisk: true,
|
||||
todoCount: 21,
|
||||
inProgressCount: 3,
|
||||
inReviewCount: 2,
|
||||
idleNonEphemeralAgentCount: 0,
|
||||
threshold: 20,
|
||||
reason: "todo-exceeds-threshold-and-no-idle-agents",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Todo 21/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/threshold 20/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/In Progress 3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/In Review 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Idle agents 0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onDismiss when dismiss button is clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<CapacityRiskBanner
|
||||
signal={{
|
||||
atRisk: true,
|
||||
todoCount: 21,
|
||||
inProgressCount: 3,
|
||||
inReviewCount: 2,
|
||||
idleNonEphemeralAgentCount: 0,
|
||||
threshold: 20,
|
||||
reason: "todo-exceeds-threshold-and-no-idle-agents",
|
||||
}}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /dismiss capacity warning/i }));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user