[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:
Renzo F
2026-06-18 03:33:47 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Backend Developer 1
parent 32b6d72933
commit 6007f47fc9
12 changed files with 676 additions and 17 deletions
+2
View File
@@ -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
View File
@@ -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 &mdash; target: &lt;&nbsp;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} />;
}
+10 -6
View File
@@ -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()} />;
} }
+2
View File
@@ -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 {
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
+28
View File
@@ -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"),
},
},
});
+2
View File
@@ -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):
+5 -1
View File
@@ -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),
+38
View File
@@ -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(
+61 -1
View File
@@ -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)