Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/api/metric-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ export const METRIC_REGISTRY = {
// Per-person "member values" for the team heatmap + needs-attention widgets:
// long rows (person_id, metric_key, value) for a roster, no cohort.
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 collaboration Messaging peer counters (#1527): long rows
// (person_id, metric_key, value + per-org_unit bands) for messages_sent /
// channel_posts.
V2_IC_COLLAB_PEER_COUNTERS: "00000000-0000-0000-0001-000000000053",

// Per-person PRs merged for a roster (period-bounded, from the weekly git
// silver). Merged into the team_member rows for the heatmap PRs column.
Expand Down
13 changes: 11 additions & 2 deletions src/api/metric-results-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export type MetricResultViewKind =
| "breakdown"
| "histogram";
export type MetricBucket = "day" | "week" | "month";
export type MetricComputation = "sum" | "ratio" | "median";
export type MetricComputation =
| "sum"
| "ratio"
| "median"
| "distinct_count";
export type MetricEntityType = "person";

export interface MetricResultsRequest {
Expand Down Expand Up @@ -53,7 +57,8 @@ export interface MetricDimension {
export type MetricResult =
| SumMetricResult
| RatioMetricResult
| MedianMetricResult;
| MedianMetricResult
| DistinctCountMetricResult;

interface MetricResultBase {
metric_key: string;
Expand All @@ -79,6 +84,10 @@ export interface MedianMetricResult extends MetricResultBase {
computation: "median";
}

export interface DistinctCountMetricResult extends MetricResultBase {
computation: "distinct_count";
}

export type MetricResultView =
| PeriodView
| TimeseriesView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,45 @@ describe("CollectionDrilldown", () => {
expect(screen.getByText(/vs peer median/)).toBeInTheDocument();
});

it("renders a summary-card block as headline cards", () => {
const summaryDef: MetricGroup = {
kind: "metrics",
id: "collaboration",
title: "Collaboration",
collection: {
metrics: [
{
key: "ai.accepted_lines",
views: [
{ view: "period" },
{ view: "peer" },
{ view: "breakdown", dimensions: ["tool"] },
],
},
],
},
card: { preview: ["ai.accepted_lines"] },
drilldown: [
{
chart: "summary-card",
view: "breakdown",
metrics: ["ai.accepted_lines"],
},
],
};
render(
<CollectionDrilldown
def={summaryDef}
data={result()}
entityId="alice@example.com"
/>,
);
expect(screen.getAllByText("Accepted lines").length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: /By tool/ }),
).toBeInTheDocument();
});

it("shows the error state with retry when the collection failed", () => {
const refetch = vi.fn();
render(
Expand Down
35 changes: 30 additions & 5 deletions src/components/widgets/metric-views/collection-drilldown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { MetricBreakdown } from "@/components/widgets/metric-views/metric-breakdown";
import { MetricHistogram } from "@/components/widgets/metric-views/metric-histogram";
import { MetricSummaryCard } from "@/components/widgets/metric-views/metric-summary-card";
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";
Expand Down Expand Up @@ -107,13 +108,21 @@ export function CollectionDrilldown({
}

const entries = buildPeerStoryEntries(def.collection, data.byKey, entityId);
// Distribution (histogram) charts get their own labeled card below the peer
// story; everything else pairs into the top chart grid. Filter to blocks
// that actually have data so column counts (and full-width-when-alone) key
// off what renders, not what's declared.
// Summary cards get their own wider grid row above the charts;
// distribution (histogram) charts get their own labeled card below the
// peer story; everything else pairs into the top chart grid. Filter to
// blocks that actually have data so column counts (and
// full-width-when-alone) key off what renders, not what's declared.
const isSummaryBlock = (block: DrilldownBlock) =>
block.view === "breakdown" && block.chart === "summary-card";
const summaryMetrics = def.drilldown
.filter(isSummaryBlock)
.flatMap((block) => blockMetrics(block, data.byKey));
const chartBlocks = def.drilldown.filter(
(block) =>
block.view !== "histogram" && blockMetrics(block, data.byKey).length > 0,
block.view !== "histogram" &&
!isSummaryBlock(block) &&
blockMetrics(block, data.byKey).length > 0,
);
const distributionMetrics = def.drilldown
.filter((block) => block.view === "histogram")
Expand All @@ -127,6 +136,22 @@ export function CollectionDrilldown({
className,
)}
>
{summaryMetrics.length > 0 ? (
<div
className={cn(
"grid grid-cols-1 gap-4 sm:grid-cols-2",
summaryMetrics.length > 2 && "xl:grid-cols-4",
)}
>
{summaryMetrics.map((metric) => (
<MetricSummaryCard
key={metric.metric_key}
metric={metric}
entityId={entityId}
/>
))}
</div>
) : null}
{chartBlocks.length > 0 ? (
// Pair charts into two columns; a lone chart spans the full width
// rather than leaving an empty column. Blocks return fragments, so
Expand Down
15 changes: 8 additions & 7 deletions src/components/widgets/metric-views/metric-breakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import {
} from "@/components/widgets/metric-views/dimension-series";
import { formatMetricValue } from "@/lib/format";
import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection";
import { integerPercentShares } from "@/lib/metrics/shares";
import { swatchPalette } from "@/lib/swatch-palette";
import { percentShareLabels } from "@/lib/metrics/shares";
import { seriesColors } from "@/lib/series-colors";

export interface MetricBreakdownProps {
metric: NormalizedMetricResult;
Expand Down Expand Up @@ -48,16 +48,17 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) {
}

const dimensions = metric.breakdown?.dimensions ?? [];
const colorsBySeed = swatchPalette(rows.map((row) => row.colorSeed));
const colorsBySeed = seriesColors(rows.map((row) => row.colorSeed));
const total = rows.reduce((sum, row) => sum + row.value, 0);
// Ribbon widths use the exact fraction; displayed percents use integer
// shares that sum to exactly 100 (no 99%/101% in the legend).
const shares = integerPercentShares(rows.map((row) => row.value));
// Ribbon widths use the exact fraction; displayed percents use share
// labels that sum to exactly 100 (no 99%/101% in the legend, and a
// nonzero part never reads 0%).
const shares = percentShareLabels(rows.map((row) => row.value));
const items = rows.map((row, index) => ({
...row,
color: colorsBySeed[row.colorSeed],
pct: total > 0 ? (row.value / total) * 100 : 0,
share: shares[index] ?? 0,
share: shares[index] ?? "0",
formatted: formatMetricValue(row.value, metric.format, metric.unit),
}));

Expand Down
29 changes: 12 additions & 17 deletions src/components/widgets/metric-views/metric-group-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ 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 { toPeerStats } from "@/lib/metrics/peer-story";
import { peerStatusVsQuartiles } from "@/lib/peers";
import { derivePeerStanding } from "@/lib/metrics/peer-standing";
import {
SECTION_STRIPE,
aggregateSectionStatus,
pickSectionHeadline,
sectionCounts,
type ScoredMetric,
} from "@/lib/scoring";
import {
STATUS_BG_CLASS,
STATUS_STRIPE_LEFT,
STATUS_TEXT_CLASS,
applyFocusStatus,
type Status,
Expand Down Expand Up @@ -65,7 +64,7 @@ export function MetricGroupCard({
// Keep the card's identity while it loads: the name in the header, a
// spinner in the body. Not interactive — nothing to open yet.
return (
<Card className="border-l-2 border-l-border">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-semibold">{def.title}</CardTitle>
{subtitle ? (
Expand Down Expand Up @@ -128,8 +127,7 @@ export function MetricGroupCard({
.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;
const stripeClass =
status === "neutral" ? "border-l-border" : SECTION_STRIPE[status];
const stripeClass = STATUS_STRIPE_LEFT[status];

return (
<Card
Expand All @@ -143,7 +141,7 @@ export function MetricGroupCard({
/>
}
className={cn(
"border-l-2 text-left transition-colors hover:bg-accent/50",
"text-left transition-colors hover:bg-accent/50",
stripeClass,
)}
>
Expand Down Expand Up @@ -223,16 +221,13 @@ function rowStatusFromPeer(
value: number | null,
entityId: string,
): Status {
if (metric.direction === "neutral" || value == null) return "neutral";
const peerRow =
metric.peer?.values.find((v) => v.entity_id === entityId) ?? null;
// Unmeasured people take no standing: the period value is zero-filled,
// but a null peer target_value means no observations (same gate as
// kpi-row, attention, team-metrics, and the peer story).
if (metric.peer && peerRow?.target_value == null) return "neutral";
const stats = toPeerStats(peerRow);
if (!stats) return "neutral";
return peerStatusToStatus(
peerStatusVsQuartiles(value, stats, metric.direction !== "lower_is_better"),
);
// All eligibility gates (observed / suppressed / flat pool / neutral
// direction) live in the shared standing derivation.
const standing = derivePeerStanding(metric.direction, {
value,
peer: peerRow,
});
return peerStatusToStatus(standing.rank);
}
135 changes: 135 additions & 0 deletions src/components/widgets/metric-views/metric-summary-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";

import { MetricSummaryCard } from "@/components/widgets/metric-views/metric-summary-card";
import {
normalizeMetricResults,
type NormalizedMetricResult,
} from "@/lib/metrics/collection";
import type { MetricResult } from "@/api/metric-results-client";

vi.mock("@/hooks/use-settings", () => ({
useSettings: () => ({ focusMode: "all", showExplanations: true }),
}));

function summaryMetric(
opts: {
value: number | null;
median?: number;
direction?: MetricResult["direction"];
breakdown?: Array<{ tool: string; value: number | null }>;
} = { value: 20 },
): NormalizedMetricResult {
const median = opts.median ?? 10;
const views: MetricResult["views"] = [
{ view: "period", values: [{ entity_id: "me@x.com", value: opts.value }] },
{
view: "peer",
values: [
{
entity_id: "me@x.com",
target_value: opts.value,
p25: median * 0.5,
median,
p75: median * 1.5,
min: 0,
max: median * 3,
n: 12,
},
],
},
];
if (opts.breakdown) {
views.push({
view: "breakdown",
dimensions: ["tool"],
values: opts.breakdown.map((row) => ({
entity_id: "me@x.com",
dimensions: [{ key: "tool", value: row.tool, label: row.tool }],
value: row.value,
})),
});
}
const result: MetricResult = {
metric_key: "collab.meeting_hours",
label: "Meeting hours",
description: "Time in meetings",
unit: "h",
format: "integer",
direction: opts.direction ?? "higher_is_better",
computation: "sum",
views,
};
return normalizeMetricResults([result]).get("collab.meeting_hours")!;
}

describe("MetricSummaryCard", () => {
it("renders the headline value, unit, and collapses the breakdown by default", () => {
render(
<MetricSummaryCard
metric={summaryMetric({
value: 20,
breakdown: [
{ tool: "slack", value: 12 },
{ tool: "teams", value: 8 },
],
})}
entityId="me@x.com"
/>,
);
expect(screen.getByText("Meeting hours")).toBeInTheDocument();
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("h")).toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /By tool/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("slack")).not.toBeInTheDocument();
});

it("reveals the ribbon and legend when expanded", async () => {
const user = userEvent.setup();
render(
<MetricSummaryCard
metric={summaryMetric({
value: 20,
breakdown: [
{ tool: "slack", value: 12 },
{ tool: "teams", value: 8 },
],
})}
entityId="me@x.com"
/>,
);
await user.click(screen.getByRole("button", { name: /By tool/ }));
expect(screen.getByText("slack")).toBeInTheDocument();
expect(screen.getByText("teams")).toBeInTheDocument();
});

it("hides the breakdown control when a single group has data", () => {
render(
<MetricSummaryCard
metric={summaryMetric({
value: 20,
breakdown: [
{ tool: "slack", value: 20 },
{ tool: "teams", value: 0 },
],
})}
entityId="me@x.com"
/>,
);
expect(
screen.queryByRole("button", { name: /By tool/ }),
).not.toBeInTheDocument();
});

it("renders an em dash when the entity has no value", () => {
render(
<MetricSummaryCard
metric={summaryMetric({ value: null })}
entityId="me@x.com"
/>,
);
expect(screen.getByText("—")).toBeInTheDocument();
});
});
Loading