mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [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>
97 lines
3.8 KiB
Python
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)
|