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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user