[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
+61 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
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.cockpit import CockpitService
from roboco.services.strategy_engine import StrategyObservation
from roboco.services.task import TaskService
_IN_PROGRESS = 2
_CLAIMED = 1
_BLOCKED = 3
_BUDGET = 100.0
_SPEND_30D = 150.0
_COMPLETED_30D = 5
_MEDIAN_LEAD_TIME = 12.5
def _agent(role: AgentRole) -> AgentContext:
@@ -44,10 +48,17 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
"blocked": _BLOCKED,
"awaiting_ceo_approval": 1,
}
delivery_stats = {
"completed_30d": _COMPLETED_30D,
"median_lead_time_hours": _MEDIAN_LEAD_TIME,
}
monkeypatch.setattr(
cm,
"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(
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["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
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"]["over_budget"] is True
assert out["pending_pitches"] == 1
@@ -106,6 +119,8 @@ async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
"in_flight": 0,
"blocked": 0,
"awaiting_ceo": 0,
"completed_30d": 0,
"median_lead_time_hours": None,
},
"spend": {
"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)
resp = await croute.cockpit_signals(MagicMock(), _agent(AgentRole.CEO))
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)