mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [d700055f] feat(scorecard): replace StubObjectivesSection with live charter objective cards Delete the StubObjectivesSection placeholder (fake 'Revenue growth'/'Customer retention' labels) and render a real ObjectivesSection with three positional charter objective cards, each showing its live metric against its target: first_pass_yield (90%), median_lead_time_hours (<24h), escaped_defects (0). - CockpitSummary: add optional first_pass_yield and escaped_defects fields (backend companion item not yet shipped; UI renders 'No data yet' until then) - ObjectivesSection: positional mapping objectives[i].metric -> metric i, documented as a positional-by-convention assumption; canonical fallback labels when the charter objectives array is empty/shorter than 3; 'No data yet' italic muted fallback for null/undefined metrics (SpeedSection pattern); DeliveryMetric card styling; first_pass_yield formatted as pctOrDash does - SpeedSection kept as-is; ObjectivesSection references the same median_lead_time_hours value as a peer card alongside the other two - Tests: buildSummary carries the new fields; cover present/missing metrics and absence of the fake stub labels; existing lead-time/null tests adjusted for the now-shared value and multi-card 'No data yet' * [d700055f] docs(scorecard): document live ObjectivesSection and new CockpitSummary fields Add panel/docs/frontend/company-scorecard-card.md covering the new ObjectivesSection: the three positional charter objective cards (first_pass_yield 90%, median_lead_time_hours <24h, escaped_defects 0), the positional-by-convention mapping, canonical fallback labels, the 'No data yet' fallback pattern, and the two new optional CockpitSummary fields with the backend companion-item caveat. Add a Key Symbols row for CompanyScorecardCard/ObjectivesSection in docs/map/panel.md. --------- Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend PM <fe-pm@roboco.tech>
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
"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";
|
|
import { HelpTip } from "@/components/ui/help-tip";
|
|
import { useUsageTimeSeries } from "@/hooks/use-usage";
|
|
import { SpendTrendChart } from "./spend-trend-chart";
|
|
import type { UsageTimePoint } from "@/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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, ...props }: React.ComponentProps<"p">) {
|
|
return (
|
|
<p
|
|
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delivery section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DeliveryMetricProps {
|
|
label: string;
|
|
value: number;
|
|
hint: string;
|
|
}
|
|
|
|
function DeliveryMetric({ label, value, hint }: DeliveryMetricProps) {
|
|
return (
|
|
<HelpTip label={hint}>
|
|
<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>
|
|
</HelpTip>
|
|
);
|
|
}
|
|
|
|
interface DeliverySectionProps {
|
|
delivery: CockpitSummary["delivery"];
|
|
}
|
|
|
|
function DeliverySection({ delivery }: DeliverySectionProps) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<HelpTip label="Live task counts across the pipeline — only 'Done (30 d)' looks backward">
|
|
<SectionLabel>Delivery</SectionLabel>
|
|
</HelpTip>
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<DeliveryMetric
|
|
label="In flight"
|
|
value={delivery.in_flight}
|
|
hint="Tasks currently claimed or in progress"
|
|
/>
|
|
<DeliveryMetric
|
|
label="Blocked"
|
|
value={delivery.blocked}
|
|
hint="Tasks stuck on an external dependency"
|
|
/>
|
|
<DeliveryMetric
|
|
label="Awaiting CEO"
|
|
value={delivery.awaiting_ceo}
|
|
hint="Tasks escalated to you for final approval"
|
|
/>
|
|
<DeliveryMetric
|
|
label="Done (30 d)"
|
|
value={delivery.completed_30d ?? 0}
|
|
hint="Tasks completed in the last 30 days"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spend section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface SpendSectionProps {
|
|
spend: CockpitSummary["spend"];
|
|
spendTrend: UsageTimePoint[] | undefined;
|
|
spendTrendLoading: boolean;
|
|
}
|
|
|
|
function SpendSection({
|
|
spend,
|
|
spendTrend,
|
|
spendTrendLoading,
|
|
}: 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">
|
|
<HelpTip label="Total agent-session cost over the trailing 30 days (Anthropic priced; local/Ollama models cost $0)">
|
|
<span className="text-muted-foreground">30-day spend</span>
|
|
</HelpTip>
|
|
<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">
|
|
<HelpTip label="The 30-day spend extrapolated to a full month">
|
|
<span className="text-muted-foreground">Projected monthly</span>
|
|
</HelpTip>
|
|
<span className="font-medium tabular-nums">
|
|
${projected_monthly_usd.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between text-sm">
|
|
<HelpTip label="Set via operating_policy.monthly_budget_cap on the Goals tab">
|
|
<span className="text-muted-foreground">Monthly cap</span>
|
|
</HelpTip>
|
|
{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>
|
|
<SpendTrendChart data={spendTrend} isLoading={spendTrendLoading} />
|
|
</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">
|
|
<HelpTip label="Hours from task creation to completion, over tasks completed in the last 30 days">
|
|
<span className="text-muted-foreground">Median lead time</span>
|
|
</HelpTip>
|
|
{hasData ? (
|
|
<span className="font-medium tabular-nums">
|
|
{medianLeadTimeHours.toFixed(1)}h median — target:
|
|
< 24h
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground italic">No data yet</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Objectives section — three charter objective cards, each showing a live
|
|
// metric against its target.
|
|
//
|
|
// ponytail: The charter `objectives` field is free text
|
|
// (Record<string, unknown>[]) while the three metrics are hardcoded
|
|
// (first_pass_yield, median_lead_time_hours, escaped_defects). The mapping
|
|
// is positional-by-convention, not derived — one card per charter objective
|
|
// only holds until you edit the Goals tab. Stated assumption, not
|
|
// discovered later.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OBJECTIVE_FALLBACK_LABELS = [
|
|
"Tasks shipped to merge with no human code edits",
|
|
"Median lead time, intake → merged",
|
|
"Critical escaped defects per release",
|
|
] as const;
|
|
|
|
interface ObjectivesSectionProps {
|
|
objectives: Record<string, unknown>[];
|
|
firstPassYield: number | null | undefined;
|
|
medianLeadTimeHours: number | null | undefined;
|
|
escapedDefects: number | null | undefined;
|
|
}
|
|
|
|
function objectiveLabel(
|
|
objectives: Record<string, unknown>[],
|
|
index: number,
|
|
): string {
|
|
const raw = objectives[index]?.metric;
|
|
return typeof raw === "string" && raw.length > 0
|
|
? raw
|
|
: OBJECTIVE_FALLBACK_LABELS[index];
|
|
}
|
|
|
|
interface ObjectiveCardProps {
|
|
label: string;
|
|
hasData: boolean;
|
|
formattedValue: string;
|
|
targetText: string;
|
|
}
|
|
|
|
function ObjectiveCard({
|
|
label,
|
|
hasData,
|
|
formattedValue,
|
|
targetText,
|
|
}: ObjectiveCardProps) {
|
|
return (
|
|
<div className="rounded-lg border bg-card p-3 space-y-1">
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
{hasData ? (
|
|
<span className="font-medium tabular-nums">{formattedValue}</span>
|
|
) : (
|
|
<span className="text-muted-foreground italic">No data yet</span>
|
|
)}
|
|
<span className="text-xs text-muted-foreground">
|
|
target: {targetText}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ObjectivesSection({
|
|
objectives,
|
|
firstPassYield,
|
|
medianLeadTimeHours,
|
|
escapedDefects,
|
|
}: ObjectivesSectionProps) {
|
|
// Show 'No data yet' when a metric is null or undefined — never a fabricated
|
|
// value. Mirrors SpeedSection's hasData guard.
|
|
const fpyHasData = firstPassYield != null;
|
|
const ltHasData = medianLeadTimeHours != null;
|
|
const edHasData = escapedDefects != null;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<SectionLabel>Objectives</SectionLabel>
|
|
<div className="space-y-2">
|
|
<ObjectiveCard
|
|
label={objectiveLabel(objectives, 0)}
|
|
hasData={fpyHasData}
|
|
formattedValue={
|
|
fpyHasData
|
|
? `${((firstPassYield as number) * 100).toFixed(0)}%`
|
|
: ""
|
|
}
|
|
targetText="90%"
|
|
/>
|
|
<ObjectiveCard
|
|
label={objectiveLabel(objectives, 1)}
|
|
hasData={ltHasData}
|
|
formattedValue={
|
|
ltHasData
|
|
? `${(medianLeadTimeHours as number).toFixed(1)}h`
|
|
: ""
|
|
}
|
|
targetText="< 24h"
|
|
/>
|
|
<ObjectiveCard
|
|
label={objectiveLabel(objectives, 2)}
|
|
hasData={edHasData}
|
|
formattedValue={edHasData ? `${escapedDefects as number}` : ""}
|
|
targetText="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scorecard body — rendered when data is available
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ScorecardBodyProps {
|
|
data: CockpitSummary;
|
|
spendTrend: UsageTimePoint[] | undefined;
|
|
spendTrendLoading: boolean;
|
|
}
|
|
|
|
function ScorecardBody({
|
|
data,
|
|
spendTrend,
|
|
spendTrendLoading,
|
|
}: 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}
|
|
spendTrend={spendTrend}
|
|
spendTrendLoading={spendTrendLoading}
|
|
/>
|
|
<SpeedSection medianLeadTimeHours={data.median_lead_time_hours} />
|
|
<ObjectivesSection
|
|
objectives={data.objectives}
|
|
firstPassYield={data.first_pass_yield}
|
|
medianLeadTimeHours={data.median_lead_time_hours}
|
|
escapedDefects={data.escaped_defects}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public export
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function CompanyScorecardCard() {
|
|
const { data, isLoading, isError, refetch } = useQuery({
|
|
queryKey: ["cockpit-summary"],
|
|
queryFn: cockpitApi.summary,
|
|
});
|
|
const { data: spendTrend, isLoading: spendTrendLoading } =
|
|
useUsageTimeSeries("30d");
|
|
|
|
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}
|
|
spendTrend={spendTrend}
|
|
spendTrendLoading={spendTrendLoading}
|
|
/>
|
|
);
|
|
}
|