mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[ef7b7cb9] Add Company Scorecard to Business Goals tab (#212)
* [0c7a4732] feat(cockpit): add completed_30d and median_lead_time_hours to delivery summary (#207) (#210) - Extend DeliverySummary schema with completed_30d: int = 0 and median_lead_time_hours: float | None = None fields - Add TaskService.get_delivery_stats_30d() that queries tasks completed in the last 30 days and computes statistics.median of lead times - Update CockpitService.summary() to source both new keys from get_delivery_stats_30d() and include them in the delivery dict - Update tests: mock new method in _patch(), assert new fields in test_summary_aggregates, fix test_route_ok_for_ceo dict, add three new unit tests for get_delivery_stats_30d (empty, multi, single) Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [d2647edf] Frontend: Build CompanyScorecard card on Goals tab (#211) * [12569f37] Extend CockpitSummary type and build CompanyScorecardCard component (#208) * [12569f37] feat(cockpit): extend CockpitSummary type with completed_30d and median_lead_time_hours Add optional delivery.completed_30d (number) and top-level median_lead_time_hours (number | null, optional) to CockpitSummary interface in panel/src/lib/api/cockpit.ts so the API shape captures the new backend fields without breaking existing consumers. * [12569f37] feat(business): add CompanyScorecardCard component Create panel/src/components/business/company-scorecard-card.tsx exporting CompanyScorecardCard. The card fetches /cockpit/summary via useQuery and renders five always-visible sections: - Delivery: in_flight, blocked, awaiting_ceo, completed_30d tiles (all from API response; no hardcoded numbers) - Spend: 30d spend + projected monthly; muted 'No budget cap set' when cap is null; red/destructive styling only when cap is a non-null number AND over_budget is true - Speed: 'X.Xh median — target: < 24h' when value present; 'No data yet' when null/undefined; '0h' never rendered - Two stub Objectives with 'Not tracked yet' label, muted text, and dashed-border styling — no fabricated numeric values - Loading: three grouped Skeleton blocks - Error: OfflineState with title 'Could not load scorecard data' --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [f1f5cded] Integrate CompanyScorecardCard into GoalsTab and pass quality gate (#209) * [f1f5cded] feat(cockpit): extend CockpitSummary with completed_30d and median_lead_time_hours Add optional delivery.completed_30d (number) and top-level median_lead_time_hours (number | null, optional) to CockpitSummary interface in panel/src/lib/api/cockpit.ts. Backward compatible. * [f1f5cded] feat(business): add CompanyScorecardCard component Create panel/src/components/business/company-scorecard-card.tsx exporting CompanyScorecardCard. Fetches /cockpit/summary via useQuery and renders five always-visible sections: Delivery (no hardcoded numbers), Spend (muted 'No budget cap set' when null; red only when cap set AND over_budget true), Speed (X.Xh median or 'No data yet'), two stub Objectives with dashed border and 'Not tracked yet' label. Loading: three skeleton groups. Error: OfflineState 'Could not load scorecard data'. * [f1f5cded] feat(goals-tab): integrate CompanyScorecardCard into GoalsTab Import and render CompanyScorecardCard below the charter form in goals-tab.tsx. The scorecard fetches its own data independently so all loading/error states are handled per-card. Both cards are always rendered in the Goals tab. * [f1f5cded] fix(scorecard-tests): add vitest framework and CompanyScorecardCard test suite Install vitest + @testing-library/react + @testing-library/jest-dom + jsdom + @vitest/coverage-v8 as devDependencies in panel/. Add panel/vitest.config.ts (jsdom env, @/* alias, coverage on company-scorecard-card.tsx with 80% threshold). Add panel/src/test/setup.ts (jest-dom matchers). Update panel/package.json: add test, test:watch, typecheck scripts. Update panel/eslint.config.mjs: ignore coverage/ directory to keep lint clean of generated files. Write panel/src/components/business/__tests__/company-scorecard-card.test.tsx with 8 tests covering all 7 AC2 scenarios: - loading skeleton rendered - OfflineState on error - OfflineState when data undefined - delivery counts from mock data - spend 'No budget cap set' when cap null - spend destructive styling when cap non-null and over_budget true - speed 'No data yet' when lead time null - speed formatted value when lead time present pnpm lint: 0 errors pnpm typecheck: 0 errors pnpm test: 8/8 pass coverage: stmts 95% branches 90% fns 91% lines 95% --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
This commit is contained in:
co-authored by
Frontend Developer 1
Backend Developer 1
parent
32b6d72933
commit
6007f47fc9
@@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Generated coverage output — not source files
|
||||||
|
"coverage/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -7,7 +7,10 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -53,14 +56,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tanstack/react-query-devtools": "^5.91.2",
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.0.8",
|
"@types/node": "^25.0.8",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { CockpitSummary } from "@/lib/api/cockpit";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock @tanstack/react-query so we can control useQuery return values.
|
||||||
|
// vi.hoisted() ensures the variable exists before vi.mock() is hoisted.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { mockUseQuery } = vi.hoisted(() => ({
|
||||||
|
mockUseQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useQuery: mockUseQuery,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the cockpit API module so no real HTTP calls occur
|
||||||
|
vi.mock("@/lib/api/cockpit", () => ({
|
||||||
|
cockpitApi: {
|
||||||
|
summary: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import component AFTER mocks are set up
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { CompanyScorecardCard } from "../company-scorecard-card";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSummary(overrides: Partial<CockpitSummary> = {}): CockpitSummary {
|
||||||
|
return {
|
||||||
|
basis: "test",
|
||||||
|
north_star: "Test north star",
|
||||||
|
objectives: [],
|
||||||
|
delivery: {
|
||||||
|
task_counts: {},
|
||||||
|
in_flight: 5,
|
||||||
|
blocked: 2,
|
||||||
|
awaiting_ceo: 1,
|
||||||
|
completed_30d: 12,
|
||||||
|
},
|
||||||
|
spend: {
|
||||||
|
spend_30d_usd: 42.5,
|
||||||
|
projected_monthly_usd: null,
|
||||||
|
monthly_budget_cap_usd: null,
|
||||||
|
over_budget: false,
|
||||||
|
},
|
||||||
|
pending_pitches: 0,
|
||||||
|
signals: [],
|
||||||
|
median_lead_time_hours: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQueryState(state: {
|
||||||
|
isLoading?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
data?: CockpitSummary | undefined;
|
||||||
|
}) {
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
data: state.data,
|
||||||
|
isLoading: state.isLoading ?? false,
|
||||||
|
isError: state.isError ?? false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("CompanyScorecardCard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 1: Loading skeleton
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("renders skeleton groups while loading", () => {
|
||||||
|
setQueryState({ isLoading: true });
|
||||||
|
|
||||||
|
const { container } = render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
// Skeleton elements use data-slot="skeleton"
|
||||||
|
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should NOT show error or data content
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Could not load scorecard data")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Company Scorecard")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 2: Error / OfflineState
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("renders OfflineState when query errors", () => {
|
||||||
|
setQueryState({ isError: true, data: undefined });
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("Could not load scorecard data")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Skeleton and scorecard body should not appear
|
||||||
|
expect(screen.queryByText("Company Scorecard")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders OfflineState when data is undefined (no error flag)", () => {
|
||||||
|
setQueryState({ isError: false, data: undefined });
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
// When data is falsy the component falls through to the OfflineState branch
|
||||||
|
expect(
|
||||||
|
screen.getByText("Could not load scorecard data")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 3: Delivery counts from mock data
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("shows delivery counts from mock data", () => {
|
||||||
|
setQueryState({
|
||||||
|
data: buildSummary({
|
||||||
|
delivery: {
|
||||||
|
task_counts: {},
|
||||||
|
in_flight: 7,
|
||||||
|
blocked: 3,
|
||||||
|
awaiting_ceo: 2,
|
||||||
|
completed_30d: 15,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
// All four metric values must appear
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument(); // in_flight
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument(); // blocked
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument(); // awaiting_ceo
|
||||||
|
expect(screen.getByText("15")).toBeInTheDocument(); // completed_30d
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
expect(screen.getByText("In flight")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Blocked")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Awaiting CEO")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Done (30 d)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 4: Spend — 'No budget cap set' when cap is null
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("shows 'No budget cap set' when monthly_budget_cap_usd is null", () => {
|
||||||
|
setQueryState({
|
||||||
|
data: buildSummary({
|
||||||
|
spend: {
|
||||||
|
spend_30d_usd: 10.0,
|
||||||
|
projected_monthly_usd: null,
|
||||||
|
monthly_budget_cap_usd: null,
|
||||||
|
over_budget: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No budget cap set")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 5: Spend — destructive styling when cap is set and over_budget
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("applies destructive styling when cap is non-null and over_budget is true", () => {
|
||||||
|
setQueryState({
|
||||||
|
data: buildSummary({
|
||||||
|
spend: {
|
||||||
|
spend_30d_usd: 200.0,
|
||||||
|
projected_monthly_usd: 220.0,
|
||||||
|
monthly_budget_cap_usd: 150.0,
|
||||||
|
over_budget: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
// The cap value element should carry text-destructive class
|
||||||
|
const capElement = screen.getByText(/\$150\.00/);
|
||||||
|
expect(capElement).toHaveClass("text-destructive");
|
||||||
|
|
||||||
|
// Over-budget indicator text is also visible
|
||||||
|
expect(screen.getByText("(over budget)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 6: Speed — 'No data yet' when lead time is null
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("shows 'No data yet' when median_lead_time_hours is null", () => {
|
||||||
|
setQueryState({
|
||||||
|
data: buildSummary({ median_lead_time_hours: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
expect(screen.getByText("No data yet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// AC2 Scenario 7: Speed — formatted value when lead time is present
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
it("shows formatted lead time when median_lead_time_hours is present", () => {
|
||||||
|
setQueryState({
|
||||||
|
data: buildSummary({ median_lead_time_hours: 18.7 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CompanyScorecardCard />);
|
||||||
|
|
||||||
|
// Component renders `{value.toFixed(1)}h median — target: < 24h`
|
||||||
|
expect(screen.getByText(/18\.7h/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 'No data yet' must NOT appear
|
||||||
|
expect(screen.queryByText("No data yet")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { cockpitApi, type CockpitSummary } from "@/lib/api/cockpit";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { OfflineState } from "@/components/ui/offline-state";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loading skeleton — three grouped skeleton blocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ScorecardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-40 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Group 1: Delivery + Spend */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
|
<Skeleton className="h-16 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Group 2: Spend */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-12 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
{/* Group 3: Speed + Objectives */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
<Skeleton className="h-10 rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-24 mt-2" />
|
||||||
|
<Skeleton className="h-10 rounded-lg" />
|
||||||
|
<Skeleton className="h-10 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Section header helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delivery section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeliveryMetricProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryMetric({ label, value }: DeliveryMetricProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeliverySectionProps {
|
||||||
|
delivery: CockpitSummary["delivery"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliverySection({ delivery }: DeliverySectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SectionLabel>Delivery</SectionLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<DeliveryMetric label="In flight" value={delivery.in_flight} />
|
||||||
|
<DeliveryMetric label="Blocked" value={delivery.blocked} />
|
||||||
|
<DeliveryMetric label="Awaiting CEO" value={delivery.awaiting_ceo} />
|
||||||
|
<DeliveryMetric
|
||||||
|
label="Done (30 d)"
|
||||||
|
value={delivery.completed_30d ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spend section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SpendSectionProps {
|
||||||
|
spend: CockpitSummary["spend"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpendSection({ spend }: SpendSectionProps) {
|
||||||
|
const { monthly_budget_cap_usd, spend_30d_usd, projected_monthly_usd, over_budget } =
|
||||||
|
spend;
|
||||||
|
|
||||||
|
// Red/destructive only when cap is a non-null number AND over_budget is true
|
||||||
|
const isOverBudget = monthly_budget_cap_usd !== null && over_budget;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SectionLabel>Spend</SectionLabel>
|
||||||
|
<div className="rounded-lg border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">30-day spend</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
${spend_30d_usd.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{projected_monthly_usd !== null && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Projected monthly</span>
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
${projected_monthly_usd.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Monthly cap</span>
|
||||||
|
{monthly_budget_cap_usd === null ? (
|
||||||
|
<span className="text-muted-foreground italic">No budget cap set</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isOverBudget
|
||||||
|
? "font-semibold text-destructive tabular-nums"
|
||||||
|
: "font-medium tabular-nums"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
${monthly_budget_cap_usd.toFixed(2)}
|
||||||
|
{isOverBudget && (
|
||||||
|
<span className="ml-1 text-xs">(over budget)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Speed section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SpeedSectionProps {
|
||||||
|
medianLeadTimeHours: number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) {
|
||||||
|
// Show 'No data yet' when null or undefined. Never render '0h'.
|
||||||
|
const hasData = medianLeadTimeHours != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SectionLabel>Speed</SectionLabel>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Median lead time</span>
|
||||||
|
{hasData ? (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{medianLeadTimeHours.toFixed(1)}h median — target: < 24h
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">No data yet</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub objectives section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StubObjectivesSection() {
|
||||||
|
const stubs = [
|
||||||
|
{ id: "obj-1", label: "Revenue growth" },
|
||||||
|
{ id: "obj-2", label: "Customer retention" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SectionLabel>Objectives</SectionLabel>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stubs.map((stub) => (
|
||||||
|
<div
|
||||||
|
key={stub.id}
|
||||||
|
className="rounded-lg border border-dashed p-3 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">{stub.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground italic">
|
||||||
|
Not tracked yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scorecard body — rendered when data is available
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ScorecardBodyProps {
|
||||||
|
data: CockpitSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorecardBody({ data }: ScorecardBodyProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Company Scorecard</CardTitle>
|
||||||
|
<CardDescription>Live performance against the charter</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<DeliverySection delivery={data.delivery} />
|
||||||
|
<SpendSection spend={data.spend} />
|
||||||
|
<SpeedSection medianLeadTimeHours={data.median_lead_time_hours} />
|
||||||
|
<StubObjectivesSection />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function CompanyScorecardCard() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["cockpit-summary"],
|
||||||
|
queryFn: cockpitApi.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <ScorecardSkeleton />;
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<OfflineState
|
||||||
|
title="Could not load scorecard data"
|
||||||
|
description="The cockpit summary could not be fetched. Check the backend is running."
|
||||||
|
onRetry={() => void refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ScorecardBody data={data} />;
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { OfflineState } from "@/components/ui/offline-state";
|
import { OfflineState } from "@/components/ui/offline-state";
|
||||||
|
import { CompanyScorecardCard } from "@/components/business/company-scorecard-card";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -358,17 +359,20 @@ export function GoalsTab() {
|
|||||||
queryFn: companyGoalsApi.get,
|
queryFn: companyGoalsApi.get,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <GoalsTabSkeleton />;
|
|
||||||
|
|
||||||
if (isError || !data) {
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<GoalsTabSkeleton />
|
||||||
|
) : isError || !data ? (
|
||||||
<OfflineState
|
<OfflineState
|
||||||
title="Failed to load company goals"
|
title="Failed to load company goals"
|
||||||
description="Could not reach the orchestrator API. Check the backend is running."
|
description="Could not reach the orchestrator API. Check the backend is running."
|
||||||
onRetry={() => void refetch()}
|
onRetry={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<GoalsForm goals={data} refetch={() => void refetch()} />
|
||||||
|
)}
|
||||||
|
<CompanyScorecardCard />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <GoalsForm goals={data} refetch={() => void refetch()} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface CockpitSummary {
|
|||||||
in_flight: number;
|
in_flight: number;
|
||||||
blocked: number;
|
blocked: number;
|
||||||
awaiting_ceo: number;
|
awaiting_ceo: number;
|
||||||
|
completed_30d?: number;
|
||||||
};
|
};
|
||||||
spend: {
|
spend: {
|
||||||
spend_30d_usd: number;
|
spend_30d_usd: number;
|
||||||
@@ -18,6 +19,7 @@ export interface CockpitSummary {
|
|||||||
};
|
};
|
||||||
pending_pitches: number;
|
pending_pitches: number;
|
||||||
signals: CockpitSignal[];
|
signals: CockpitSignal[];
|
||||||
|
median_lead_time_hours?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CockpitSignal {
|
export interface CockpitSignal {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
include: [
|
||||||
|
"src/components/business/company-scorecard-card.tsx",
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
reporter: ["text", "lcov", "json-summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ class DeliverySummary(BaseModel):
|
|||||||
in_flight: int
|
in_flight: int
|
||||||
blocked: int
|
blocked: int
|
||||||
awaiting_ceo: int
|
awaiting_ceo: int
|
||||||
|
completed_30d: int = 0
|
||||||
|
median_lead_time_hours: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class SpendSummary(BaseModel):
|
class SpendSummary(BaseModel):
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ class CockpitService(BaseService):
|
|||||||
service_name = "cockpit"
|
service_name = "cockpit"
|
||||||
|
|
||||||
async def summary(self) -> dict[str, Any]:
|
async def summary(self) -> dict[str, Any]:
|
||||||
|
task_svc = get_task_service(self.session)
|
||||||
goals = await get_company_goals_service(self.session).get()
|
goals = await get_company_goals_service(self.session).get()
|
||||||
counts = await get_task_service(self.session).count_by_status()
|
counts = await task_svc.count_by_status()
|
||||||
|
delivery_stats = await task_svc.get_delivery_stats_30d()
|
||||||
usage_svc = get_usage_service(self.session)
|
usage_svc = get_usage_service(self.session)
|
||||||
spend = await usage_svc.get_summary("30d")
|
spend = await usage_svc.get_summary("30d")
|
||||||
projection = await usage_svc.get_projection()
|
projection = await usage_svc.get_projection()
|
||||||
@@ -60,6 +62,8 @@ class CockpitService(BaseService):
|
|||||||
"in_flight": counts.get("in_progress", 0) + counts.get("claimed", 0),
|
"in_flight": counts.get("in_progress", 0) + counts.get("claimed", 0),
|
||||||
"blocked": counts.get("blocked", 0),
|
"blocked": counts.get("blocked", 0),
|
||||||
"awaiting_ceo": counts.get("awaiting_ceo_approval", 0),
|
"awaiting_ceo": counts.get("awaiting_ceo_approval", 0),
|
||||||
|
"completed_30d": delivery_stats["completed_30d"],
|
||||||
|
"median_lead_time_hours": delivery_stats["median_lead_time_hours"],
|
||||||
},
|
},
|
||||||
"spend": {
|
"spend": {
|
||||||
"spend_30d_usd": round(spend_30d, 2),
|
"spend_30d_usd": round(spend_30d, 2),
|
||||||
|
|||||||
@@ -5404,6 +5404,44 @@ class TaskService(BaseService):
|
|||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return {row[0].value: row[1] for row in result.all()}
|
return {row[0].value: row[1] for row in result.all()}
|
||||||
|
|
||||||
|
async def get_delivery_stats_30d(self) -> dict[str, Any]:
|
||||||
|
"""Return completed-task count and median lead time for the last 30 days.
|
||||||
|
|
||||||
|
Queries tasks WHERE status=completed AND completed_at IS NOT NULL AND
|
||||||
|
completed_at >= now()-30d. Lead time is ``completed_at - created_at``
|
||||||
|
expressed in hours; the median is computed with :func:`statistics.median`.
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"completed_30d": int,
|
||||||
|
"median_lead_time_hours": float | None, # None when no tasks
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=30)
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(TaskTable.created_at, TaskTable.completed_at).where(
|
||||||
|
TaskTable.status == TaskStatus.COMPLETED,
|
||||||
|
TaskTable.completed_at.is_not(None),
|
||||||
|
TaskTable.completed_at >= cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
completed_30d = len(rows)
|
||||||
|
median_lead_time_hours: float | None = None
|
||||||
|
if rows:
|
||||||
|
lead_times = [
|
||||||
|
(row.completed_at - row.created_at).total_seconds() / 3600
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
median_lead_time_hours = float(statistics.median(lead_times))
|
||||||
|
return {
|
||||||
|
"completed_30d": completed_30d,
|
||||||
|
"median_lead_time_hours": median_lead_time_hours,
|
||||||
|
}
|
||||||
|
|
||||||
async def count_by_team(self) -> dict[str, int]:
|
async def count_by_team(self) -> dict[str, int]:
|
||||||
"""Count tasks by team."""
|
"""Count tasks by team."""
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -15,12 +16,15 @@ from roboco.models.permissions import AgentContext
|
|||||||
from roboco.services import cockpit as cm
|
from roboco.services import cockpit as cm
|
||||||
from roboco.services.cockpit import CockpitService
|
from roboco.services.cockpit import CockpitService
|
||||||
from roboco.services.strategy_engine import StrategyObservation
|
from roboco.services.strategy_engine import StrategyObservation
|
||||||
|
from roboco.services.task import TaskService
|
||||||
|
|
||||||
_IN_PROGRESS = 2
|
_IN_PROGRESS = 2
|
||||||
_CLAIMED = 1
|
_CLAIMED = 1
|
||||||
_BLOCKED = 3
|
_BLOCKED = 3
|
||||||
_BUDGET = 100.0
|
_BUDGET = 100.0
|
||||||
_SPEND_30D = 150.0
|
_SPEND_30D = 150.0
|
||||||
|
_COMPLETED_30D = 5
|
||||||
|
_MEDIAN_LEAD_TIME = 12.5
|
||||||
|
|
||||||
|
|
||||||
def _agent(role: AgentRole) -> AgentContext:
|
def _agent(role: AgentRole) -> AgentContext:
|
||||||
@@ -44,10 +48,17 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"blocked": _BLOCKED,
|
"blocked": _BLOCKED,
|
||||||
"awaiting_ceo_approval": 1,
|
"awaiting_ceo_approval": 1,
|
||||||
}
|
}
|
||||||
|
delivery_stats = {
|
||||||
|
"completed_30d": _COMPLETED_30D,
|
||||||
|
"median_lead_time_hours": _MEDIAN_LEAD_TIME,
|
||||||
|
}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cm,
|
cm,
|
||||||
"get_task_service",
|
"get_task_service",
|
||||||
lambda _s: MagicMock(count_by_status=AsyncMock(return_value=counts)),
|
lambda _s: MagicMock(
|
||||||
|
count_by_status=AsyncMock(return_value=counts),
|
||||||
|
get_delivery_stats_30d=AsyncMock(return_value=delivery_stats),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
usage = MagicMock(
|
usage = MagicMock(
|
||||||
get_summary=AsyncMock(return_value={"total_cost_usd": _SPEND_30D}),
|
get_summary=AsyncMock(return_value={"total_cost_usd": _SPEND_30D}),
|
||||||
@@ -82,6 +93,8 @@ async def test_summary_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert out["north_star"] == "Win the market"
|
assert out["north_star"] == "Win the market"
|
||||||
assert out["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
|
assert out["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
|
||||||
assert out["delivery"]["blocked"] == _BLOCKED
|
assert out["delivery"]["blocked"] == _BLOCKED
|
||||||
|
assert out["delivery"]["completed_30d"] == _COMPLETED_30D
|
||||||
|
assert out["delivery"]["median_lead_time_hours"] == _MEDIAN_LEAD_TIME
|
||||||
assert out["spend"]["spend_30d_usd"] == _SPEND_30D
|
assert out["spend"]["spend_30d_usd"] == _SPEND_30D
|
||||||
assert out["spend"]["over_budget"] is True
|
assert out["spend"]["over_budget"] is True
|
||||||
assert out["pending_pitches"] == 1
|
assert out["pending_pitches"] == 1
|
||||||
@@ -106,6 +119,8 @@ async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"in_flight": 0,
|
"in_flight": 0,
|
||||||
"blocked": 0,
|
"blocked": 0,
|
||||||
"awaiting_ceo": 0,
|
"awaiting_ceo": 0,
|
||||||
|
"completed_30d": 0,
|
||||||
|
"median_lead_time_hours": None,
|
||||||
},
|
},
|
||||||
"spend": {
|
"spend": {
|
||||||
"spend_30d_usd": 0.0,
|
"spend_30d_usd": 0.0,
|
||||||
@@ -153,3 +168,48 @@ async def test_signals_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
|
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
|
||||||
resp = await croute.cockpit_signals(MagicMock(), _agent(AgentRole.CEO))
|
resp = await croute.cockpit_signals(MagicMock(), _agent(AgentRole.CEO))
|
||||||
assert resp.signals[0].kind == "idle"
|
assert resp.signals[0].kind == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_delivery_stats_30d_no_tasks() -> None:
|
||||||
|
"""When no completed tasks exist in the 30d window, returns zeros and None."""
|
||||||
|
session = MagicMock()
|
||||||
|
execute_result = MagicMock()
|
||||||
|
execute_result.all.return_value = []
|
||||||
|
session.execute = AsyncMock(return_value=execute_result)
|
||||||
|
stats = await TaskService(session).get_delivery_stats_30d()
|
||||||
|
assert stats["completed_30d"] == 0
|
||||||
|
assert stats["median_lead_time_hours"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_delivery_stats_30d_with_tasks() -> None:
|
||||||
|
"""With completed tasks, returns count and median lead time in hours."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
# Three tasks with lead times of 2h, 4h, 6h → median = 4h
|
||||||
|
rows = [
|
||||||
|
MagicMock(created_at=now - timedelta(hours=2), completed_at=now),
|
||||||
|
MagicMock(created_at=now - timedelta(hours=4), completed_at=now),
|
||||||
|
MagicMock(created_at=now - timedelta(hours=6), completed_at=now),
|
||||||
|
]
|
||||||
|
session = MagicMock()
|
||||||
|
execute_result = MagicMock()
|
||||||
|
execute_result.all.return_value = rows
|
||||||
|
session.execute = AsyncMock(return_value=execute_result)
|
||||||
|
stats = await TaskService(session).get_delivery_stats_30d()
|
||||||
|
assert stats["completed_30d"] == len(rows)
|
||||||
|
assert stats["median_lead_time_hours"] == pytest.approx(4.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_delivery_stats_30d_single_task() -> None:
|
||||||
|
"""With a single task, median equals that task's lead time."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
rows = [MagicMock(created_at=now - timedelta(hours=10), completed_at=now)]
|
||||||
|
session = MagicMock()
|
||||||
|
execute_result = MagicMock()
|
||||||
|
execute_result.all.return_value = rows
|
||||||
|
session.execute = AsyncMock(return_value=execute_result)
|
||||||
|
stats = await TaskService(session).get_delivery_stats_30d()
|
||||||
|
assert stats["completed_30d"] == 1
|
||||||
|
assert stats["median_lead_time_hours"] == pytest.approx(10.0, abs=0.01)
|
||||||
|
|||||||
Reference in New Issue
Block a user