Files
roboco/roboco/services/cockpit.py
T
6007f47fc9 [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>
2026-06-18 03:33:47 +02:00

97 lines
3.8 KiB
Python

"""CockpitService — the CEO's read-only "is the business winning?" summary.
A pure aggregation over existing data: the charter (goals), delivery counts,
30-day spend vs the charter's budget cap, pending pitches, and the strategy
engine's signals (what needs the CEO). Read-only; no writes, no side effects.
Performance is necessarily a **proxy** (work shipped, spend, signals) until the
CEO greenlights real external launches — every payload is stamped
``basis="proxy"`` so that boundary stays honest.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.pitch import get_pitch_service
from roboco.services.strategy_engine import get_strategy_engine
from roboco.services.task import get_task_service
from roboco.services.usage import get_usage_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _as_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class CockpitService(BaseService):
"""Aggregate company state into one read-only cockpit summary."""
service_name = "cockpit"
async def summary(self) -> dict[str, Any]:
task_svc = get_task_service(self.session)
goals = await get_company_goals_service(self.session).get()
counts = await task_svc.count_by_status()
delivery_stats = await task_svc.get_delivery_stats_30d()
usage_svc = get_usage_service(self.session)
spend = await usage_svc.get_summary("30d")
projection = await usage_svc.get_projection()
observations = await get_strategy_engine(self.session).assess()
pitches = await get_pitch_service(self.session).list_pitches()
operating_policy = goals.get("operating_policy") or {}
budget_cap = _as_float(operating_policy.get("monthly_budget_cap"))
spend_30d = _as_float(spend.get("total_cost_usd")) or 0.0
return {
"basis": "proxy",
"north_star": goals.get("north_star", ""),
"objectives": goals.get("objectives", []),
"delivery": {
"task_counts": counts,
"in_flight": counts.get("in_progress", 0) + counts.get("claimed", 0),
"blocked": counts.get("blocked", 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_30d_usd": round(spend_30d, 2),
"projected_monthly_usd": projection.get("projected_monthly_cost_usd"),
"monthly_budget_cap_usd": budget_cap,
"over_budget": bool(budget_cap is not None and spend_30d > budget_cap),
},
"pending_pitches": sum(1 for p in pitches if p.status == "proposed"),
"signals": [
{"kind": o.kind, "summary": o.summary, "detail": o.detail}
for o in observations
],
}
async def signals(self) -> dict[str, Any]:
"""Just the strategy-engine signals (what needs the CEO) — the lightweight
slice the Dashboard's panel needs, without the full ``summary`` fan-out
(goals / usage / task-counts / pitches)."""
observations = await get_strategy_engine(self.session).assess()
return {
"signals": [
{"kind": o.kind, "summary": o.summary, "detail": o.detail}
for o in observations
],
}
def get_cockpit_service(session: AsyncSession) -> CockpitService:
"""Construct a CockpitService bound to ``session``."""
return CockpitService(session)