diff --git a/src/api/metric-registry.ts b/src/api/metric-registry.ts index 25ad19f..77043d1 100644 --- a/src/api/metric-registry.ts +++ b/src/api/metric-registry.ts @@ -29,11 +29,11 @@ export const METRIC_REGISTRY = { V2_IC_SECTION_TREND: "00000000-0000-0000-0001-000000000036", // Per-person "member values" for the team heatmap + needs-attention widgets: - // long rows (person_id, metric_key, value) for a roster, no cohort. + // long rows (person_id, metric_key, value) for a roster, no cohort. Task + // delivery and collaboration both flipped to unified IC metrics groups, but + // their per-member values still feed the legacy team heatmap bullet + // columns + needs-attention until that surface migrates to unified metrics. V2_MEMBER_VALUES_DELIVERY: "00000000-0000-0000-0001-000000000040", - // Collaboration flipped to a unified IC metrics group, but its per-member - // values still feed the legacy team heatmap meeting-hours bullet column + - // needs-attention until that surface migrates to unified metrics. V2_MEMBER_VALUES_COLLAB: "00000000-0000-0000-0001-000000000041", // Per-person PRs merged for a roster (period-bounded, from the weekly git diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..57d9aec --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,104 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/src/components/widgets/group-card-empty.tsx b/src/components/widgets/group-card-empty.tsx new file mode 100644 index 0000000..17e5e9d --- /dev/null +++ b/src/components/widgets/group-card-empty.tsx @@ -0,0 +1,26 @@ +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; + +/** + * Empty body for a dashboard group card. `min-h` is the footprint floor so an + * empty card holds a populated card's height even when its grid row has no + * taller sibling to stretch against. It fills and centers within the card's + * content region (the parent `CardContent` carries `flex-1`); no padding of + * its own, so nothing biases the centered message downward. + */ +export function GroupCardEmpty() { + return ( + + + No data + + No metrics with data for this period. + + + + ); +} diff --git a/src/components/widgets/metric-views/chart-empty.tsx b/src/components/widgets/metric-views/chart-empty.tsx new file mode 100644 index 0000000..a139b0e --- /dev/null +++ b/src/components/widgets/metric-views/chart-empty.tsx @@ -0,0 +1,24 @@ +import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; +import { cn } from "@/lib/utils"; + +/** + * Placeholder for a chart with no data — centers the message in the plot area + * so an empty chart keeps its neighbours' footprint instead of collapsing to + * a line of text. No frame of its own: the enclosing card is the border. + * `className` carries the plot height so it matches the chart it stands in for. + */ +export function ChartEmpty({ + message, + className, +}: { + message: string; + className?: string; +}) { + return ( + + + {message} + + + ); +} diff --git a/src/components/widgets/metric-views/collection-drilldown.test.tsx b/src/components/widgets/metric-views/collection-drilldown.test.tsx index a489521..d0b26cb 100644 --- a/src/components/widgets/metric-views/collection-drilldown.test.tsx +++ b/src/components/widgets/metric-views/collection-drilldown.test.tsx @@ -114,9 +114,9 @@ describe("CollectionDrilldown", () => { entityId="alice@example.com" />, ); - // Histograms live in a single labeled "Distributions" card, shaded vs the - // peer median (the subtitle proves the diverging render, not a bare chart). - expect(screen.getByText("Distributions")).toBeInTheDocument(); + // Each histogram is its own card in the grid (no wrapping "Distributions" + // card), shaded vs the peer median (the subtitle proves the diverging + // render, not a bare chart). expect(screen.getByText(/vs peer median/)).toBeInTheDocument(); }); diff --git a/src/components/widgets/metric-views/collection-drilldown.tsx b/src/components/widgets/metric-views/collection-drilldown.tsx index 2613f27..43f3842 100644 --- a/src/components/widgets/metric-views/collection-drilldown.tsx +++ b/src/components/widgets/metric-views/collection-drilldown.tsx @@ -1,10 +1,4 @@ import { ComingSoon } from "@/components/widgets/coming-soon"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { MetricBreakdown } from "@/components/widgets/metric-views/metric-breakdown"; import { MetricHistogram } from "@/components/widgets/metric-views/metric-histogram"; @@ -12,7 +6,7 @@ import { MetricSummaryCard } from "@/components/widgets/metric-views/metric-summ import { MetricTrend } from "@/components/widgets/metric-views/metric-trend"; import { PeerStory } from "@/components/widgets/metric-views/peer-story"; import type { DrilldownBlock, MetricGroup } from "@/lib/insight/groups"; -import type { NormalizedMetricResult } from "@/lib/metrics/collection"; +import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; import { buildPeerStoryEntries } from "@/lib/metrics/peer-story"; import type { PeerCohortLabel } from "@/lib/peers"; import type { MetricCollectionResult } from "@/queries/metric-results"; @@ -124,9 +118,18 @@ export function CollectionDrilldown({ !isSummaryBlock(block) && blockMetrics(block, data.byKey).length > 0, ); - const distributionMetrics = def.drilldown + // Populated distributions lead; those with no events for this entity in the + // period sort to the end so the section doesn't open on an empty placeholder. + // Stable partition keeps declared order within each group. + const declaredDistributions = def.drilldown .filter((block) => block.view === "histogram") .flatMap((block) => blockMetrics(block, data.byKey)); + const hasDistribution = (metric: NormalizedMetricResult) => + (forEntity(metric, entityId).histogram[0]?.bins?.length ?? 0) > 0; + const distributionMetrics = [ + ...declaredDistributions.filter(hasDistribution), + ...declaredDistributions.filter((metric) => !hasDistribution(metric)), + ]; return (
{distributionMetrics.length > 0 ? ( - // One labeled card holds every distribution; the charts inside are - // un-carded so there are no nested borders and "distribution" isn't - // repeated per chart. - - - - Distributions - - - 1 && "lg:grid-cols-2", - )} - > - {distributionMetrics.map((metric) => ( - - ))} - - + // Each histogram is its own card, dropped straight into the grid like + // the chart blocks above — no wrapping "Distributions" card. +
1 && "lg:grid-cols-2", + )} + > + {distributionMetrics.map((metric) => ( + + ))} +
) : null}
); diff --git a/src/components/widgets/metric-views/metric-breakdown.test.tsx b/src/components/widgets/metric-views/metric-breakdown.test.tsx index 717e2ec..d7fa2d2 100644 --- a/src/components/widgets/metric-views/metric-breakdown.test.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.test.tsx @@ -61,6 +61,6 @@ describe("MetricBreakdown", () => { entityId="me@x.com" />, ); - expect(screen.getByText("No composition data yet.")).toBeInTheDocument(); + expect(screen.getByText("No composition data yet")).toBeInTheDocument(); }); }); diff --git a/src/components/widgets/metric-views/metric-breakdown.tsx b/src/components/widgets/metric-views/metric-breakdown.tsx index 6778aa4..04d6f6f 100644 --- a/src/components/widgets/metric-views/metric-breakdown.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.tsx @@ -5,6 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty"; import { dimensionColorSeed, dimensionLabel, @@ -38,11 +39,13 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { if (rows.length === 0) { return ( - - + + {metric.label} - No composition data yet. + + + ); } diff --git a/src/components/widgets/metric-views/metric-group-card.test.tsx b/src/components/widgets/metric-views/metric-group-card.test.tsx index ccbeeb3..6ff1f17 100644 --- a/src/components/widgets/metric-views/metric-group-card.test.tsx +++ b/src/components/widgets/metric-views/metric-group-card.test.tsx @@ -89,6 +89,46 @@ describe("MetricGroupCard", () => { expect(screen.getByText("3 days")).toBeInTheDocument(); }); + it("shows a single empty state without a standing badge when no metric has data", () => { + render( + , + ); + expect( + screen.getByText("No metrics with data for this period."), + ).toBeInTheDocument(); + // The badge would only restate the absence — one message, not two. + expect(screen.queryByText("no peer data")).not.toBeInTheDocument(); + // Nothing to drill into — the card is not an interactive button. + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("keeps a fixed preview key with no value, rendering an em dash", () => { + render( + , + ); + // Both preview rows stay on the card — the valueless one shows "—", not + // dropped, so the card's identity is stable across periods. + expect(screen.getByText("ai.active_days")).toBeInTheDocument(); + expect(screen.getByText("ai.cost")).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + it("keeps the card name and shows a spinner while loading", () => { render( { onOpen={vi.fn()} />, ); - // No metric is scorable → no "in top" tally. - expect(screen.getByText("No peer data")).toBeInTheDocument(); + // No metric is scorable → no rankable peers. + expect(screen.getByText("no peer data")).toBeInTheDocument(); }); it("scores measured people against quartiles", () => { @@ -136,8 +176,9 @@ describe("MetricGroupCard", () => { onOpen={vi.fn()} />, ); - // active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 of 2 in top. - expect(screen.getByText("1 of 2 in top")).toBeInTheDocument(); + // active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 bottom wins + // the phrase (behind beats ahead), single bottom below the red bar → amber. + expect(screen.getByText("1 behind peers")).toBeInTheDocument(); }); it("owns its error state with retry", () => { diff --git a/src/components/widgets/metric-views/metric-group-card.tsx b/src/components/widgets/metric-views/metric-group-card.tsx index a28b7cb..2bc9630 100644 --- a/src/components/widgets/metric-views/metric-group-card.tsx +++ b/src/components/widgets/metric-views/metric-group-card.tsx @@ -7,24 +7,28 @@ import { } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { ComingSoon } from "@/components/widgets/coming-soon"; +import { GroupCardEmpty } from "@/components/widgets/group-card-empty"; import { useSettings } from "@/hooks/use-settings"; import { formatMetricValue } from "@/lib/format"; import type { MetricGroup } from "@/lib/insight/groups"; import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; -import { derivePeerStanding } from "@/lib/metrics/peer-standing"; +import { formatGapMagnitude } from "@/lib/metrics/gap"; import { - aggregateSectionStatus, - pickSectionHeadline, - sectionCounts, - type ScoredMetric, + derivePeerStanding, + type PeerStanding, +} from "@/lib/metrics/peer-standing"; +import type { PeerStatusWithNeutral } from "@/lib/peers"; +import { + gradeSectionStanding, + rankCounts, + sectionStandingPhrase, } from "@/lib/scoring"; import { STATUS_BG_CLASS, STATUS_STRIPE_LEFT, STATUS_TEXT_CLASS, applyFocusStatus, - type Status, } from "@/lib/status"; import type { MetricCollectionResult } from "@/queries/metric-results"; import { cn } from "@/lib/utils"; @@ -37,18 +41,26 @@ export interface MetricGroupCardProps { onHover?: () => void; subtitle?: string; /** - * Per-metric status override (team view): replaces the single-entity + * Per-metric rank override (team view): replaces the single-entity * quartile scoring with a roster rollup computed by the caller. */ - statusByMetricKey?: Map; + rankByMetricKey?: Map; } interface CardRow { metric: NormalizedMetricResult; value: number | null; - status: Status; + rank: PeerStatusWithNeutral; + standing: PeerStanding; } +const HEADLINE_TIER: Record = { + bottom: 3, + in_pack: 2, + top: 1, + neutral: 0, +}; + export function MetricGroupCard({ def, data, @@ -56,7 +68,7 @@ export function MetricGroupCard({ onOpen, onHover, subtitle, - statusByMetricKey, + rankByMetricKey, }: MetricGroupCardProps) { const { focusMode } = useSettings(); @@ -65,7 +77,7 @@ export function MetricGroupCard({ // spinner in the body. Not interactive — nothing to open yet. return ( - + {def.title} {subtitle ? ( @@ -97,84 +109,98 @@ export function MetricGroupCard({ const metric = data.byKey.get(metricConfig.key); if (!metric) return []; const entityData = forEntity(metric, entityId); - const status = - statusByMetricKey?.get(metric.metric_key) ?? - rowStatusFromPeer(metric, entityData.value, entityId); - return [{ metric, value: entityData.value, status }]; + const peerRow = + metric.peer?.values.find((v) => v.entity_id === entityId) ?? null; + // All eligibility gates (observed / suppressed / flat pool / neutral + // direction) live in the shared standing derivation, reused for the + // headline's severity ordering and gap. + const standing = derivePeerStanding(metric.direction, { + value: entityData.value, + peer: peerRow, + }); + const rank = rankByMetricKey?.get(metric.metric_key) ?? standing.rank; + return [{ metric, value: entityData.value, rank, standing }]; }); - const scored: ScoredMetric[] = rows.map((row) => ({ - row, - status: row.status, - })); - const status = applyFocusStatus(aggregateSectionStatus(scored), focusMode); - const counts = sectionCounts(scored); - const evaluated = counts.good + counts.warn + counts.bad; - const badgeText = - evaluated === 0 ? "No peer data" : `${counts.good} of ${evaluated} in top`; + const counts = rankCounts(rows.map((row) => ({ row, rank: row.rank }))); + const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); + const badgeText = sectionStandingPhrase(counts); - const headline = pickSectionHeadline(scored); - const summary = - headline && headline.row.value != null - ? `${headline.row.metric.label}: ${formatMetricValue( - headline.row.value, - headline.row.metric.format, - headline.row.metric.unit, - )}` - : "No data for this period."; + const headlineRow = pickHeadlineRow(rows); + const summary = headlineRow + ? headlineSummary(headlineRow) + : "No data for this period."; + // The preview is a FIXED set of keys — the card's stable identity. Keep a + // key even when its value is null (renders "—"); only drop a key the + // response never carried. A present-but-empty metric still belongs on the + // card. const preview = def.card.preview .map((key) => rows.find((row) => row.metric.metric_key === key)) - .filter((row): row is CardRow => row != null && row.value != null); - const isEmpty = evaluated === 0 && preview.length === 0; + .filter((row): row is CardRow => row != null); + const isEmpty = !rows.some((row) => row.value != null); const stripeClass = STATUS_STRIPE_LEFT[status]; return ( + isEmpty ? undefined : ( + @@ -89,8 +104,8 @@ export function IcNeedsAttention({ ) : null} - - + + ); } diff --git a/src/components/widgets/v2/kpi-tile.stories.tsx b/src/components/widgets/v2/kpi-tile.stories.tsx index 931eafa..7f4fde9 100644 --- a/src/components/widgets/v2/kpi-tile.stories.tsx +++ b/src/components/widgets/v2/kpi-tile.stories.tsx @@ -29,12 +29,14 @@ import { KpiTile } from "./kpi-tile"; function makeTile(overrides: Partial = {}): KpiTileData { return { - key: "tasks_closed", + key: "tasks.closed", label: "Bugs Fixed", value: "12", valueStatus: "good", delta: { text: "+9%", status: "good", down: false }, - medianLabel: "Median 6", + medianLabel: "median 6", + gapText: null, + gapStatus: "neutral", context: "Jira", groupId: "task_delivery", ...overrides, diff --git a/src/components/widgets/v2/kpi-tile.test.tsx b/src/components/widgets/v2/kpi-tile.test.tsx index 42d62f9..4caf561 100644 --- a/src/components/widgets/v2/kpi-tile.test.tsx +++ b/src/components/widgets/v2/kpi-tile.test.tsx @@ -20,7 +20,9 @@ function tile(overrides: Partial = {}): KpiTileData { value: "14", valueStatus: "good", delta: { text: "+17%", status: "good", down: false }, - medianLabel: "Median 11", + medianLabel: "median 11", + gapText: null, + gapStatus: "neutral", context: "Days with any AI tool activity", groupId: "ai_adoption", ...overrides, @@ -32,12 +34,26 @@ describe("KpiTile", () => { render(); expect(screen.getByText("14")).toBeInTheDocument(); expect(screen.getByText("+17%")).toBeInTheDocument(); - expect(screen.getByText("Median 11")).toBeInTheDocument(); + expect(screen.getByText("median 11")).toBeInTheDocument(); expect( screen.getByText("Days with any AI tool activity"), ).toBeInTheDocument(); }); + it("shows the divergence gap next to the median", () => { + render( + , + ); + expect(screen.getByText("3.5×")).toBeInTheDocument(); + expect(screen.getByText(/vs median 3,563/)).toBeInTheDocument(); + }); + it("falls back to 'No peer data' without a median label", () => { render(); expect(screen.getByText("No peer data")).toBeInTheDocument(); diff --git a/src/components/widgets/v2/kpi-tile.tsx b/src/components/widgets/v2/kpi-tile.tsx index 8238ea9..cc76843 100644 --- a/src/components/widgets/v2/kpi-tile.tsx +++ b/src/components/widgets/v2/kpi-tile.tsx @@ -81,7 +81,18 @@ export function KpiTile({ tile, onOpenGroup }: KpiTileProps) { - {tile.medianLabel ?? "No peer data"} + {tile.gapText && tile.medianLabel ? ( + + + {tile.gapText} + {" "} + vs {tile.medianLabel} + + ) : ( + (tile.medianLabel ?? "No peer data") + )} ); diff --git a/src/components/widgets/v2/section-card.test.tsx b/src/components/widgets/v2/section-card.test.tsx index 4dc2f03..c97f57c 100644 --- a/src/components/widgets/v2/section-card.test.tsx +++ b/src/components/widgets/v2/section-card.test.tsx @@ -1,10 +1,10 @@ /** * Component-render coverage for `` status coloring. * - * Two status sources: - * - IC view (no override): `rowStatus` colors each row off its own `peer` + * Two rank sources: + * - IC view (no override): each row ranks off its own `peer` * (p25/p50/p75) + the catalog's `higher_is_better` — no FE math. - * - Team view: a `statusByMetricKey` override replaces that per-row scoring, + * - Team view: a `rankByMetricKey` override replaces that per-row scoring, * so the card rolls up per-member-vs-own-department standings instead of * comparing a team aggregate against an individual band. * The headline always reflects the row's own label/value (the aggregate), @@ -74,7 +74,7 @@ describe(" peer-driven coloring", () => { }, ]), ); - // value 12 ≥ peer.p75 (8), higher_is_better ⇒ 'top' ⇒ good ⇒ "1 of 1 in top". + // value 12 ≥ peer.p75 (8), higher_is_better ⇒ 'top' ⇒ good ⇒ "1 ahead of peers". const row = makeBullet({ value: "12", peer: { p25: 4, p50: 6, p75: 8, min: 2, max: 15, n: 12 }, @@ -89,7 +89,7 @@ describe(" peer-driven coloring", () => { />, ); await waitFor(() => { - expect(screen.getByText("1 of 1 in top")).toBeInTheDocument(); + expect(screen.getByText("1 ahead of peers")).toBeInTheDocument(); }); // Headline reflects the team aggregate row, not the cohort. expect(screen.getByText("Tasks Closed: 12 tasks")).toBeInTheDocument(); @@ -98,7 +98,7 @@ describe(" peer-driven coloring", () => { ).toBeInTheDocument(); }); - it("statusByMetricKey override drives the badge, overriding the row's own peer", async () => { + it("rankByMetricKey override drives the badge, overriding the row's own peer", async () => { fetchCatalog.mockResolvedValue( buildCatalogResponse([ { @@ -108,8 +108,8 @@ describe(" peer-driven coloring", () => { }, ]), ); - // No peer on the row ⇒ rowStatus would be neutral ("No peer data"). The - // team override says 'good' ⇒ badge becomes "1 of 1 in top". + // No peer on the row ⇒ its own rank would be neutral ("no peer data"). + // The team override says 'top' ⇒ badge becomes "1 ahead of peers". const row = makeBullet({ value: "12", peer: undefined }); renderWithCatalogClient( peer-driven coloring", () => { rows={[row]} onOpen={() => {}} subtitle="vs department peers" - statusByMetricKey={new Map([["tasks_completed", "good"]])} + rankByMetricKey={new Map([["tasks_completed", "top"]])} />, ); await waitFor(() => { - expect(screen.getByText("1 of 1 in top")).toBeInTheDocument(); + expect(screen.getByText("1 ahead of peers")).toBeInTheDocument(); }); - expect(screen.queryByText("No peer data")).not.toBeInTheDocument(); + expect(screen.queryByText("no peer data")).not.toBeInTheDocument(); // Headline still the aggregate row. expect(screen.getByText("Tasks Closed: 12 tasks")).toBeInTheDocument(); }); - it("a row with no peer scores neutral — 'No peer data' badge", async () => { + it("a row with no peer scores neutral — 'no peer data' badge", async () => { fetchCatalog.mockResolvedValue( buildCatalogResponse([ { @@ -149,7 +149,7 @@ describe(" peer-driven coloring", () => { />, ); await waitFor(() => { - expect(screen.getByText("No peer data")).toBeInTheDocument(); + expect(screen.getByText("no peer data")).toBeInTheDocument(); }); }); }); diff --git a/src/components/widgets/v2/section-card.tsx b/src/components/widgets/v2/section-card.tsx index 80042ef..ca1d4f9 100644 --- a/src/components/widgets/v2/section-card.tsx +++ b/src/components/widgets/v2/section-card.tsx @@ -8,20 +8,27 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { GroupCardEmpty } from "@/components/widgets/group-card-empty"; import { useSettings } from "@/hooks/use-settings"; -import { hasBulletValue, rowStatus } from "@/lib/insight/v2/peer-status"; import { - aggregateSectionStatus, + hasBulletValue, + peerStatusForRow, + peerStatusToStatus, +} from "@/lib/insight/v2/peer-status"; +import type { PeerStatusWithNeutral } from "@/lib/peers"; +import { + gradeSectionStanding, pickSectionHeadline, - sectionCounts, - type ScoredMetric, + rankCounts, + rankableCount, + sectionStandingPhrase, + type RankedMetric, } from "@/lib/scoring"; import { STATUS_BG_CLASS, STATUS_STRIPE_LEFT, STATUS_TEXT_CLASS, applyFocusStatus, - type Status, } from "@/lib/status"; import { cn } from "@/lib/utils"; import type { BulletMetric } from "@/types/insight"; @@ -42,12 +49,13 @@ export interface SectionCardProps { subtitle?: string; unavailable?: boolean; /** - * Per-metric status override. When supplied (team view), it replaces the - * single-row `rowStatus` scoring — the team card rolls up each metric from + * Per-metric rank override. When supplied (team view), it replaces the + * single-row quartile scoring — the team card rolls up each metric from * per-member-vs-own-department standings instead of comparing a team - * aggregate against an individual band. Absent (IC view) → `rowStatus`. + * aggregate against an individual band. Absent (IC view) → the row's own + * cohort rank. */ - statusByMetricKey?: Map; + rankByMetricKey?: Map; } export function SectionCard({ @@ -58,17 +66,17 @@ export function SectionCard({ sectionId, subtitle, unavailable, - statusByMetricKey, + rankByMetricKey, }: SectionCardProps) { const { focusMode } = useSettings(); const { byMetricKey } = useCatalog(); - const metricStatus = (r: BulletMetric): Status => - statusByMetricKey?.get(r.metric_key) ?? rowStatus(r, byMetricKey); + const metricRank = (r: BulletMetric): PeerStatusWithNeutral => + rankByMetricKey?.get(r.metric_key) ?? peerStatusForRow(r, byMetricKey); if (unavailable) { return ( - + {title} @@ -82,21 +90,16 @@ export function SectionCard({ ); } - const scored: ScoredMetric[] = rows.map((r) => ({ + const ranked: RankedMetric[] = rows.map((r) => ({ row: r, - status: metricStatus(r), + rank: metricRank(r), })); - const rawStatus = aggregateSectionStatus(scored); - const status = applyFocusStatus(rawStatus, focusMode); - const counts = sectionCounts(scored); - const evaluated = counts.good + counts.warn + counts.bad; - const topCount = counts.good; - const badgeText = - evaluated === 0 - ? "No peer data" - : `${topCount} of ${evaluated} in top`; + const counts = rankCounts(ranked); + const evaluated = rankableCount(counts); + const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); + const badgeText = sectionStandingPhrase(counts); - const headline = pickSectionHeadline(scored); + const headline = pickSectionHeadline(ranked); const unit = headline?.row.unit ?? ""; const summary = headline ? `${headline.row.label}: ${headline.row.value}${unit ? ` ${unit}` : ""}` @@ -108,40 +111,54 @@ export function SectionCard({ return ( + isEmpty ? undefined : ( +