From b3dda00e416587f42cec1a441c55ac379ab4a48e Mon Sep 17 00:00:00 2001 From: "roboco-app[bot]" <302741806+roboco-app[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:13:22 +0000 Subject: [PATCH] [d700055f] Replace StubObjectivesSection with live charter objective cards (#702) (#704) * [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 Co-authored-by: Frontend Documenter Co-authored-by: Frontend PM --- docs/map/panel.md | 1 + panel/docs/frontend/company-scorecard-card.md | 89 ++++++++++++ .../__tests__/company-scorecard-card.test.tsx | 104 +++++++++++++- .../business/company-scorecard-card.tsx | 127 +++++++++++++++--- panel/src/lib/api/cockpit.ts | 7 + 5 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 panel/docs/frontend/company-scorecard-card.md diff --git a/docs/map/panel.md b/docs/map/panel.md index 25c3a8c2..833f7345 100644 --- a/docs/map/panel.md +++ b/docs/map/panel.md @@ -125,6 +125,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.25.0) is the s | `WorkSessionsView` | comp | `components/work-sessions/work-sessions-view.tsx` | Git page's "Work Sessions" tab body; search/status filters are LOCAL `useState`, not URL params | | `SessionTrendChart` | comp | `components/work-sessions/session-trend-chart.tsx` | Active-session start-time histogram (hourly/daily bucketing); honestly labeled active-only, no history beyond `GET /work-sessions` | | `CostTrendChart` / `SpendTrendChart` | comp | `components/dashboard/cost-trend-chart.tsx` / `components/business/spend-trend-chart.tsx` | Daily-spend area charts off `GET /usage/time-series`; 7d on Overview (`CommandCenter`), 30d on the Business scorecard (`CompanyScorecardCard`) | +| `CompanyScorecardCard` / `ObjectivesSection` | comp | `components/business/company-scorecard-card.tsx` | Business page Scorecard tab body; four sections off one `cockpitApi.summary()` call. `ObjectivesSection` renders three positional charter objective cards (`first_pass_yield` 90%, `median_lead_time_hours` <24h, `escaped_defects` 0) — positional-by-convention mapping documented in a `ponytail:` code comment; "No data yet" fallback for null/undefined metrics (mirrors `SpeedSection`). `CockpitSummary` gained optional `first_pass_yield?: number\|null` and `escaped_defects?: number\|null` (backend companion item not yet shipped — see `panel/docs/frontend/company-scorecard-card.md`). | | `ProductCardGrid` | comp | `components/products/product-card-grid.tsx` | Workstation Products card-grid view; reuses `CellsList`/`ProgressCell` (exported from `product-table.tsx`) | | `ProjectCardGrid` | comp | `components/projects/project-card-grid.tsx` | Workstation Projects card-grid view; reuses `getExternalUrl`/badge renderers (exported from `project-table.tsx`) | | `sortProducts` | fn | `components/products/products-view.tsx` | Pure client-side sort (name/cell count) for the Products card grid; direction rides a comparator multiplier, not sort-then-reverse, so ties keep their relative order | diff --git a/panel/docs/frontend/company-scorecard-card.md b/panel/docs/frontend/company-scorecard-card.md new file mode 100644 index 00000000..b135e03f --- /dev/null +++ b/panel/docs/frontend/company-scorecard-card.md @@ -0,0 +1,89 @@ +# Company Scorecard Card + +## Overview + +`CompanyScorecardCard` (`panel/src/components/business/company-scorecard-card.tsx`) is the live charter-performance card on the Business page's **Scorecard** tab (`app/(dashboard)/business/page.tsx`, `?tab=scorecard`). Its subtitle reads "Live performance against the charter". It renders four sections off a single `cockpitApi.summary()` call (`GET /api/cockpit/summary`, typed by `CockpitSummary` in `panel/src/lib/api/cockpit.ts`): + +1. **Delivery** — live task counts across the pipeline (`DeliverySection`). +2. **Spend** — 30-day spend, projected monthly, monthly cap, plus the `SpendTrendChart` (`SpendSection`). +3. **Speed** — median lead time, intake → merged (`SpeedSection`). +4. **Objectives** — three charter objective cards, each showing a live metric against its target (`ObjectivesSection`). + +Loading, error, and empty states are handled at the card level: `ScorecardSkeleton` while the query loads, `OfflineState` with a retry button on error or missing data. + +## Objectives section + +`ObjectivesSection` replaced the former `StubObjectivesSection`, a placeholder that rendered fake "Revenue growth" / "Customer retention" labels with a "Not tracked yet" badge. The stub actively misrepresented charter performance; the live section renders the three real charter objectives against their live metrics. + +### The three objective cards + +Each card shows the objective label, the live metric value (or "No data yet"), and the target: + +| Position | Charter objective (`objectives[i].metric`) | Target | Metric field | Format | +|---|---|---|---|---| +| 0 | Tasks shipped to merge with no human code edits | `90%` | `first_pass_yield` | percentage, `(v * 100).toFixed(0)%` | +| 1 | Median lead time, intake → merged | `< 24h` | `median_lead_time_hours` | `{value.toFixed(1)}h` | +| 2 | Critical escaped defects per release | `0` | `escaped_defects` | count, `${value}` | + +The lead-time metric is the same value `SpeedSection` renders in the Speed section above — `ObjectivesSection` reads `data.median_lead_time_hours` a second time and presents it as an objective card alongside the other two. `SpeedSection` is intentionally kept as-is. + +### Positional-by-convention mapping + +The charter `objectives` field on `CockpitSummary` is `Record[]` — free-form text from the Goals tab, each entry carrying `{metric, target, status}`. The three metrics above are hardcoded by contrast. The mapping is **positional by convention, not derived**: `objectives[i].metric` supplies the label for card `i`, and card `i` reads the `i`-th hardcoded metric. This holds only while the charter has exactly three objectives in the canonical order; editing the Goals tab can desync labels from metrics. The assumption is documented in a `ponytail:` code comment at the top of the `ObjectivesSection` block in `company-scorecard-card.tsx` — a stated assumption, not something discovered later. + +`objectiveLabel(objectives, index)` returns `objectives[index].metric` when it is a non-empty string, falling back to `OBJECTIVE_FALLBACK_LABELS[index]` (the three canonical labels above) when the array is empty or shorter than three. A fabricated label is never rendered. + +### "No data yet" fallback + +Each card guards its metric with a `hasData` check (`value != null`, covering both `null` and `undefined`). When the metric is absent the card renders "No data yet" in italic muted text instead of a value — mirroring the `SpeedSection` pattern. This is the contract for the two new fields (`first_pass_yield`, `escaped_defects`): until the backend companion item ships them on the `/cockpit/summary` response, both are absent and both cards show "No data yet". The card never fabricates a value or a label. + +## CockpitSummary type changes + +`panel/src/lib/api/cockpit.ts` gained two optional fields on `CockpitSummary`: + +```typescript +// Fraction of tasks shipped to merge with no human code edits (0–1). +// Backend companion item adds this; until it ships the field is absent +// and the UI renders 'No data yet'. Formatted as a percentage, matching +// the phone's pctOrDash convention in tg-metrics-tab.tsx. +first_pass_yield?: number | null; + +// Count of critical escaped defects per release (backend companion item). +escaped_defects?: number | null; +``` + +Both are optional and nullable so the UI degrades cleanly while the backend companion item is outstanding. `first_pass_yield` is a 0–1 fraction formatted as a percentage, matching the phone's `pctOrDash(scorecard.first_pass_yield)` convention in `tg-metrics-tab.tsx`. `median_lead_time_hours` is unchanged — it already existed at the top level of the type and `SpeedSection` reads it from there. + +The backend companion item (out of scope for this change) is what adds `first_pass_yield` and `escaped_defects` to the `/api/cockpit/summary` response. Until it lands, the two objective cards for those metrics show "No data yet". + +## Card structure + +``` +CompanyScorecardCard +└── ScorecardBody (data, spendTrend, spendTrendLoading) + ├── DeliverySection (data.delivery) + ├── SpendSection (data.spend, spendTrend, spendTrendLoading) + ├── SpeedSection (data.median_lead_time_hours) + └── ObjectivesSection (data.objectives, first_pass_yield, + median_lead_time_hours, escaped_defects) + └── ObjectiveCard × 3 (label, hasData, formattedValue, targetText) +``` + +`ObjectiveCard` is a presentational leaf: a `rounded-lg border bg-card p-3` card (matching `DeliveryMetric`'s styling) with the label on top and a `flex justify-between` row holding the value (or "No data yet") and the `target: {targetText}` annotation. + +## Tests + +`panel/src/components/business/__tests__/company-scorecard-card.test.tsx` covers the Objectives section: + +- Three objective cards render with their target values when all metrics are present (labels from `objectives[i].metric`, values formatted as `92%` / `18.7h` / `0`, targets `90%` / `< 24h` / `0`). +- A missing `first_pass_yield` (null) and `escaped_defects` (undefined) each render "No data yet" — two fallbacks — while a present `median_lead_time_hours` still shows its value. +- The fake "Revenue growth" / "Customer retention" / "Not tracked yet" stub labels do not appear in any render path. + +The `buildSummary` test helper carries `first_pass_yield: null` and `escaped_defects: null` by default, matching the not-yet-shipped backend state. + +## Related + +- `docs/map/panel.md` — the agent-facing codebase map entry for `CompanyScorecardCard` / `ObjectivesSection`. +- `panel/src/lib/api/cockpit.ts` — `CockpitSummary` type and `cockpitApi.summary()` client. +- `panel/src/components/business/spend-trend-chart.tsx` — the 30-day spend chart embedded in `SpendSection`. +- `tg-metrics-tab.tsx` — the phone cockpit's metrics tab, whose `pctOrDash(scorecard.first_pass_yield)` convention the `first_pass_yield` percentage format matches. \ No newline at end of file diff --git a/panel/src/components/business/__tests__/company-scorecard-card.test.tsx b/panel/src/components/business/__tests__/company-scorecard-card.test.tsx index 80395e13..c7d49c37 100644 --- a/panel/src/components/business/__tests__/company-scorecard-card.test.tsx +++ b/panel/src/components/business/__tests__/company-scorecard-card.test.tsx @@ -68,6 +68,8 @@ function buildSummary(overrides: Partial = {}): CockpitSummary { pending_pitches: 0, signals: [], median_lead_time_hours: null, + first_pass_yield: null, + escaped_defects: null, ...overrides, }; } @@ -237,7 +239,9 @@ describe("CompanyScorecardCard", () => { render(); - expect(screen.getByText("No data yet")).toBeInTheDocument(); + // Speed section renders 'No data yet' (and so do the objective cards + // for the still-absent first_pass_yield / escaped_defects metrics). + expect(screen.getAllByText("No data yet").length).toBeGreaterThan(0); }); // ------------------------------------------------------------------------- @@ -245,15 +249,105 @@ describe("CompanyScorecardCard", () => { // ------------------------------------------------------------------------- it("shows formatted lead time when median_lead_time_hours is present", () => { setQueryState({ - data: buildSummary({ median_lead_time_hours: 18.7 }), + data: buildSummary({ + median_lead_time_hours: 18.7, + first_pass_yield: 0.9, + escaped_defects: 0, + }), }); render(); - // Component renders `{value.toFixed(1)}h median — target: < 24h` - expect(screen.getByText(/18\.7h/)).toBeInTheDocument(); + // Lead time renders in both the Speed section and the Objectives section + expect(screen.getAllByText(/18\.7h/).length).toBeGreaterThanOrEqual(1); - // 'No data yet' must NOT appear + // 'No data yet' must NOT appear when all metrics are present expect(screen.queryByText("No data yet")).not.toBeInTheDocument(); }); + + // ------------------------------------------------------------------------- + // Objectives section: three charter objective cards against live metrics + // ------------------------------------------------------------------------- + + it("renders three objective cards with target values when all metrics are present", () => { + setQueryState({ + data: buildSummary({ + first_pass_yield: 0.92, + median_lead_time_hours: 18.7, + escaped_defects: 0, + objectives: [ + { metric: "Tasks shipped to merge with no human code edits", target: "90%", status: "Active" }, + { metric: "Median lead time, intake → merged", target: "< 24h", status: "Active" }, + { metric: "Critical escaped defects per release", target: "0", status: "Active" }, + ], + }), + }); + + render(); + + // Three objective labels from the charter objectives array + expect( + screen.getByText("Tasks shipped to merge with no human code edits"), + ).toBeInTheDocument(); + expect( + screen.getByText("Median lead time, intake → merged"), + ).toBeInTheDocument(); + expect( + screen.getByText("Critical escaped defects per release"), + ).toBeInTheDocument(); + + // Live metric values: first_pass_yield as a percentage (0.92 → 92%), + // median_lead_time_hours as {value}h, escaped_defects as a count. + expect(screen.getByText("92%")).toBeInTheDocument(); + expect(screen.getByText("18.7h")).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + + // All three target values render. The '< 24h' target appears in both the + // Speed section and the Objectives section, so use getAllByText there. + expect(screen.getByText(/target: 90%/)).toBeInTheDocument(); + expect(screen.getAllByText(/target: < 24h/).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/target: 0/)).toBeInTheDocument(); + }); + + it("renders 'No data yet' for missing first_pass_yield and escaped_defects, not fabricated labels", () => { + setQueryState({ + data: buildSummary({ + first_pass_yield: null, + median_lead_time_hours: 18.7, + escaped_defects: undefined, + objectives: [ + { metric: "Tasks shipped to merge with no human code edits", target: "90%", status: "Active" }, + { metric: "Median lead time, intake → merged", target: "< 24h", status: "Active" }, + { metric: "Critical escaped defects per release", target: "0", status: "Active" }, + ], + }), + }); + + render(); + + // Two 'No data yet' fallbacks: one for first_pass_yield, one for escaped_defects. + // median_lead_time_hours is present so its card shows the value, not the fallback. + expect(screen.getAllByText("No data yet").length).toBe(2); + + // The fake stub labels must NOT appear anywhere + expect(screen.queryByText("Revenue growth")).not.toBeInTheDocument(); + expect(screen.queryByText("Customer retention")).not.toBeInTheDocument(); + expect(screen.queryByText("Not tracked yet")).not.toBeInTheDocument(); + }); + + it("does not render the fake 'Revenue growth' or 'Customer retention' stub labels", () => { + setQueryState({ + data: buildSummary({ + first_pass_yield: 0.9, + median_lead_time_hours: 12, + escaped_defects: 0, + }), + }); + + render(); + + expect(screen.queryByText("Revenue growth")).not.toBeInTheDocument(); + expect(screen.queryByText("Customer retention")).not.toBeInTheDocument(); + expect(screen.queryByText("Not tracked yet")).not.toBeInTheDocument(); + }); }); diff --git a/panel/src/components/business/company-scorecard-card.tsx b/panel/src/components/business/company-scorecard-card.tsx index 7547bbe6..9318c084 100644 --- a/panel/src/components/business/company-scorecard-card.tsx +++ b/panel/src/components/business/company-scorecard-card.tsx @@ -239,32 +239,112 @@ function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) { } // --------------------------------------------------------------------------- -// Stub objectives section +// Objectives section — three charter objective cards, each showing a live +// metric against its target. +// +// ponytail: The charter `objectives` field is free text +// (Record[]) 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. // --------------------------------------------------------------------------- -function StubObjectivesSection() { - const stubs = [ - { id: "obj-1", label: "Revenue growth" }, - { id: "obj-2", label: "Customer retention" }, - ]; +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[]; + firstPassYield: number | null | undefined; + medianLeadTimeHours: number | null | undefined; + escapedDefects: number | null | undefined; +} + +function objectiveLabel( + objectives: Record[], + 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 ( +
+
{label}
+
+ {hasData ? ( + {formattedValue} + ) : ( + No data yet + )} + + target: {targetText} + +
+
+ ); +} + +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 (
- - Objectives - + Objectives
- {stubs.map((stub) => ( -
- {stub.label} - - Not tracked yet - -
- ))} + + +
); @@ -299,7 +379,12 @@ function ScorecardBody({ spendTrendLoading={spendTrendLoading} /> - + ); diff --git a/panel/src/lib/api/cockpit.ts b/panel/src/lib/api/cockpit.ts index 1b6812f9..555b4f33 100644 --- a/panel/src/lib/api/cockpit.ts +++ b/panel/src/lib/api/cockpit.ts @@ -20,6 +20,13 @@ export interface CockpitSummary { pending_pitches: number; signals: CockpitSignal[]; median_lead_time_hours?: number | null; + // Fraction of tasks shipped to merge with no human code edits (0–1). + // Backend companion item adds this; until it ships the field is absent + // and the UI renders 'No data yet'. Formatted as a percentage, matching + // the phone's pctOrDash convention in tg-metrics-tab.tsx. + first_pass_yield?: number | null; + // Count of critical escaped defects per release (backend companion item). + escaped_defects?: number | null; } export interface CockpitSignal {