diff --git a/src/api/metric-registry.ts b/src/api/metric-registry.ts
index 3c3285f..25ad19f 100644
--- a/src/api/metric-registry.ts
+++ b/src/api/metric-registry.ts
@@ -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.
diff --git a/src/api/metric-results-client.ts b/src/api/metric-results-client.ts
index 05ab507..70a71c7 100644
--- a/src/api/metric-results-client.ts
+++ b/src/api/metric-results-client.ts
@@ -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 {
@@ -53,7 +57,8 @@ export interface MetricDimension {
export type MetricResult =
| SumMetricResult
| RatioMetricResult
- | MedianMetricResult;
+ | MedianMetricResult
+ | DistinctCountMetricResult;
interface MetricResultBase {
metric_key: string;
@@ -79,6 +84,10 @@ export interface MedianMetricResult extends MetricResultBase {
computation: "median";
}
+export interface DistinctCountMetricResult extends MetricResultBase {
+ computation: "distinct_count";
+}
+
export type MetricResultView =
| PeriodView
| TimeseriesView
diff --git a/src/components/widgets/metric-views/collection-drilldown.test.tsx b/src/components/widgets/metric-views/collection-drilldown.test.tsx
index 843d4f6..a489521 100644
--- a/src/components/widgets/metric-views/collection-drilldown.test.tsx
+++ b/src/components/widgets/metric-views/collection-drilldown.test.tsx
@@ -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(
+ ,
+ );
+ 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(
diff --git a/src/components/widgets/metric-views/collection-drilldown.tsx b/src/components/widgets/metric-views/collection-drilldown.tsx
index 44bc5f1..2613f27 100644
--- a/src/components/widgets/metric-views/collection-drilldown.tsx
+++ b/src/components/widgets/metric-views/collection-drilldown.tsx
@@ -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";
@@ -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")
@@ -127,6 +136,22 @@ export function CollectionDrilldown({
className,
)}
>
+ {summaryMetrics.length > 0 ? (
+
2 && "xl:grid-cols-4",
+ )}
+ >
+ {summaryMetrics.map((metric) => (
+
+ ))}
+
+ ) : 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
diff --git a/src/components/widgets/metric-views/metric-breakdown.tsx b/src/components/widgets/metric-views/metric-breakdown.tsx
index e05f6d0..6778aa4 100644
--- a/src/components/widgets/metric-views/metric-breakdown.tsx
+++ b/src/components/widgets/metric-views/metric-breakdown.tsx
@@ -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;
@@ -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),
}));
diff --git a/src/components/widgets/metric-views/metric-group-card.tsx b/src/components/widgets/metric-views/metric-group-card.tsx
index ef36e51..a28b7cb 100644
--- a/src/components/widgets/metric-views/metric-group-card.tsx
+++ b/src/components/widgets/metric-views/metric-group-card.tsx
@@ -12,10 +12,8 @@ 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,
@@ -23,6 +21,7 @@ import {
} from "@/lib/scoring";
import {
STATUS_BG_CLASS,
+ STATUS_STRIPE_LEFT,
STATUS_TEXT_CLASS,
applyFocusStatus,
type Status,
@@ -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 (
-
+
{def.title}
{subtitle ? (
@@ -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 (
}
className={cn(
- "border-l-2 text-left transition-colors hover:bg-accent/50",
+ "text-left transition-colors hover:bg-accent/50",
stripeClass,
)}
>
@@ -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);
}
diff --git a/src/components/widgets/metric-views/metric-summary-card.test.tsx b/src/components/widgets/metric-views/metric-summary-card.test.tsx
new file mode 100644
index 0000000..a166e5e
--- /dev/null
+++ b/src/components/widgets/metric-views/metric-summary-card.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: /By tool/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders an em dash when the entity has no value", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/widgets/metric-views/metric-summary-card.tsx b/src/components/widgets/metric-views/metric-summary-card.tsx
new file mode 100644
index 0000000..91d3524
--- /dev/null
+++ b/src/components/widgets/metric-views/metric-summary-card.tsx
@@ -0,0 +1,173 @@
+import { useState } from "react";
+import { ChevronDown, ChevronRight } from "lucide-react";
+
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ dimensionColorSeed,
+ dimensionLabel,
+ dimensionSeriesKey,
+} from "@/components/widgets/metric-views/dimension-series";
+import { MetricSublabel } from "@/components/widgets/v2/metric-sublabel";
+import { useSettings } from "@/hooks/use-settings";
+import {
+ formatMetricNumber,
+ formatMetricValue,
+ metricDisplayUnit,
+} from "@/lib/format";
+import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection";
+import { derivePeerStanding } from "@/lib/metrics/peer-standing";
+import { seriesColors } from "@/lib/series-colors";
+import {
+ STATUS_STRIPE_LEFT,
+ STATUS_TEXT_CLASS,
+ applyFocusStatus,
+} from "@/lib/status";
+import { cn } from "@/lib/utils";
+
+export interface MetricSummaryCardProps {
+ metric: NormalizedMetricResult;
+ entityId: string;
+}
+
+
+/**
+ * Modality headline card: period total with peer status, plus a collapsible
+ * proportional breakdown over the metric's dimension groups (ribbon +
+ * legend). The breakdown section renders only when at least two groups have
+ * data — a single-source metric reads as a plain summary card.
+ */
+export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) {
+ const [open, setOpen] = useState(false);
+ const { focusMode } = useSettings();
+
+ const data = forEntity(metric, entityId);
+ const value = data.value;
+ // Eligibility (observed / suppressed / flat pool / neutral direction) and
+ // the median judgment come from the shared standing derivation — same
+ // verdict as the KPI tiles by construction. Only a strictly favorable /
+ // unfavorable median side earns a color; at-median is "on par", exactly
+ // the peer story's vocabulary for the same fact.
+ const standing = derivePeerStanding(metric.direction, data);
+ const status = applyFocusStatus(
+ standing.medianSide === "favorable"
+ ? "good"
+ : standing.medianSide === "unfavorable"
+ ? "bad"
+ : "neutral",
+ focusMode,
+ );
+ // The status is carried by the stripe and the value color alone — no
+ // status words on the card.
+ const stripeClass = STATUS_STRIPE_LEFT[status];
+
+ const rows = data.breakdown
+ .filter((row) => (row.value ?? 0) > 0)
+ .map((row) => ({
+ key: dimensionSeriesKey(row.dimensions),
+ colorSeed: dimensionColorSeed(row.dimensions),
+ label: dimensionLabel(row.dimensions),
+ value: row.value ?? 0,
+ }))
+ .sort((a, b) => b.value - a.value);
+ const colorsBySeed = seriesColors(rows.map((row) => row.colorSeed));
+ const rowsTotal = rows.reduce((sum, row) => sum + row.value, 0) || 1;
+ const breakdownLabel = `By ${(metric.breakdown?.dimensions ?? []).join(" / ")}`;
+
+ const displayUnit = metricDisplayUnit(metric.format, metric.unit);
+
+ return (
+
+
+ {/* KPI-tile line structure — label, sublabel slot, then the
+ value on its own line — so narrow cards never truncate the label
+ against the number, and all cards in a row share geometry (the
+ sublabel reserves two lines whenever explanations are on). */}
+
+
+ {metric.label}
+
+
+
+
+
+ {value == null
+ ? "—"
+ : metric.format === "percent"
+ ? formatMetricValue(value, metric.format, metric.unit)
+ : formatMetricNumber(value, metric.format)}
+
+ {value != null && displayUnit ? (
+
+ {displayUnit}
+
+ ) : null}
+
+
+ {rows.length > 1 ? (
+ <>
+ setOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
+ aria-expanded={open}
+ >
+ {open ? (
+
+ ) : (
+
+ )}
+ {breakdownLabel}
+
+ {open ? (
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ {rows.map((row) => (
+
+
+
+ {row.label}
+
+
+ {formatMetricValue(row.value, metric.format, metric.unit)}
+
+
+ ))}
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/widgets/metric-views/metric-trend.tsx b/src/components/widgets/metric-views/metric-trend.tsx
index 7258d98..cbbee14 100644
--- a/src/components/widgets/metric-views/metric-trend.tsx
+++ b/src/components/widgets/metric-views/metric-trend.tsx
@@ -29,8 +29,8 @@ import {
} from "@/components/widgets/metric-views/dimension-series";
import { formatMetricNumber } 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 MetricTrendProps {
/** One metric → one series per dimension group; several → one per metric. */
@@ -219,7 +219,7 @@ export function MetricTrend({ metrics, entityId, chart }: MetricTrendProps) {
);
}
- const colorsBySeed = swatchPalette(series.map((item) => item.colorSeed));
+ const colorsBySeed = seriesColors(series.map((item) => item.colorSeed));
const config: ChartConfig = Object.fromEntries(
series.map((item) => [
item.key,
@@ -239,11 +239,12 @@ export function MetricTrend({ metrics, entityId, chart }: MetricTrendProps) {
const compositionTotal = series.reduce((sum, item) => sum + item.total, 0);
const legendFormat: MetricFormat = metrics[0]?.format ?? "integer";
const legendUnit = metrics[0]?.unit ? ` ${metrics[0].unit}` : "";
- // For a composition, each part gets an integer share that sums to exactly
- // 100 (largest-remainder rounding — the legend must never read 99%/101%);
- // legendShares[i] is series[i]'s share. A non-composition has no whole → [].
+ // For a composition, each part gets a share label summing to exactly 100
+ // (largest-remainder rounding — the legend must never read 99%/101%; a
+ // nonzero part never reads 0%, gaining a decimal instead). legendShares[i]
+ // labels series[i]. A non-composition has no whole → [].
const legendShares = isComposition
- ? integerPercentShares(series.map((item) => item.total))
+ ? percentShareLabels(series.map((item) => item.total))
: [];
const axes = (
diff --git a/src/components/widgets/metric-views/peer-story.tsx b/src/components/widgets/metric-views/peer-story.tsx
index c388932..54681fc 100644
--- a/src/components/widgets/metric-views/peer-story.tsx
+++ b/src/components/widgets/metric-views/peer-story.tsx
@@ -24,6 +24,7 @@ import {
type PeerStoryEntry,
} from "@/lib/metrics/peer-story";
import { PEER_FILL, PEER_TEXT, type PeerCohortLabel } from "@/lib/peers";
+import { STATUS_STRIPE_LEFT, STATUS_STRIPE_TOP } from "@/lib/status";
import { cn } from "@/lib/utils";
interface PeerStoryProps {
@@ -88,9 +89,7 @@ function HeroCard({
@@ -113,7 +112,7 @@ function HeroCard({
{entry.sublabel}
) : null}
-
+
{entry.stats ? (
-
- gap{" "}
-
- {formatGap(entry)}
- {" "}
- from {cohortLabel} median{" "}
-
- {formatMetricValue(entry.stats.p50, entry.format, entry.unit)}
+ <>
+
+ ·
-
+
+ {Math.abs(entry.gapDelta) <= 1e-9 ? (
+ <>at the {cohortLabel} median >
+ ) : (
+ <>
+ gap{" "}
+
+ {formatGap(entry)}
+ {" "}
+ from {cohortLabel} median{" "}
+ >
+ )}
+
+ {formatMetricValue(entry.stats.p50, entry.format, entry.unit)}
+
+
+ >
) : null}
{entry.stats && entry.stats.max > entry.stats.min ? (
@@ -160,7 +170,13 @@ function HeroCard({
);
}
-function SideCards({ entries }: { entries: PeerStoryEntry[] }) {
+function SideCards({
+ entries,
+ cohortLabel,
+}: {
+ entries: PeerStoryEntry[];
+ cohortLabel: PeerCohortLabel;
+}) {
if (entries.length === 0) return null;
const stretchCards = entries.length > 1;
return (
@@ -173,7 +189,12 @@ function SideCards({ entries }: { entries: PeerStoryEntry[] }) {
)}
>
{entries.map((entry) => (
-
+
))}
);
@@ -182,9 +203,11 @@ function SideCards({ entries }: { entries: PeerStoryEntry[] }) {
function SideCard({
entry,
stretch,
+ cohortLabel,
}: {
entry: PeerStoryEntry;
stretch: boolean;
+ cohortLabel: PeerCohortLabel;
}) {
const unit = metricDisplayUnit(entry.format, entry.unit);
return (
@@ -196,9 +219,8 @@ function SideCard({
PEER_TEXT[entry.status],
entry.status === "top" && "bg-success/5",
entry.status === "bottom" && "bg-destructive/5",
- entry.status === "top" && "shadow-[inset_4px_0_0_0_var(--success)]",
- entry.status === "bottom" &&
- "shadow-[inset_4px_0_0_0_var(--destructive)]",
+ entry.status === "top" && STATUS_STRIPE_LEFT.good,
+ entry.status === "bottom" && STATUS_STRIPE_LEFT.bad,
)}
>
@@ -214,14 +236,45 @@ function SideCard({
) : null}
-
- {entry.format === "percent"
- ? formatMetricValue(entry.value, entry.format, entry.unit)
- : formatMetricNumber(entry.value, entry.format)}
- {unit ? (
-
- {unit}
-
+ {/* Mini-hero: value with the gap phrase beside it, rank as the
+ footer — the hero's facts at side-card size (minus the band,
+ which outgrows these cards), so chips are the only
+ tooltip-gated tier. */}
+
+
+ {entry.format === "percent"
+ ? formatMetricValue(entry.value, entry.format, entry.unit)
+ : formatMetricNumber(entry.value, entry.format)}
+ {unit ? (
+
+ {unit}
+
+ ) : null}
+
+ {entry.stats ? (
+ <>
+
+ ·
+
+
+ {Math.abs(entry.gapDelta) <= 1e-9 ? (
+ <>at the {cohortLabel} median >
+ ) : (
+ <>
+ gap{" "}
+
+ {formatGap(entry)}
+ {" "}
+ from {cohortLabel} median{" "}
+ >
+ )}
+
+ {formatMetricValue(entry.stats.p50, entry.format, entry.unit)}
+
+
+ >
) : null}
@@ -253,7 +306,9 @@ function ChipTooltip({
{entry.stats ? (
- gap {formatGap(entry)} · {cohortLabel} median{" "}
+ {Math.abs(entry.gapDelta) <= 1e-9
+ ? `at the ${cohortLabel} median `
+ : `gap ${formatGap(entry)} · ${cohortLabel} median `}
{formatMetricValue(entry.stats.p50, entry.format, entry.unit)}
) : null}
@@ -500,7 +555,7 @@ export function PeerStory({
{hero ? (
-
+
) : focusMode === "critical" ? (
diff --git a/src/components/widgets/metric-views/team-metric-group-card.tsx b/src/components/widgets/metric-views/team-metric-group-card.tsx
index 4bda391..115ed61 100644
--- a/src/components/widgets/metric-views/team-metric-group-card.tsx
+++ b/src/components/widgets/metric-views/team-metric-group-card.tsx
@@ -14,11 +14,14 @@ import {
type TeamMetricStanding,
} from "@/lib/insight/team-metrics";
import {
- SECTION_STRIPE,
aggregateSectionStatus,
sectionCounts,
} from "@/lib/scoring";
-import { STATUS_BG_CLASS, applyFocusStatus } from "@/lib/status";
+import {
+ STATUS_BG_CLASS,
+ STATUS_STRIPE_LEFT,
+ applyFocusStatus,
+} from "@/lib/status";
import type { MetricCollectionResult } from "@/queries/metric-results";
import { cn } from "@/lib/utils";
@@ -48,7 +51,7 @@ export function TeamMetricGroupCard({
// 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 (
-
+
{def.title}
{subtitle ? (
@@ -97,8 +100,7 @@ export function TeamMetricGroupCard({
const preview: TeamMetricStanding[] = def.card.preview
.map((key) => scored.find((s) => s.metric.metric_key === key))
.filter((s): s is TeamMetricStanding => s != null);
- const stripeClass =
- status === "neutral" ? "border-l-border" : SECTION_STRIPE[status];
+ const stripeClass = STATUS_STRIPE_LEFT[status];
return (
}
className={cn(
- "border-l-2 text-left transition-colors hover:bg-accent/50",
+ "text-left transition-colors hover:bg-accent/50",
stripeClass,
)}
>
diff --git a/src/components/widgets/v2/collab-messaging-panel.stories.tsx b/src/components/widgets/v2/collab-messaging-panel.stories.tsx
deleted file mode 100644
index 7d27e05..0000000
--- a/src/components/widgets/v2/collab-messaging-panel.stories.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * Story + browser component test for (#1527).
- *
- * Mirrors the KpiTile PoC: the story is tagged `["test"]` so it runs as a real-
- * browser test via @storybook/addon-vitest, with BOTH wire calls mocked over
- * MSW — the catalog (`/catalog/get_metrics`) and the collab peer-counter query
- * (`/metrics/{…0053}/query`). Asserts the two Messaging counters render under
- * the "Messaging" heading, driven entirely by the catalog rows.
- *
- * See docs/testing/storybook-component-tests.md.
- */
-
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { http, HttpResponse } from "msw";
-import { expect, waitFor } from "storybook/test";
-
-import type { CatalogResponse } from "@/api/catalog-client";
-import { METRIC_REGISTRY } from "@/api/metric-registry";
-import { authStore } from "@/auth/auth-store";
-
-import { CollabMessagingPanel } from "./collab-messaging-panel";
-
-const TENANT = "t-1";
-
-const catalog: CatalogResponse = {
- tenant_id: TENANT,
- generated_at: "2026-06-01T00:00:00Z",
- metrics: [
- {
- id: "id-msg",
- metric_key: "collab_person_counter_daily.messages_sent",
- label: "Messages sent",
- sublabel: "Chat messages across sources",
- unit: "messages",
- higher_is_better: true,
- is_member_scale: false,
- source_tags: ["m365", "slack", "zulip"],
- schema_status: "ok",
- thresholds: {
- good: 200,
- warn: 50,
- resolved_from: "product-default",
- bounded_by_lock: false,
- },
- },
- {
- id: "id-chan",
- metric_key: "collab_person_counter_daily.channel_posts",
- label: "Channel posts",
- sublabel: "Channel posts + replies",
- unit: "messages",
- higher_is_better: true,
- is_member_scale: false,
- source_tags: ["m365", "slack"],
- schema_status: "ok",
- thresholds: {
- good: 20,
- warn: 5,
- resolved_from: "product-default",
- bounded_by_lock: false,
- },
- },
- ],
- links: [],
-};
-
-const counterRows = [
- {
- person_id: "dev@example.com",
- org_unit_id: "Engineering",
- metric_key: "collab_person_counter_daily.messages_sent",
- value: 30,
- median: 30,
- p25: 20,
- p75: 40,
- n: 5,
- range_min: 10,
- range_max: 50,
- },
- {
- person_id: "dev@example.com",
- org_unit_id: "Engineering",
- metric_key: "collab_person_counter_daily.channel_posts",
- value: 6,
- median: 6,
- p25: 4,
- p75: 8,
- n: 5,
- range_min: 2,
- range_max: 10,
- },
-];
-
-const handlers = [
- http.post("*/catalog/get_metrics", () => HttpResponse.json(catalog)),
- http.post(`*/metrics/${METRIC_REGISTRY.V2_IC_COLLAB_PEER_COUNTERS}/query`, () =>
- HttpResponse.json({ items: counterRows }),
- ),
-];
-
-const meta: Meta = {
- title: "Widgets/v2/CollabMessagingPanel",
- component: CollabMessagingPanel,
- args: {
- personId: "dev@example.com",
- range: { from: "2026-04-01", to: "2026-04-30" },
- },
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
- beforeEach: () => {
- authStore.setTenantId(TENANT);
- },
-};
-export default meta;
-
-type Story = StoryObj;
-
-/** Demo story for the Storybook UI (not a test — no `test` tag). */
-export const Default: Story = {
- parameters: { msw: { handlers } },
-};
-
-/** Component test: both Messaging counters render under the "Messaging" card. */
-export const TestMessagingCounters: Story = {
- tags: ["test"],
- parameters: { msw: { handlers } },
- play: async ({ canvas }) => {
- await expect(canvas.getByText("Messaging")).toBeInTheDocument();
- // Both counters render once the (mocked) counter query + catalog resolve.
- await waitFor(() =>
- expect(canvas.getByText("Messages sent")).toBeInTheDocument(),
- );
- await expect(canvas.getByText("Channel posts")).toBeInTheDocument();
- },
-};
diff --git a/src/components/widgets/v2/collab-messaging-panel.test.tsx b/src/components/widgets/v2/collab-messaging-panel.test.tsx
deleted file mode 100644
index f7d71f1..0000000
--- a/src/components/widgets/v2/collab-messaging-panel.test.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-import type { CatalogMetric } from "@/api/catalog-client";
-import type { DateRange } from "@/api/period-to-date-range";
-import { CollabMessagingPanel } from "@/components/widgets/v2/collab-messaging-panel";
-import type { PeerCounterRow } from "@/queries/v2/ic-extras";
-
-const mocks = vi.hoisted(() => ({
- useCatalog: vi.fn(),
- useIcCollabPeerCounters: vi.fn(),
-}));
-
-vi.mock("@/api/use-catalog", () => ({
- useCatalog: mocks.useCatalog,
-}));
-
-vi.mock("@/queries/v2/ic-extras", async () => {
- const actual = await vi.importActual(
- "@/queries/v2/ic-extras",
- );
- return {
- ...actual,
- useIcCollabPeerCounters: mocks.useIcCollabPeerCounters,
- };
-});
-
-vi.mock("@/hooks/use-settings", () => ({
- useSettings: () => ({ focusMode: "all" }),
-}));
-
-const RANGE: DateRange = { from: "2026-04-01", to: "2026-04-07" };
-
-function queryState(data: T, overrides: Partial> = {}) {
- return { data, isPending: false, isError: false, refetch: vi.fn(), ...overrides };
-}
-
-function catalogMetric(
- metric_key: string,
- label: string,
- overrides: Partial = {},
-): CatalogMetric {
- return {
- id: metric_key,
- metric_key,
- label,
- higher_is_better: true,
- is_member_scale: false,
- source_tags: [],
- schema_status: "ok",
- thresholds: {
- good: 1,
- warn: 0,
- resolved_from: "product-default",
- bounded_by_lock: false,
- },
- ...overrides,
- };
-}
-
-function mockCatalog(metrics: CatalogMetric[]) {
- const byKey = new Map(metrics.map((m) => [m.metric_key, m]));
- mocks.useCatalog.mockReturnValue({
- byMetricKey: (metricKey: string) => byKey.get(metricKey),
- isLoading: false,
- isError: false,
- refetch: vi.fn(),
- });
-}
-
-function peerCounter(
- metric_key: string,
- value: number,
- overrides: Partial = {},
-): PeerCounterRow {
- return {
- person_id: "dev@example.com",
- org_unit_id: "Engineering",
- metric_key,
- value,
- median: 30,
- p25: 20,
- p75: 40,
- n: 5,
- range_min: 10,
- range_max: 50,
- ...overrides,
- };
-}
-
-describe("", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- mockCatalog([
- catalogMetric(
- "collab_person_counter_daily.messages_sent",
- "Messages sent",
- { unit: "messages", sublabel: "Chat messages across sources" },
- ),
- catalogMetric(
- "collab_person_counter_daily.channel_posts",
- "Channel posts",
- { unit: "messages", sublabel: "Channel posts + replies" },
- ),
- ]);
- mocks.useIcCollabPeerCounters.mockReturnValue(queryState([]));
- });
-
- it("renders nothing without a person or range", () => {
- const { container, rerender } = render(
- ,
- );
- expect(container).toBeEmptyDOMElement();
-
- rerender( );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("renders both Messaging counters under the Messaging heading", () => {
- mocks.useIcCollabPeerCounters.mockReturnValue(
- queryState([
- peerCounter("collab_person_counter_daily.messages_sent", 30),
- peerCounter("collab_person_counter_daily.channel_posts", 6, {
- median: 6,
- p25: 4,
- p75: 8,
- range_min: 2,
- range_max: 10,
- }),
- ]),
- );
-
- render( );
-
- expect(screen.getByText("Messaging")).toBeInTheDocument();
- expect(screen.getByText("Messages sent")).toBeInTheDocument();
- expect(screen.getByText("Channel posts")).toBeInTheDocument();
- });
-
- it("omits counters whose metric_key is not in the catalog", () => {
- mocks.useIcCollabPeerCounters.mockReturnValue(
- queryState([
- peerCounter("collab_person_counter_daily.messages_sent", 30),
- peerCounter("collab_person_counter_daily.unknown_key", 99),
- ]),
- );
-
- render( );
-
- expect(screen.getByText("Messages sent")).toBeInTheDocument();
- expect(screen.queryByText("collab_person_counter_daily.unknown_key")).not.toBeInTheDocument();
- });
-});
diff --git a/src/components/widgets/v2/collab-messaging-panel.tsx b/src/components/widgets/v2/collab-messaging-panel.tsx
deleted file mode 100644
index 1fa6f01..0000000
--- a/src/components/widgets/v2/collab-messaging-panel.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { DateRange } from "@/api/period-to-date-range";
-import type { CatalogMetric } from "@/api/catalog-client";
-import { useCatalog } from "@/api/use-catalog";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-import { ComingSoon } from "@/components/widgets/coming-soon";
-import {
- PeerStorySection,
- type PeerStoryInput,
-} from "@/components/widgets/v2/peer-story-section";
-import type { PeerStats } from "@/lib/peers";
-import {
- useIcCollabPeerCounters,
- type PeerCounterRow,
-} from "@/queries/v2/ic-extras";
-
-interface CollabMessagingPanelProps {
- personId: string | null | undefined;
- range: DateRange | null | undefined;
-}
-
-/** Build `PeerStats` from a counter row, or null when any band is absent. */
-function counterStats(row: PeerCounterRow): PeerStats | null {
- const { p25, median, p75, range_min, range_max, n } = row;
- const bands = [p25, median, p75, range_min, range_max, n];
- if (bands.some((b) => b == null || !Number.isFinite(b))) return null;
- return {
- p25: p25 as number,
- p50: median as number,
- p75: p75 as number,
- min: range_min as number,
- max: range_max as number,
- n: n as number,
- };
-}
-
-function peerStoryEntries(
- rows: PeerCounterRow[],
- byMetricKey: (metricKey: string) => CatalogMetric | undefined,
-): PeerStoryInput[] {
- return rows.flatMap((row) => {
- const catalog = byMetricKey(row.metric_key);
- if (!catalog || row.value == null || !Number.isFinite(row.value)) return [];
- return [
- {
- key: row.metric_key,
- label: catalog.label,
- sublabel: catalog.sublabel,
- value: row.value,
- unit: catalog.unit,
- format: catalog.format,
- higherIsBetter: catalog.higher_is_better,
- stats: counterStats(row),
- },
- ];
- });
-}
-
-/**
- * Messaging modality peer counters for the Collaboration drilldown (#1527):
- * `messages_sent` + `channel_posts` vs the person's department cohort. Mirrors
- * the AI adoption peer-counters section; fully catalog-driven — any counter row
- * whose `metric_key` is in the catalog renders.
- */
-export function CollabMessagingPanel({
- personId,
- range,
-}: CollabMessagingPanelProps) {
- const canQuery = Boolean(personId && range);
- const fallbackRange = range ?? { from: "", to: "" };
- const countersQ = useIcCollabPeerCounters(personId ?? "", fallbackRange);
- const catalogQ = useCatalog();
-
- if (!canQuery) return null;
-
- const isPending = countersQ.isPending || catalogQ.isLoading;
- const isError = countersQ.isError || catalogQ.isError;
-
- return (
-
-
- Messaging
-
- Messages sent and channel posts vs your department
-
-
-
- {isPending ? (
-
- ) : isError ? (
- {
- void countersQ.refetch();
- catalogQ.refetch();
- }}
- />
- ) : (
-
- )}
-
-
- );
-}
diff --git a/src/components/widgets/v2/group-drilldown-sheet.tsx b/src/components/widgets/v2/group-drilldown-sheet.tsx
index 0884015..a84f6f1 100644
--- a/src/components/widgets/v2/group-drilldown-sheet.tsx
+++ b/src/components/widgets/v2/group-drilldown-sheet.tsx
@@ -1,7 +1,6 @@
import { useState } from "react";
import { Maximize2, Minimize2, XIcon } from "lucide-react";
-import { CollabMessagingPanel } from "@/components/widgets/v2/collab-messaging-panel";
import { ComingSoon } from "@/components/widgets/coming-soon";
import { CountersBlock } from "@/components/widgets/v2/counters-block";
import { DistributionStrip } from "@/components/widgets/v2/distribution-strip";
@@ -15,7 +14,6 @@ import {
type SectionTrendPoint,
type SectionTrendSeries,
} from "@/components/widgets/v2/section-trend";
-import { SummaryWithBreakdown } from "@/components/widgets/v2/summary-with-breakdown";
import {
Dialog,
DialogClose,
@@ -32,7 +30,6 @@ import {
useIcDrilldownBatch,
type DrilldownBatchData,
} from "@/queries/v2/ic-extras";
-import { deriveCollabActivities } from "@/lib/insight/v2/derivations";
import type { PeerCohortLabel } from "@/lib/peers";
import type { MetricCollectionResult } from "@/queries/metric-results";
import { cn } from "@/lib/utils";
@@ -219,9 +216,7 @@ function LegacyDrilldownBody({
batchQ.isFetching && "opacity-60",
)}
>
- {batch ? (
-
- ) : null}
+ {batch ? : null}
{counters.length > 0 ? (
) : null}
@@ -248,9 +243,6 @@ function LegacyDrilldownBody({
/>
)
) : null}
- {sectionId === "collaboration" ? (
-
- ) : null}
{rows.length === 0 ? (
No data for this section in the selected period.
@@ -263,11 +255,9 @@ function LegacyDrilldownBody({
function DrilldownExtras({
sectionId,
batch,
- rows,
}: {
sectionId: string;
batch: DrilldownBatchData;
- rows: BulletMetric[];
}) {
if (sectionId === "task_delivery") {
const data: SectionTrendPoint[] = (batch.delivery ?? []).map((d) => ({
@@ -286,23 +276,5 @@ function DrilldownExtras({
/>
);
}
- if (sectionId === "collaboration") {
- const activities = deriveCollabActivities(rows);
- return (
-
- {activities.map((a) => (
-
- ))}
-
- );
- }
return null;
}
diff --git a/src/components/widgets/v2/members-heatmap/triage-list.tsx b/src/components/widgets/v2/members-heatmap/triage-list.tsx
index c71e633..e3259cd 100644
--- a/src/components/widgets/v2/members-heatmap/triage-list.tsx
+++ b/src/components/widgets/v2/members-heatmap/triage-list.tsx
@@ -2,12 +2,13 @@ import { ChevronRight } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useSettings } from "@/hooks/use-settings";
+import { peerStatusToStatus } from "@/lib/insight/v2/peer-status";
import {
applyFocus,
- PEER_BORDER,
PEER_TEXT,
type PeerStatusWithNeutral,
} from "@/lib/peers";
+import { STATUS_STRIPE_LEFT } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { TeamMember } from "@/types/insight";
@@ -51,8 +52,8 @@ export function TriageList({ rows, onMemberClick }: TriageListProps) {
onMemberClick(r.member)}
>
diff --git a/src/components/widgets/v2/peer-story-section.tsx b/src/components/widgets/v2/peer-story-section.tsx
index 3a0024c..6d181e1 100644
--- a/src/components/widgets/v2/peer-story-section.tsx
+++ b/src/components/widgets/v2/peer-story-section.tsx
@@ -73,8 +73,7 @@ function storyEntries(entries: PeerStoryInput[]): PeerStoryEntry[] {
const status = usePeerRanking
? peerStatusVsQuartiles(entry.value, stats, entry.higherIsBetter)
: "neutral";
- const rawDelta = stats ? entry.value - stats.p50 : 0;
- const gapDelta = entry.higherIsBetter ? rawDelta : -rawDelta;
+ const gapDelta = stats ? entry.value - stats.p50 : 0;
const gapPct =
stats && Math.abs(stats.p50) > 1e-9
? gapDelta / Math.abs(stats.p50)
diff --git a/src/components/widgets/v2/section-card.tsx b/src/components/widgets/v2/section-card.tsx
index 6debe87..80042ef 100644
--- a/src/components/widgets/v2/section-card.tsx
+++ b/src/components/widgets/v2/section-card.tsx
@@ -13,12 +13,12 @@ import { hasBulletValue, rowStatus } from "@/lib/insight/v2/peer-status";
import {
aggregateSectionStatus,
pickSectionHeadline,
- SECTION_STRIPE,
sectionCounts,
type ScoredMetric,
} from "@/lib/scoring";
import {
STATUS_BG_CLASS,
+ STATUS_STRIPE_LEFT,
STATUS_TEXT_CLASS,
applyFocusStatus,
type Status,
@@ -26,22 +26,10 @@ import {
import { cn } from "@/lib/utils";
import type { BulletMetric } from "@/types/insight";
-const COLLAB_PREVIEW_KEYS = [
- "meeting_hours",
- "slack_messages_sent",
- "m365_emails_sent",
-];
-
function pickPreviewRows(
- sectionId: string | undefined,
+ _sectionId: string | undefined,
rows: BulletMetric[],
): BulletMetric[] {
- if (sectionId === "collaboration") {
- const byKey = new Map(rows.map((r) => [r.metric_key, r]));
- return COLLAB_PREVIEW_KEYS.map((k) => byKey.get(k)).filter(
- (r): r is BulletMetric => r != null && hasBulletValue(r),
- );
- }
return rows.filter(hasBulletValue).slice(0, 3);
}
@@ -79,7 +67,7 @@ export function SectionCard({
if (unavailable) {
return (
-
+
{title}
@@ -116,8 +104,7 @@ export function SectionCard({
const preview = pickPreviewRows(sectionId, rows);
const isEmpty = evaluated === 0 && preview.length === 0;
- const stripeClass =
- status === "neutral" ? "border-l-border" : SECTION_STRIPE[status];
+ const stripeClass = STATUS_STRIPE_LEFT[status];
return (
}
className={cn(
- "border-l-2 text-left transition-colors hover:bg-accent/50",
+ "text-left transition-colors hover:bg-accent/50",
stripeClass,
)}
>
diff --git a/src/components/widgets/v2/summary-with-breakdown.tsx b/src/components/widgets/v2/summary-with-breakdown.tsx
deleted file mode 100644
index 85856a7..0000000
--- a/src/components/widgets/v2/summary-with-breakdown.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import { useState } from "react";
-import { ChevronDown, ChevronRight } from "lucide-react";
-
-import { ComingSoon } from "@/components/widgets/coming-soon";
-import { MetricSublabel } from "@/components/widgets/v2/metric-sublabel";
-import { Card, CardContent } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-import { useSettings } from "@/hooks/use-settings";
-import { STATUS_TEXT_CLASS, applyFocusStatus, type Status } from "@/lib/status";
-import { SECTION_STRIPE } from "@/lib/scoring";
-import { cn } from "@/lib/utils";
-
-export interface BreakdownItem {
- label: string;
- value: number;
- unit?: string;
-}
-
-export interface SummaryWithBreakdownProps {
- label: string;
- description?: string;
- value: number;
- unit?: string;
- status?: Status;
- /** Catalog source tags (e.g. ["m365","zoom"]). Shown as provenance instead
- * of a peer-status line when present. */
- sources?: string[];
- breakdown: BreakdownItem[];
- breakdownLabel?: string;
- defaultOpen?: boolean;
- isPending?: boolean;
- isError?: boolean;
- onRetry?: () => void;
-}
-
-/** Display labels for catalog source tags. */
-const SOURCE_LABELS: Record = {
- m365: "M365",
- teams: "Teams",
- zoom: "Zoom",
- slack: "Slack",
- jira: "Jira",
- bitbucket: "Bitbucket",
- github: "GitHub",
- gitlab: "GitLab",
- cursor: "Cursor",
- claude_code: "Claude Code",
- codex: "Codex",
- copilot: "Copilot",
- bamboohr: "BambooHR",
-};
-
-function formatSources(sources: string[]): string {
- return sources.map((s) => SOURCE_LABELS[s] ?? s).join(" · ");
-}
-
-const SEGMENT_CLASSES = [
- "bg-chart-1",
- "bg-chart-2",
- "bg-chart-3",
- "bg-chart-4",
- "bg-chart-5",
-];
-
-function formatNum(v: number, unit?: string): string {
- if (!Number.isFinite(v)) return "—";
- if (unit === "h" || unit === "d") {
- return v >= 10 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, "");
- }
- return Math.round(v).toString();
-}
-
-export function SummaryWithBreakdown({
- label,
- description,
- value,
- unit,
- status: statusProp = "neutral",
- sources,
- breakdown,
- breakdownLabel = "Breakdown",
- defaultOpen = false,
- isPending,
- isError,
- onRetry,
-}: SummaryWithBreakdownProps) {
- const [open, setOpen] = useState(defaultOpen);
- const { focusMode } = useSettings();
- const status = applyFocusStatus(statusProp, focusMode);
- const breakdownTotal = breakdown.reduce((s, b) => s + b.value, 0) || 1;
- const stripeClass =
- status === "neutral" ? "border-l-border" : SECTION_STRIPE[status];
-
- if (isPending) {
- return ;
- }
- if (isError) {
- return (
-
- );
- }
-
- return (
-
-
-
-
- {label}
-
- {sources && sources.length > 0 ? (
-
- {formatSources(sources)}
-
- ) : (
-
- {status === "neutral" ? "no data" : status}
-
- )}
-
-
-
- {formatNum(value, unit)}
-
- {unit ? (
- {unit}
- ) : null}
-
-
-
- {breakdown.length > 0 ? (
- <>
- setOpen((v) => !v)}
- className="flex items-center gap-1.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
- aria-expanded={open}
- >
- {open ? (
-
- ) : (
-
- )}
- {breakdownLabel}
-
- {open ? (
-
-
- {breakdown.map((b, i) => {
- const width = (b.value / breakdownTotal) * 100;
- return (
-
- );
- })}
-
-
- {breakdown.map((b, i) => (
-
-
-
- {b.label}
-
-
- {formatNum(b.value, b.unit)}
- {b.unit ? ` ${b.unit}` : ""}
-
-
- ))}
-
-
- ) : null}
- >
- ) : null}
-
-
- );
-}
diff --git a/src/index.css b/src/index.css
index 318cb49..b916f78 100644
--- a/src/index.css
+++ b/src/index.css
@@ -75,13 +75,42 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --swatch-l: 0.55;
- --swatch-fg: oklch(0.98 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --brand-slack: oklch(0.46 0.16 325);
+ --brand-m365: oklch(0.62 0.19 40);
+ --brand-zoom: oklch(0.55 0.21 262);
+ --brand-zulip: oklch(0.58 0.14 155);
+ --brand-github: oklch(0.35 0.02 270);
+ --brand-gitlab: oklch(0.7 0.18 45);
+ --brand-bitbucket: oklch(0.5 0.2 262);
+ --brand-bitbucket-server: oklch(0.42 0.16 265);
+ --brand-cursor: oklch(0.3 0.01 270);
+ --brand-claude: oklch(0.5 0.1 50);
+ --brand-claude-code: oklch(0.68 0.14 38);
+ --brand-chatgpt: oklch(0.6 0.12 170);
+ --brand-codex: oklch(0.45 0.09 180);
+ --brand-openai: oklch(0.68 0.1 172);
+ --brand-copilot: oklch(0.58 0.17 295);
+ --brand-jetbrains: oklch(0.55 0.21 355);
+ --brand-windsurf: oklch(0.78 0.14 160);
+ --brand-jira: oklch(0.5 0.19 262);
+ --brand-youtrack: oklch(0.6 0.22 345);
+ --brand-hubspot: oklch(0.72 0.17 35);
+ --brand-salesforce: oklch(0.67 0.14 230);
+ --brand-jsm: oklch(0.5 0.19 262);
+ --brand-zendesk: oklch(0.42 0.07 195);
+ --brand-confluence: oklch(0.55 0.18 258);
+ --brand-outline: oklch(0.45 0.08 275);
+ --brand-allure: oklch(0.75 0.15 110);
+ --brand-figma: oklch(0.65 0.2 30);
+ --brand-bamboohr: oklch(0.72 0.18 130);
+ --brand-workday: oklch(0.72 0.16 60);
+ --brand-ms-entra: oklch(0.6 0.14 235);
+ --brand-ldap: oklch(0.5 0.03 260);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
@@ -116,13 +145,42 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --swatch-l: 0.74;
- --swatch-fg: oklch(0.18 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --brand-slack: oklch(0.68 0.14 325);
+ --brand-m365: oklch(0.7 0.17 42);
+ --brand-zoom: oklch(0.7 0.16 258);
+ --brand-zulip: oklch(0.72 0.14 158);
+ --brand-github: oklch(0.85 0.02 270);
+ --brand-gitlab: oklch(0.74 0.16 45);
+ --brand-bitbucket: oklch(0.68 0.16 258);
+ --brand-bitbucket-server: oklch(0.6 0.14 262);
+ --brand-cursor: oklch(0.9 0.01 270);
+ --brand-claude: oklch(0.65 0.1 50);
+ --brand-claude-code: oklch(0.74 0.12 40);
+ --brand-chatgpt: oklch(0.7 0.12 170);
+ --brand-codex: oklch(0.6 0.1 185);
+ --brand-openai: oklch(0.78 0.1 172);
+ --brand-copilot: oklch(0.7 0.15 295);
+ --brand-jetbrains: oklch(0.68 0.18 355);
+ --brand-windsurf: oklch(0.8 0.13 162);
+ --brand-jira: oklch(0.68 0.16 258);
+ --brand-youtrack: oklch(0.7 0.18 345);
+ --brand-hubspot: oklch(0.75 0.15 35);
+ --brand-salesforce: oklch(0.74 0.12 230);
+ --brand-jsm: oklch(0.68 0.16 258);
+ --brand-zendesk: oklch(0.7 0.09 190);
+ --brand-confluence: oklch(0.7 0.15 255);
+ --brand-outline: oklch(0.65 0.08 275);
+ --brand-allure: oklch(0.78 0.14 110);
+ --brand-figma: oklch(0.72 0.17 32);
+ --brand-bamboohr: oklch(0.76 0.16 130);
+ --brand-workday: oklch(0.76 0.14 60);
+ --brand-ms-entra: oklch(0.7 0.12 235);
+ --brand-ldap: oklch(0.7 0.03 260);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
diff --git a/src/lib/insight/attention.ts b/src/lib/insight/attention.ts
index 3fb42bc..2fb67e4 100644
--- a/src/lib/insight/attention.ts
+++ b/src/lib/insight/attention.ts
@@ -8,7 +8,7 @@ import {
forEntity,
type NormalizedMetricResult,
} from "@/lib/metrics/collection";
-import { toPeerStats } from "@/lib/metrics/peer-story";
+import { toPeerStats } from "@/lib/metrics/peer-standing";
import { peerStatusVsQuartiles } from "@/lib/peers";
import type { BulletMetric } from "@/types/insight";
diff --git a/src/lib/insight/groups.ts b/src/lib/insight/groups.ts
index 5651f1c..b33aa89 100644
--- a/src/lib/insight/groups.ts
+++ b/src/lib/insight/groups.ts
@@ -16,7 +16,7 @@ import type { MetricCollectionConfig } from "@/lib/metrics/collection";
*/
export type TimeseriesChartKind = "line" | "stacked-bar";
-export type BreakdownChartKind = "bars";
+export type BreakdownChartKind = "bars" | "summary-card";
export type HistogramChartKind = "histogram";
export type ChartKind =
| TimeseriesChartKind
@@ -149,6 +149,82 @@ const GIT_OUTPUT_COLLECTION: MetricCollectionConfig = {
],
};
+const COLLABORATION_COLLECTION: MetricCollectionConfig = {
+ metrics: [
+ {
+ key: "collab.messages_sent",
+ views: [
+ { view: "period" },
+ { view: "peer" },
+ { view: "breakdown", dimensions: ["tool"] },
+ ],
+ },
+ { key: "collab.channel_posts", views: [{ view: "period" }, { view: "peer" }] },
+ { key: "collab.dm_ratio", views: [{ view: "period" }, { view: "peer" }] },
+ {
+ key: "collab.msgs_per_active_day",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ { key: "collab.active_days", views: [{ view: "period" }, { view: "peer" }] },
+ {
+ key: "collab.meeting_hours",
+ views: [
+ { view: "period" },
+ { view: "peer" },
+ { view: "breakdown", dimensions: ["tool"] },
+ ],
+ },
+ {
+ key: "collab.meetings_count",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ {
+ key: "collab.meeting_free_days",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ {
+ key: "collab.meetings_organized",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ { key: "collab.adhoc_meetings", views: [{ view: "period" }, { view: "peer" }] },
+ {
+ key: "collab.scheduled_meetings",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ { key: "collab.focus_time_pct", views: [{ view: "period" }, { view: "peer" }] },
+ { key: "collab.breadth", views: [{ view: "period" }, { view: "peer" }] },
+ {
+ key: "collab.emails_sent",
+ views: [
+ { view: "period" },
+ { view: "peer" },
+ // Single-tool today; the summary card hides its breakdown section
+ // below two groups, so this lights up if a second mail source lands.
+ { view: "breakdown", dimensions: ["tool"] },
+ ],
+ },
+ { key: "collab.emails_received", views: [{ view: "period" }, { view: "peer" }] },
+ { key: "collab.emails_read", views: [{ view: "period" }, { view: "peer" }] },
+ { key: "collab.files_engaged", views: [{ view: "period" }, { view: "peer" }] },
+ {
+ key: "collab.files_shared_internal",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ {
+ key: "collab.files_shared_external",
+ views: [{ view: "period" }, { view: "peer" }],
+ },
+ {
+ key: "collab.files_shared",
+ views: [
+ { view: "period" },
+ { view: "peer" },
+ { view: "breakdown", dimensions: ["scope"] },
+ ],
+ },
+ ],
+};
+
export const GROUPS: readonly GroupDef[] = [
{ kind: "legacy", id: "task_delivery", title: "Task delivery" },
{
@@ -179,7 +255,35 @@ export const GROUPS: readonly GroupDef[] = [
{ chart: "histogram", view: "histogram", metrics: ["git.commit_size"] },
],
},
- { kind: "legacy", id: "collaboration", title: "Collaboration" },
+ {
+ kind: "metrics",
+ id: "collaboration",
+ title: "Collaboration",
+ collection: COLLABORATION_COLLECTION,
+ card: {
+ preview: [
+ "collab.messages_sent",
+ "collab.meeting_hours",
+ "collab.focus_time_pct",
+ ],
+ },
+ drilldown: [
+ // Modality headline cards (period total + dimension breakdown) instead
+ // of trend charts — one card per modality. Everything else takes its
+ // standing in the peer story below; a second card row echoed the
+ // story's outliers.
+ {
+ chart: "summary-card",
+ view: "breakdown",
+ metrics: [
+ "collab.meeting_hours",
+ "collab.messages_sent",
+ "collab.emails_sent",
+ "collab.files_shared",
+ ],
+ },
+ ],
+ },
{
kind: "metrics",
id: "ai_adoption",
@@ -225,7 +329,7 @@ export type KpiTileSource =
export const KPI_ROW: readonly KpiTileSource[] = [
{ kind: "legacy", key: "tasks_closed", groupId: "task_delivery" },
- { kind: "legacy", key: "focus_time_pct", groupId: "collaboration" },
+ { kind: "metric", metricKey: "collab.focus_time_pct" },
{ kind: "metric", metricKey: "git.prs_merged" },
{ kind: "metric", metricKey: "ai.active_days" },
{ kind: "metric", metricKey: "ai.accepted_lines" },
diff --git a/src/lib/insight/kpi-row.test.ts b/src/lib/insight/kpi-row.test.ts
index 1983cbd..69f40d4 100644
--- a/src/lib/insight/kpi-row.test.ts
+++ b/src/lib/insight/kpi-row.test.ts
@@ -81,23 +81,6 @@ describe("legacyKpiTiles", () => {
expect(tile.context).toBe("jira");
});
- it("drops the trailing % from percent-point deltas", () => {
- const tiles = legacyKpiTiles(
- [
- icKpi({
- metric_key: "focus_time_pct",
- unit: "%",
- value: "62",
- raw_value: 62,
- delta: "+5%",
- }),
- ],
- () => ({ ...CATALOG_ROW, format: "percent" }) as CatalogMetric,
- "all",
- );
- expect(tiles[0]?.delta?.text).toBe("+5");
- expect(tiles[0]?.value).toBe("62%");
- });
});
describe("metricKpiTiles", () => {
@@ -165,14 +148,14 @@ describe("metricKpiTiles", () => {
expect(tiles[0]?.valueStatus).toBe("neutral");
});
- it("computes pp deltas for percent ratios (pinned until a ratio reaches the row)", () => {
- const current = metricResult("ai.active_days", 77, {
- metric_key: "ai.active_days",
+ it("computes pp deltas for percent-ratio tiles (focus time)", () => {
+ const current = metricResult("collab.focus_time_pct", 77, {
+ metric_key: "collab.focus_time_pct",
format: "percent",
computation: "ratio",
scale: 100,
} as Partial);
- const previous = metricResult("ai.active_days", 72, {
+ const previous = metricResult("collab.focus_time_pct", 72, {
format: "percent",
computation: "ratio",
scale: 100,
diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts
index 6de9240..43f14a1 100644
--- a/src/lib/insight/kpi-row.ts
+++ b/src/lib/insight/kpi-row.ts
@@ -14,6 +14,7 @@ import {
forEntity,
type NormalizedMetricResult,
} from "@/lib/metrics/collection";
+import { derivePeerStanding } from "@/lib/metrics/peer-standing";
import { computeDelta, type MetricDelta } from "@/lib/metrics/delta";
import type { FocusMode } from "@/lib/peers";
import { applyFocusStatus, statusVsMedian, type Status } from "@/lib/status";
@@ -137,24 +138,19 @@ export function metricKpiTiles(
const data = forEntity(metric, entityId);
const value = data.value;
- // Thin-cohort suppression is server-side: below the backend's disclosure
- // floor the peer view returns null percentiles, so `median` is null here.
const median = data.peer?.median ?? null;
- // A null peer target_value means no observations — the zero-filled own
- // total is not a measured value, so it takes no standing vs the median.
- const observed = data.peer?.target_value != null;
-
+ // Eligibility (observed / suppressed / flat pool / neutral direction)
+ // and the median judgment come from the shared standing derivation.
+ // Only a strictly favorable/unfavorable median side earns a color —
+ // at-median is "on par", not praise (an all-idle cohort must not paint
+ // idleness green).
+ const standing = derivePeerStanding(metric.direction, data);
const valueStatus = applyFocusStatus(
- value != null &&
- median != null &&
- observed &&
- metric.direction !== "neutral"
- ? statusVsMedian(
- value,
- median,
- metric.direction !== "lower_is_better",
- )
- : "neutral",
+ standing.medianSide === "favorable"
+ ? "good"
+ : standing.medianSide === "unfavorable"
+ ? "bad"
+ : "neutral",
focusMode,
);
diff --git a/src/lib/insight/team-metrics.test.ts b/src/lib/insight/team-metrics.test.ts
index 2f31796..53b4765 100644
--- a/src/lib/insight/team-metrics.test.ts
+++ b/src/lib/insight/team-metrics.test.ts
@@ -12,6 +12,7 @@ import {
} from "@/lib/insight/groups";
import {
buildMetricCollectionRequest,
+ entityChunkSize,
normalizeMetricResults,
projectViews,
type NormalizedMetricResult,
@@ -164,32 +165,40 @@ describe("memberMetricEntries", () => {
});
describe("team request row-limit projection", () => {
- it("stays under the backend all-or-nothing limit for a 200-person roster", () => {
+ it("chunks each metrics group so no request exceeds the backend row limit", () => {
// Backend caps projected rows at 5000 per request and rejects the WHOLE
// request beyond it. Projection: period/peer → one row per entity per
- // metric-view; timeseries → entities × buckets. The team surface must
- // therefore request period+peer only.
+ // metric-view. The team surface requests period+peer only and
+ // `useMetricCollectionSet` chunks the roster by `entityChunkSize`, so a
+ // group larger than the unchunked budget (e.g. collaboration's 19 metrics)
+ // is split across requests rather than rejected.
const ROW_LIMIT = 5000;
const roster = 200;
for (const def of metricGroups()) {
const projected = projectViews(def.collection, ["period", "peer"]);
+ // Roster surfaces carry no per-bucket / per-dimension views.
+ expect(
+ projected.metrics.flatMap((m) => m.views).some(
+ (v) =>
+ v.view === "timeseries" ||
+ v.view === "breakdown" ||
+ v.view === "histogram",
+ ),
+ ).toBe(false);
+ const chunkSize = entityChunkSize(projected) ?? roster;
+ const chunkIds = Array.from(
+ { length: Math.min(chunkSize, roster) },
+ (_, i) => `person${i}@x.com`,
+ );
const request = buildMetricCollectionRequest(
projected,
- {
- type: "person",
- ids: Array.from({ length: roster }, (_, i) => `person${i}@x.com`),
- },
+ { type: "person", ids: chunkIds },
{ from: "2026-06-01", to: "2026-06-30" },
);
const projectedRows = request.metrics.reduce(
- (sum, metric) => sum + metric.views.length * roster,
+ (sum, metric) => sum + metric.views.length * chunkIds.length,
0,
);
- expect(
- request.metrics.flatMap((m) => m.views).some(
- (v) => v.view === "timeseries" || v.view === "breakdown",
- ),
- ).toBe(false);
expect(projectedRows).toBeLessThanOrEqual(ROW_LIMIT);
}
});
diff --git a/src/lib/insight/team-metrics.ts b/src/lib/insight/team-metrics.ts
index cc9b0bd..5af6c07 100644
--- a/src/lib/insight/team-metrics.ts
+++ b/src/lib/insight/team-metrics.ts
@@ -5,9 +5,9 @@ import {
} from "@/lib/metrics/collection";
import {
buildPeerStoryEntries,
- toPeerStats,
type PeerStoryEntry,
} from "@/lib/metrics/peer-story";
+import { toPeerStats } from "@/lib/metrics/peer-standing";
import {
peerStatusVsQuartiles,
type PeerStatusWithNeutral,
diff --git a/src/lib/insight/v2/bullet-defs.ts b/src/lib/insight/v2/bullet-defs.ts
index c4f9dfe..0900ddc 100644
--- a/src/lib/insight/v2/bullet-defs.ts
+++ b/src/lib/insight/v2/bullet-defs.ts
@@ -42,16 +42,6 @@ export const BULLET_DESCRIPTION_BY_KEY: ReadonlyMap = new Map<
pr_cycle_time: "Hours from PR open to merge",
bugs_fixed: "Bug-type issues closed",
- slack_messages_sent: "Chat messages authored",
- slack_active_days: "Days with a chat message",
- slack_dm_ratio: "Direct messages as a share of all",
- m365_emails_sent: "Emails sent",
- m365_emails_received: "Inbox email volume",
- m365_teams_chats: "Direct and group chats sent",
- meeting_hours: "Hours in scheduled meetings",
- meetings_count: "Distinct meetings attended",
- meeting_free: "Working days with no meetings",
-
wiki_pages_created: "Wiki pages authored (Confluence/Outline)",
wiki_edits: "Wiki page revisions authored",
wiki_comments: "Comments received on the person's wiki pages",
diff --git a/src/lib/insight/v2/derivations.ts b/src/lib/insight/v2/derivations.ts
index fa46faf..689f64e 100644
--- a/src/lib/insight/v2/derivations.ts
+++ b/src/lib/insight/v2/derivations.ts
@@ -5,17 +5,6 @@ export interface CompositionRow {
value: number;
}
-export interface CollabActivityRow {
- category: string;
- label: string;
- description: string;
- value: number;
- unit: string;
- higher_is_better: boolean;
- /** Raw catalog source tags of the constituent metrics, e.g. ["m365","zoom"]. */
- sources: string[];
-}
-
const AI_TOOL_KEYS: ReadonlyArray = [
["cursor_lines", "Cursor"],
["cc_lines", "Claude Code"],
@@ -29,28 +18,6 @@ function rawValue(row: BulletMetric | undefined): number {
return Number.isFinite(n) ? n : 0;
}
-function sumKeys(rows: BulletMetric[], keys: ReadonlyArray): number {
- let total = 0;
- for (const r of rows) {
- if (keys.includes(r.metric_key)) total += rawValue(r);
- }
- return total;
-}
-
-/** Union of catalog source_tags across the rows matching `keys`. */
-function collectSources(
- rows: BulletMetric[],
- keys: ReadonlyArray,
-): string[] {
- const set = new Set();
- for (const r of rows) {
- if (keys.includes(r.metric_key)) {
- for (const t of r.source_tags ?? []) set.add(t);
- }
- }
- return [...set];
-}
-
export function deriveAiToolComposition(
aiRows: BulletMetric[],
): CompositionRow[] {
@@ -68,60 +35,3 @@ export function deriveAiToolComposition(
if (other > 0) named.push({ name: "Other", value: other });
return named;
}
-
-const COLLAB_META: Record<
- string,
- Pick
-> = {
- meetings: {
- label: "Meetings",
- description: "Hours attended in scheduled meetings.",
- higher_is_better: false,
- },
- messages: {
- label: "Messages",
- description: "Chat messages, emails, and direct messages sent.",
- higher_is_better: true,
- },
- files: {
- label: "Files engaged",
- description: "Documents created or edited.",
- higher_is_better: true,
- },
-};
-
-const MEETING_KEYS = ["meeting_hours"] as const;
-const MESSAGE_KEYS = [
- "slack_messages_sent",
- "m365_emails_sent",
- "m365_teams_chats",
-] as const;
-const FILE_KEYS = ["m365_files_engaged"] as const;
-
-export function deriveCollabActivities(
- collabRows: BulletMetric[],
-): CollabActivityRow[] {
- return [
- {
- category: "meetings",
- value: sumKeys(collabRows, MEETING_KEYS),
- unit: "h",
- sources: collectSources(collabRows, MEETING_KEYS),
- ...COLLAB_META.meetings,
- },
- {
- category: "messages",
- value: sumKeys(collabRows, MESSAGE_KEYS),
- unit: "count",
- sources: collectSources(collabRows, MESSAGE_KEYS),
- ...COLLAB_META.messages,
- },
- {
- category: "files",
- value: sumKeys(collabRows, FILE_KEYS),
- unit: "count",
- sources: collectSources(collabRows, FILE_KEYS),
- ...COLLAB_META.files,
- },
- ];
-}
diff --git a/src/lib/insight/v2/metric-order.ts b/src/lib/insight/v2/metric-order.ts
index 31fec69..0d74afc 100644
--- a/src/lib/insight/v2/metric-order.ts
+++ b/src/lib/insight/v2/metric-order.ts
@@ -18,12 +18,6 @@ export const METRIC_ORDER_BY_SECTION: Record = {
"pr_cycle_time",
"prs_per_dev",
],
- collaboration: [
- "meeting_free",
- "slack_dm_ratio",
- "m365_emails_received",
- "m365_emails_read",
- ],
wiki: [
"wiki_active_authors",
"wiki_pages_created",
diff --git a/src/lib/metrics/peer-standing.test.ts b/src/lib/metrics/peer-standing.test.ts
new file mode 100644
index 0000000..4e9272b
--- /dev/null
+++ b/src/lib/metrics/peer-standing.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+
+import { derivePeerStanding } from "@/lib/metrics/peer-standing";
+import type { PeerEntityStats } from "@/lib/metrics/collection";
+
+function peer(overrides: Partial = {}): PeerEntityStats {
+ return {
+ entity_id: "me@x.com",
+ target_value: 14,
+ p25: 5,
+ median: 10,
+ p75: 15,
+ min: 0,
+ max: 30,
+ n: 8,
+ ...overrides,
+ };
+}
+
+describe("derivePeerStanding", () => {
+ it("ranks an eligible value and takes the direction-adjusted median side", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 14,
+ peer: peer(),
+ });
+ expect(standing).toMatchObject({
+ eligible: true,
+ reason: "ok",
+ rank: "in_pack",
+ medianSide: "favorable",
+ observed: true,
+ });
+ expect(standing.gapDelta).toBe(4);
+ });
+
+ it("keeps the gap sign arithmetic when direction flips favorability", () => {
+ const lower = derivePeerStanding("lower_is_better", {
+ value: 14,
+ peer: peer(),
+ });
+ expect(lower.medianSide).toBe("unfavorable");
+ expect(lower.gapDelta).toBe(4);
+ expect(lower.gapPct).toBeCloseTo(0.4);
+
+ const below = derivePeerStanding("lower_is_better", {
+ value: 6,
+ peer: peer(),
+ });
+ expect(below.medianSide).toBe("favorable");
+ expect(below.gapDelta).toBe(-4);
+ });
+
+ it("returns no_value without a period value", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: null,
+ peer: peer(),
+ });
+ expect(standing).toMatchObject({ eligible: false, reason: "no_value", rank: "neutral" });
+ });
+
+ it("returns neutral_direction for neutral metrics", () => {
+ const standing = derivePeerStanding("neutral", { value: 3, peer: peer() });
+ expect(standing).toMatchObject({ eligible: false, reason: "neutral_direction" });
+ });
+
+ it("returns unmeasured when the peer target is null (zero-filled own total)", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 0,
+ peer: peer({ target_value: null }),
+ });
+ expect(standing).toMatchObject({
+ eligible: false,
+ reason: "unmeasured",
+ observed: false,
+ });
+ });
+
+ it("returns no_stats when percentiles are suppressed", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 14,
+ peer: peer({ p25: null, median: null, p75: null, min: null, max: null }),
+ });
+ expect(standing).toMatchObject({
+ eligible: false,
+ reason: "no_stats",
+ stats: null,
+ });
+ });
+
+ it("treats a metric without a peer view as unrankable but observed", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 14,
+ peer: null,
+ });
+ expect(standing).toMatchObject({
+ eligible: false,
+ reason: "no_stats",
+ observed: true,
+ });
+ });
+
+ it("returns flat_pool when the cohort has zero spread", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 0,
+ peer: peer({ target_value: 0, p25: 0, median: 0, p75: 0, min: 0, max: 0 }),
+ });
+ expect(standing).toMatchObject({
+ eligible: false,
+ reason: "flat_pool",
+ rank: "neutral",
+ medianSide: null,
+ });
+ });
+
+ it("marks a median tie as at, never an outlier", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 0,
+ peer: peer({ target_value: 0, p25: 0, median: 0, p75: 4, min: 0, max: 9 }),
+ });
+ expect(standing).toMatchObject({
+ eligible: true,
+ rank: "in_pack",
+ medianSide: "at",
+ });
+ });
+
+ it("normalizes severity by spread when the median is zero", () => {
+ const standing = derivePeerStanding("higher_is_better", {
+ value: 8,
+ peer: peer({ target_value: 8, p25: 0, median: 0, p75: 4, min: 0, max: 9 }),
+ });
+ expect(standing.rank).toBe("top");
+ expect(standing.gapPct).toBeNull();
+ expect(standing.severity).toBe(2); // gapDelta 8 / IQR 4
+ });
+});
diff --git a/src/lib/metrics/peer-standing.ts b/src/lib/metrics/peer-standing.ts
new file mode 100644
index 0000000..8bfa226
--- /dev/null
+++ b/src/lib/metrics/peer-standing.ts
@@ -0,0 +1,134 @@
+import type { MetricDirection } from "@/api/metric-results-client";
+import type { EntityMetricData, PeerEntityStats } from "@/lib/metrics/collection";
+import {
+ peerStatusVsQuartiles,
+ type PeerStats,
+ type PeerStatusWithNeutral,
+} from "@/lib/peers";
+
+/**
+ * The single judgment layer for a person's standing against their peer
+ * cohort on the unified metric-results path. Every eligibility guard and
+ * both judgments (quartile rank, median side) are derived HERE, once —
+ * display surfaces (KPI tiles, summary cards, group cards, the peer story)
+ * only map the result to their own vocabulary. Surfaces re-deriving
+ * standings from raw stats is how contradictory verdicts ("Bottom 25%" on
+ * one widget, "GOOD" on another, for the same number) crept in.
+ */
+
+/** Why a standing is (in)eligible, most specific reason wins. */
+export type StandingReason =
+ /** Rankable — `rank` and `medianSide` are meaningful. */
+ | "ok"
+ /** No period value for the entity. */
+ | "no_value"
+ /** The metric declares no better/worse direction. */
+ | "neutral_direction"
+ /** Zero-filled own total with a null peer target: no observations. */
+ | "unmeasured"
+ /** No usable cohort stats (no peer view, or suppressed percentiles). */
+ | "no_stats"
+ /** Cohort has zero spread (everyone identical) — ranks nobody. */
+ | "flat_pool";
+
+export interface PeerStanding {
+ /** False when the peer view marks the person unmeasured. */
+ observed: boolean;
+ stats: PeerStats | null;
+ eligible: boolean;
+ reason: StandingReason;
+ /** Quartile rank; "neutral" when ineligible. */
+ rank: PeerStatusWithNeutral;
+ /** Direction-adjusted side of the cohort median; null when ineligible. */
+ medianSide: "favorable" | "at" | "unfavorable" | null;
+ /**
+ * Signed arithmetic distance from the median (positive = above). Display
+ * renders this next to "from median" so the sign must track position, not
+ * favorability — good/bad is `medianSide`'s (and the color's) job.
+ */
+ gapDelta: number;
+ gapPct: number | null;
+ /** Outlier ordering weight; 0 when ineligible. */
+ severity: number;
+}
+
+export function toPeerStats(row: PeerEntityStats | null): PeerStats | null {
+ if (
+ row?.p25 == null ||
+ row.median == null ||
+ row.p75 == null ||
+ row.min == null ||
+ row.max == null
+ ) {
+ return null;
+ }
+ return {
+ p25: row.p25,
+ p50: row.median,
+ p75: row.p75,
+ min: row.min,
+ max: row.max,
+ n: row.n,
+ };
+}
+
+/** IQR, falling back to range then 1, for severity normalization. */
+function peerSpread(stats: PeerStats): number {
+ const iqr = Math.abs(stats.p75 - stats.p25);
+ if (iqr > 1e-9) return iqr;
+ const range = Math.abs(stats.max - stats.min);
+ if (range > 1e-9) return range;
+ return 1;
+}
+
+export function derivePeerStanding(
+ direction: MetricDirection,
+ data: Pick,
+): PeerStanding {
+ const value = data.value;
+ const stats = toPeerStats(data.peer);
+ const observed = data.peer == null || data.peer.target_value != null;
+ const higherIsBetter = direction !== "lower_is_better";
+
+ const gapDelta = stats != null && value != null ? value - stats.p50 : 0;
+ const favorableDelta = higherIsBetter ? gapDelta : -gapDelta;
+ const gapPct =
+ stats != null && Math.abs(stats.p50) > 1e-9
+ ? gapDelta / Math.abs(stats.p50)
+ : null;
+
+ const ineligible = (reason: Exclude): PeerStanding => ({
+ observed,
+ stats,
+ eligible: false,
+ reason,
+ rank: "neutral",
+ medianSide: null,
+ gapDelta,
+ gapPct,
+ severity: 0,
+ });
+
+ if (value == null || !Number.isFinite(value)) return ineligible("no_value");
+ if (direction === "neutral") return ineligible("neutral_direction");
+ if (!observed) return ineligible("unmeasured");
+ if (stats == null) return ineligible("no_stats");
+ if (Math.abs(stats.max - stats.min) <= 1e-9) return ineligible("flat_pool");
+
+ return {
+ observed,
+ stats,
+ eligible: true,
+ reason: "ok",
+ rank: peerStatusVsQuartiles(value, stats, higherIsBetter),
+ medianSide:
+ Math.abs(favorableDelta) <= 1e-9
+ ? "at"
+ : favorableDelta > 0
+ ? "favorable"
+ : "unfavorable",
+ gapDelta,
+ gapPct,
+ severity: Math.abs(gapPct ?? gapDelta / peerSpread(stats)),
+ };
+}
diff --git a/src/lib/metrics/peer-story.test.ts b/src/lib/metrics/peer-story.test.ts
index e4c7341..a7af134 100644
--- a/src/lib/metrics/peer-story.test.ts
+++ b/src/lib/metrics/peer-story.test.ts
@@ -81,7 +81,7 @@ describe("buildPeerStoryEntries", () => {
expect(entries[0]?.severity).toBe(0);
});
- it("inverts the gap for lower-is-better metrics", () => {
+ it("ranks lower-is-better below-median as top while the gap stays arithmetic", () => {
const byKey = normalizeMetricResults([
metric("m.win", 2, { direction: "lower_is_better" }),
]);
@@ -90,7 +90,7 @@ describe("buildPeerStoryEntries", () => {
};
const entries = buildPeerStoryEntries(collection, byKey, "me@x.com");
expect(entries[0]?.status).toBe("top");
- expect(entries[0]?.gapDelta).toBeGreaterThan(0);
+ expect(entries[0]?.gapDelta).toBe(-8);
});
});
@@ -127,8 +127,9 @@ describe("partitionPeerStory", () => {
});
// When the cohort median is 0 the percentage gap is undefined, so severity
-// falls back to gap / peerSpread. This exercises all three peerSpread
-// branches (IQR, min–max range, constant 1) and the ordering they imply.
+// falls back to gap / peerSpread. This exercises the IQR and min–max range
+// branches plus the flat-pool gate (a zero-spread cohort ranks nobody, so it
+// carries no severity at all).
describe("peerSpread fallback (median 0)", () => {
function metricWithStats(
key: string,
@@ -164,7 +165,7 @@ describe("peerSpread fallback (median 0)", () => {
const byKey = normalizeMetricResults([
metricWithStats("iqr", { p25: -5, p75: 5, min: -8, max: 8 }), // spread 10
metricWithStats("range", { p25: 0, p75: 0, min: -10, max: 10 }), // spread 20
- metricWithStats("constant", { p25: 0, p75: 0, min: 0, max: 0 }), // spread 1
+ metricWithStats("constant", { p25: 0, p75: 0, min: 0, max: 0 }), // flat
]);
const byKeyMap = new Map(
buildPeerStoryEntries(collection, byKey, "me@x.com").map((e) => [
@@ -172,9 +173,10 @@ describe("peerSpread fallback (median 0)", () => {
e.severity,
]),
);
- // gap is 5 for all; severity = 5 / spread.
+ // gap is 5 for both spread pools; severity = 5 / spread. A flat pool
+ // (zero spread) is ineligible for ranking — severity 0, never an outlier.
expect(byKeyMap.get("iqr")).toBeCloseTo(0.5);
expect(byKeyMap.get("range")).toBeCloseTo(0.25);
- expect(byKeyMap.get("constant")).toBeCloseTo(5);
+ expect(byKeyMap.get("constant")).toBe(0);
});
});
diff --git a/src/lib/metrics/peer-story.ts b/src/lib/metrics/peer-story.ts
index bc01486..6bdb57b 100644
--- a/src/lib/metrics/peer-story.ts
+++ b/src/lib/metrics/peer-story.ts
@@ -1,18 +1,15 @@
-import type {
- MetricDirection,
- MetricFormat,
-} from "@/api/metric-results-client";
+import type { MetricFormat } from "@/api/metric-results-client";
import {
forEntity,
+ type EntityMetricData,
type MetricCollectionConfig,
type NormalizedMetricResult,
- type PeerEntityStats,
} from "@/lib/metrics/collection";
-import {
- peerStatusVsQuartiles,
- type FocusMode,
- type PeerStats,
- type PeerStatusWithNeutral,
+import { derivePeerStanding } from "@/lib/metrics/peer-standing";
+import type {
+ FocusMode,
+ PeerStats,
+ PeerStatusWithNeutral,
} from "@/lib/peers";
/**
@@ -39,52 +36,12 @@ export type PeerStoryEntry = {
severity: number;
};
-export function toPeerStats(row: PeerEntityStats | null): PeerStats | null {
- if (
- row?.p25 == null ||
- row.median == null ||
- row.p75 == null ||
- row.min == null ||
- row.max == null
- ) {
- return null;
- }
- return {
- p25: row.p25,
- p50: row.median,
- p75: row.p75,
- min: row.min,
- max: row.max,
- n: row.n,
- };
-}
-
-function peerSpread(stats: PeerStats): number {
- const iqr = Math.abs(stats.p75 - stats.p25);
- if (iqr > 1e-9) return iqr;
- const range = Math.abs(stats.max - stats.min);
- if (range > 1e-9) return range;
- return 1;
-}
-
function toStoryEntry(
metric: NormalizedMetricResult,
value: number,
- stats: PeerStats | null,
- observed: boolean,
+ data: Pick,
): PeerStoryEntry {
- const direction: MetricDirection = metric.direction;
- const neutral = direction === "neutral";
- const higherIsBetter = direction !== "lower_is_better";
- const usePeerRanking =
- !neutral && observed && stats != null && Number.isFinite(value);
- const status = usePeerRanking
- ? peerStatusVsQuartiles(value, stats, higherIsBetter)
- : "neutral";
- const rawDelta = stats ? value - stats.p50 : 0;
- const gapDelta = higherIsBetter ? rawDelta : -rawDelta;
- const gapPct =
- stats && Math.abs(stats.p50) > 1e-9 ? gapDelta / Math.abs(stats.p50) : null;
+ const standing = derivePeerStanding(metric.direction, data);
return {
key: metric.metric_key,
label: metric.label,
@@ -92,27 +49,22 @@ function toStoryEntry(
value,
unit: metric.unit,
format: metric.format,
- higherIsBetter,
- neutral,
- observed,
- stats,
- status,
- gapPct,
- gapDelta,
- severity:
- usePeerRanking && stats
- ? Math.abs(gapPct ?? gapDelta / peerSpread(stats))
- : 0,
+ higherIsBetter: metric.direction !== "lower_is_better",
+ neutral: metric.direction === "neutral",
+ observed: standing.observed,
+ stats: standing.stats,
+ status: standing.rank,
+ gapPct: standing.gapPct,
+ gapDelta: standing.gapDelta,
+ severity: standing.severity,
};
}
/**
* Entries in collection order; metrics without a period value drop out.
- * Ranking additionally requires an OBSERVED value: the period view zero-fills
- * a person's own total, but `peer.target_value` is null when the person has
- * no observations (absence is indistinguishable from lack of source
- * coverage), and an unmeasured person has no peer standing — the entry stays
- * neutral instead of being branded a bottom-quartile outlier.
+ * All eligibility (observed / suppressed / flat pool / neutral direction)
+ * and both judgments come from `derivePeerStanding` — the story only lays
+ * the results out.
*/
export function buildPeerStoryEntries(
collection: MetricCollectionConfig,
@@ -124,10 +76,7 @@ export function buildPeerStoryEntries(
if (!metric) return [];
const data = forEntity(metric, entityId);
if (data.value == null || !Number.isFinite(data.value)) return [];
- const observed = data.peer == null || data.peer.target_value != null;
- return [
- toStoryEntry(metric, data.value, toPeerStats(data.peer), observed),
- ];
+ return [toStoryEntry(metric, data.value, data)];
});
}
diff --git a/src/lib/metrics/shares.test.ts b/src/lib/metrics/shares.test.ts
index f349070..a5ed523 100644
--- a/src/lib/metrics/shares.test.ts
+++ b/src/lib/metrics/shares.test.ts
@@ -1,32 +1,46 @@
import { describe, expect, it } from "vitest";
-import { integerPercentShares } from "@/lib/metrics/shares";
+import { percentShareLabels } from "@/lib/metrics/shares";
-const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
+const sum = (labels: string[]) =>
+ labels.reduce((total, label) => total + Number(label), 0);
-describe("integerPercentShares", () => {
+describe("percentShareLabels", () => {
it("sums to exactly 100 for equal thirds (no 99%)", () => {
- const shares = integerPercentShares([1, 1, 1]);
- expect(sum(shares)).toBe(100);
- expect(shares).toEqual([34, 33, 33]);
+ const labels = percentShareLabels([1, 1, 1]);
+ expect(sum(labels)).toBe(100);
+ expect(labels).toEqual(["34", "33", "33"]);
});
- it("sums to exactly 100 for a lopsided split (no 101%)", () => {
- const shares = integerPercentShares([995, 5]);
- expect(sum(shares)).toBe(100);
+ it("keeps exact splits unchanged", () => {
+ expect(percentShareLabels([80, 20])).toEqual(["80", "20"]);
+ expect(percentShareLabels([60, 40])).toEqual(["60", "40"]);
});
- it("keeps exact splits unchanged", () => {
- expect(integerPercentShares([80, 20])).toEqual([80, 20]);
- expect(integerPercentShares([60, 40])).toEqual([60, 40]);
+ it("gives a tiny nonzero part a tenth instead of a contradictory 0%", () => {
+ const labels = percentShareLabels([1576, 1]);
+ expect(labels).toEqual(["99.9", "0.1"]);
+ expect(sum(labels)).toBeCloseTo(100);
+ });
+
+ it("switches the whole legend to tenths on a sub-1% part", () => {
+ const labels = percentShareLabels([995, 5]);
+ expect(labels).toEqual(["99.5", "0.5"]);
+ expect(sum(labels)).toBeCloseTo(100);
+ });
+
+ it("floors ultra-small nonzero parts at 0.1 by taking from the largest", () => {
+ const labels = percentShareLabels([99999, 1]);
+ expect(labels).toEqual(["99.9", "0.1"]);
+ expect(sum(labels)).toBeCloseTo(100);
});
it("returns zeros for a non-positive total", () => {
- expect(integerPercentShares([0, 0])).toEqual([0, 0]);
- expect(integerPercentShares([])).toEqual([]);
+ expect(percentShareLabels([0, 0])).toEqual(["0", "0"]);
+ expect(percentShareLabels([])).toEqual([]);
});
it("gives a single part the whole 100%", () => {
- expect(integerPercentShares([42])).toEqual([100]);
+ expect(percentShareLabels([42])).toEqual(["100"]);
});
});
diff --git a/src/lib/metrics/shares.ts b/src/lib/metrics/shares.ts
index 9106d5b..bf8d8b3 100644
--- a/src/lib/metrics/shares.ts
+++ b/src/lib/metrics/shares.ts
@@ -1,19 +1,50 @@
/**
- * Integer percentage shares of a whole that sum to exactly 100 (largest-
- * remainder / Hamilton rounding), so a "parts of a whole" legend never shows
- * 99% or 101%. Parts are assumed non-negative; a non-positive total has no
- * honest share and yields all zeros. Order is preserved (result[i] is the
- * share of values[i]).
+ * Display labels for percentage shares of a whole (largest-remainder /
+ * Hamilton rounding), summing to exactly 100 so a "parts of a whole" legend
+ * never reads 99% or 101%. Shares are integers until a nonzero part would
+ * display "0%" — then the whole legend switches to tenth-of-a-percent
+ * precision (still largest-remainder, still summing to exactly 100) with a
+ * 0.1 floor for nonzero parts, so one message beside 1,576 reads "0.1%",
+ * never a contradictory "0%". Parts are assumed non-negative; a non-positive
+ * total has no honest share and yields all "0". Order is preserved
+ * (result[i] labels values[i]); labels carry no "%" suffix.
*/
-export function integerPercentShares(values: number[]): number[] {
+export function percentShareLabels(values: number[]): string[] {
const total = values.reduce((sum, value) => sum + value, 0);
- if (!(total > 0)) return values.map(() => 0);
+ if (!(total > 0)) return values.map(() => "0");
- const exact = values.map((value) => (value / total) * 100);
+ const integers = largestRemainder(values, total, 100);
+ const needsTenths = values.some(
+ (value, index) => value > 0 && integers[index] === 0,
+ );
+ if (!needsTenths) return integers.map(String);
+
+ const tenths = largestRemainder(values, total, 1000);
+ // Floor nonzero parts at one tenth, taking it from the largest share so
+ // the sum stays exactly 100.0.
+ for (let index = 0; index < values.length; index += 1) {
+ if (!((values[index] ?? 0) > 0) || tenths[index] !== 0) continue;
+ const largest = tenths.indexOf(Math.max(...tenths));
+ if (largest === -1 || (tenths[largest] ?? 0) <= 1) break;
+ tenths[largest] = (tenths[largest] ?? 0) - 1;
+ tenths[index] = 1;
+ }
+ return tenths.map((share) =>
+ share % 10 === 0 ? String(share / 10) : (share / 10).toFixed(1),
+ );
+}
+
+/** Largest-remainder apportionment of `units` across `values`. */
+function largestRemainder(
+ values: number[],
+ total: number,
+ units: number,
+): number[] {
+ const exact = values.map((value) => (value / total) * units);
const shares = exact.map((value) => Math.floor(value));
- let remainder = 100 - shares.reduce((sum, value) => sum + value, 0);
+ let remainder = units - shares.reduce((sum, value) => sum + value, 0);
- // Hand the leftover points to the largest fractional parts first.
+ // Hand the leftover units to the largest fractional parts first.
const byFraction = exact
.map((value, index) => ({ index, frac: value - Math.floor(value) }))
.sort((a, b) => b.frac - a.frac);
diff --git a/src/lib/peers.test.ts b/src/lib/peers.test.ts
new file mode 100644
index 0000000..78aedc2
--- /dev/null
+++ b/src/lib/peers.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { peerStatusVsQuartiles } from "@/lib/peers";
+
+describe("peerStatusVsQuartiles", () => {
+ it("keeps inclusive quartile boundaries when they sit beyond the median", () => {
+ const stats = { p25: 2, p50: 5, p75: 8 };
+ expect(peerStatusVsQuartiles(8, stats, true)).toBe("top");
+ expect(peerStatusVsQuartiles(2, stats, true)).toBe("bottom");
+ expect(peerStatusVsQuartiles(5, stats, true)).toBe("in_pack");
+ expect(peerStatusVsQuartiles(2, stats, false)).toBe("top");
+ expect(peerStatusVsQuartiles(8, stats, false)).toBe("bottom");
+ });
+
+ it("ranks nobody in a flat pool (everyone at the same value)", () => {
+ const flat = { p25: 0, p50: 0, p75: 0 };
+ expect(peerStatusVsQuartiles(0, flat, true)).toBe("in_pack");
+ expect(peerStatusVsQuartiles(0, flat, false)).toBe("in_pack");
+ });
+
+ it("still ranks values strictly beyond a collapsed pool", () => {
+ const collapsed = { p25: 0, p50: 0, p75: 0 };
+ expect(peerStatusVsQuartiles(5, collapsed, true)).toBe("top");
+ expect(peerStatusVsQuartiles(-1, collapsed, true)).toBe("bottom");
+ expect(peerStatusVsQuartiles(5, collapsed, false)).toBe("bottom");
+ });
+
+ it("never brands a median tie as an outlier in a zero-inflated pool", () => {
+ // Many peers at 0, a few above: p25 == median == 0 but the pool has
+ // spread. A person at 0 is at the median — in the pack, not "Bottom 25%".
+ const zeroInflated = { p25: 0, p50: 0, p75: 4 };
+ expect(peerStatusVsQuartiles(0, zeroInflated, true)).toBe("in_pack");
+ expect(peerStatusVsQuartiles(5, zeroInflated, true)).toBe("top");
+ expect(peerStatusVsQuartiles(0, zeroInflated, false)).toBe("in_pack");
+ expect(peerStatusVsQuartiles(5, zeroInflated, false)).toBe("bottom");
+ });
+
+ it("requires the median side for the top rank symmetrically", () => {
+ // Right-heavy ties: p50 == p75. Sitting on them is the pack's middle.
+ const tiedHigh = { p25: 1, p50: 6, p75: 6 };
+ expect(peerStatusVsQuartiles(6, tiedHigh, true)).toBe("in_pack");
+ expect(peerStatusVsQuartiles(7, tiedHigh, true)).toBe("top");
+ expect(peerStatusVsQuartiles(1, tiedHigh, true)).toBe("bottom");
+ });
+});
diff --git a/src/lib/peers.ts b/src/lib/peers.ts
index 971fb50..74f92f8 100644
--- a/src/lib/peers.ts
+++ b/src/lib/peers.ts
@@ -57,12 +57,6 @@ export const PEER_TEXT: Record = {
neutral: "text-muted-foreground",
}
-export const PEER_BORDER: Record = {
- top: "border-success",
- bottom: "border-destructive",
- in_pack: "border-border",
- neutral: "border-muted",
-}
export const PEER_LABEL: Record = {
top: "Top 25%",
@@ -104,38 +98,23 @@ export function peerStatsFor(values: number[]): PeerStats | null {
export function peerStatusVsQuartiles(
value: number,
- stats: Pick,
+ stats: Pick,
higherIsBetter: boolean
): PeerStatus {
+ // An outlier must sit strictly on the outlier side of the MEDIAN as well
+ // as beyond the quartile. Quartile boundaries alone overclaim on tie-heavy
+ // pools: in an all-zero cohort everyone satisfies `value >= p75`, and in a
+ // zero-inflated one (p25 == median == 0, a few sharers above) everyone at
+ // zero satisfies `value <= p25` — branding half the pack "Top/Bottom 25%".
+ // Requiring the median side also keeps quartile ranks consistent with the
+ // median-based scorers (a "bottom" here is always below-median there).
if (higherIsBetter) {
- if (value >= stats.p75) return "top"
- if (value <= stats.p25) return "bottom"
+ if (value >= stats.p75 && value > stats.p50) return "top"
+ if (value <= stats.p25 && value < stats.p50) return "bottom"
return "in_pack"
}
- if (value <= stats.p25) return "top"
- if (value >= stats.p75) return "bottom"
+ if (value <= stats.p25 && value < stats.p50) return "top"
+ if (value >= stats.p75 && value > stats.p50) return "bottom"
return "in_pack"
}
-export function peerStatusVsMedian(
- value: number,
- median: number,
- higherIsBetter: boolean,
- toleranceFrac = 0.1
-): PeerStatus {
- const tolerance = Math.abs(median) * toleranceFrac
- const diff = value - median
- if (Math.abs(diff) <= tolerance) return "in_pack"
- const aboveMedian = diff > 0
- if (aboveMedian === higherIsBetter) return "top"
- return "bottom"
-}
-
-export function relativeGap(
- value: number,
- median: number,
- higherIsBetter: boolean
-): number {
- const denom = Math.abs(median) > 1e-9 ? Math.abs(median) : 1
- return higherIsBetter ? (median - value) / denom : (value - median) / denom
-}
diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts
index 1e6cdb8..31db01f 100644
--- a/src/lib/scoring.ts
+++ b/src/lib/scoring.ts
@@ -51,9 +51,3 @@ export function pickSectionHeadline(
return best
}
-export const SECTION_STRIPE: Record = {
- good: "border-l-success",
- warn: "border-l-warning",
- bad: "border-l-destructive",
- neutral: "border-l-border",
-}
diff --git a/src/lib/series-colors.test.ts b/src/lib/series-colors.test.ts
new file mode 100644
index 0000000..8bcd0f7
--- /dev/null
+++ b/src/lib/series-colors.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+
+import { seriesColors } from "@/lib/series-colors";
+
+describe("seriesColors", () => {
+ it("is deterministic regardless of duplicate seed inputs", () => {
+ expect(seriesColors(["cursor", "claude_code", "cursor"])).toEqual(
+ seriesColors(["claude_code", "cursor"]),
+ );
+ });
+
+ it("returns one color per unique seed", () => {
+ expect(
+ Object.keys(seriesColors(["cursor", "cursor", "codex"])).sort(),
+ ).toEqual(["codex", "cursor"]);
+ });
+
+ it("gives known tools their brand token", () => {
+ const palette = seriesColors(["slack", "m365", "zoom", "zulip_proxy"]);
+ expect(palette.slack).toBe("var(--brand-slack)");
+ expect(palette.m365).toBe("var(--brand-m365)");
+ expect(palette.zoom).toBe("var(--brand-zoom)");
+ expect(palette.zulip_proxy).toBe("var(--brand-zulip)");
+ });
+
+ it("assigns chart tokens to unknown seeds in sorted order", () => {
+ const palette = seriesColors(["beta", "alpha"]);
+ expect(palette.alpha).toBe("var(--chart-1)");
+ expect(palette.beta).toBe("var(--chart-2)");
+ });
+
+ it("does not skip chart slots for brand-mapped seeds", () => {
+ const palette = seriesColors(["slack", "unknown_tool"]);
+ expect(palette.slack).toBe("var(--brand-slack)");
+ expect(palette.unknown_tool).toBe("var(--chart-1)");
+ });
+
+ it("cycles chart tokens past five unknown seeds", () => {
+ const seeds = ["a", "b", "c", "d", "e", "f"];
+ const palette = seriesColors(seeds);
+ expect(palette.a).toBe("var(--chart-1)");
+ expect(palette.f).toBe("var(--chart-1)");
+ expect(palette.e).toBe("var(--chart-5)");
+ });
+
+ it("treats prototype-member seeds as unknown, never as brand colors", () => {
+ const palette = seriesColors(["__proto__", "constructor"]);
+ expect(palette["__proto__"]).toBe("var(--chart-1)");
+ expect(palette["constructor"]).toBe("var(--chart-2)");
+ });
+});
diff --git a/src/lib/series-colors.ts b/src/lib/series-colors.ts
new file mode 100644
index 0000000..583df52
--- /dev/null
+++ b/src/lib/series-colors.ts
@@ -0,0 +1,89 @@
+/**
+ * Series colors for dimension-split charts.
+ *
+ * Known tools and sources get a stable identity color — a vendor-brand hue
+ * tuned per ground via the `--brand-*` theme tokens — so the same tool reads
+ * the same on every chart, drilldown, and theme. The map covers every
+ * connector in the platform's documented inventory; families that are not
+ * built yet use their anticipated dimension value (snake_case vendor name)
+ * and simply fall back to chart tokens until the value matches. Unknown
+ * seeds fall back to the theme's categorical chart tokens (`--chart-1..5`),
+ * assigned in sorted seed order so a given seed set always maps the same
+ * way; past five they cycle.
+ *
+ * A `Map` (not a `Record`) so a malformed seed like `"__proto__"` or
+ * `"constructor"` falls through to the chart-token path instead of leaking
+ * `Object.prototype` members as colors.
+ */
+const BRAND_BY_SEED: ReadonlyMap = new Map([
+ // collaboration
+ ["slack", "var(--brand-slack)"],
+ ["m365", "var(--brand-m365)"],
+ ["zoom", "var(--brand-zoom)"],
+ ["zulip", "var(--brand-zulip)"],
+ ["zulip_proxy", "var(--brand-zulip)"],
+ // git
+ ["github", "var(--brand-github)"],
+ ["gitlab", "var(--brand-gitlab)"],
+ ["bitbucket_cloud", "var(--brand-bitbucket)"],
+ ["bitbucket_server", "var(--brand-bitbucket-server)"],
+ // ai
+ ["cursor", "var(--brand-cursor)"],
+ ["claude", "var(--brand-claude)"],
+ ["claude_code", "var(--brand-claude-code)"],
+ ["chatgpt", "var(--brand-chatgpt)"],
+ ["codex", "var(--brand-codex)"],
+ ["openai", "var(--brand-openai)"],
+ ["copilot", "var(--brand-copilot)"],
+ ["jetbrains", "var(--brand-jetbrains)"],
+ ["windsurf", "var(--brand-windsurf)"],
+ // task tracking
+ ["jira", "var(--brand-jira)"],
+ ["youtrack", "var(--brand-youtrack)"],
+ // crm
+ ["hubspot", "var(--brand-hubspot)"],
+ ["salesforce", "var(--brand-salesforce)"],
+ // support
+ ["jsm", "var(--brand-jsm)"],
+ ["zendesk", "var(--brand-zendesk)"],
+ // wiki
+ ["confluence", "var(--brand-confluence)"],
+ ["outline", "var(--brand-outline)"],
+ // testing
+ ["allure", "var(--brand-allure)"],
+ // ui design
+ ["figma", "var(--brand-figma)"],
+ // hr directory
+ ["bamboohr", "var(--brand-bamboohr)"],
+ ["workday", "var(--brand-workday)"],
+ ["ms_entra", "var(--brand-ms-entra)"],
+ ["ldap", "var(--brand-ldap)"],
+]);
+
+const CHART_TOKENS = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+] as const;
+
+export function seriesColors(seeds: string[]): Record {
+ // Null prototype so a seed like "__proto__" becomes an own property
+ // instead of silently mutating (or dropping) the object's prototype.
+ const palette: Record = Object.create(null) as Record<
+ string,
+ string
+ >;
+ const unknown: string[] = [];
+ for (const seed of new Set(seeds)) {
+ const brand = BRAND_BY_SEED.get(seed);
+ if (brand) palette[seed] = brand;
+ else unknown.push(seed);
+ }
+ unknown.sort();
+ unknown.forEach((seed, index) => {
+ palette[seed] = CHART_TOKENS[index % CHART_TOKENS.length]!;
+ });
+ return palette;
+}
diff --git a/src/lib/status.ts b/src/lib/status.ts
index 9928f71..6b07bb2 100644
--- a/src/lib/status.ts
+++ b/src/lib/status.ts
@@ -52,6 +52,29 @@ export const STATUS_SURFACE_CLASS: Record = {
neutral: "bg-muted text-muted-foreground",
}
+/**
+ * The status stripe along a card edge — the ONE stripe token every card
+ * uses, so stripe width never drifts between surfaces. An inset shadow, not
+ * a border: it follows the card's corner radius and cannot stack against
+ * the Card ring into a thicker edge. The edge is the per-surface choice
+ * (top for the peer-story hero, left for everything else); the width is
+ * not. Neutral carries no stripe — a stripe that says nothing is chrome.
+ * Full literal class strings so Tailwind's scanner sees them.
+ */
+export const STATUS_STRIPE_LEFT: Record = {
+ good: "shadow-[inset_3px_0_0_0_var(--success)]",
+ warn: "shadow-[inset_3px_0_0_0_var(--warning)]",
+ bad: "shadow-[inset_3px_0_0_0_var(--destructive)]",
+ neutral: undefined,
+}
+
+export const STATUS_STRIPE_TOP: Record = {
+ good: "shadow-[inset_0_3px_0_0_var(--success)]",
+ warn: "shadow-[inset_0_3px_0_0_var(--warning)]",
+ bad: "shadow-[inset_0_3px_0_0_var(--destructive)]",
+ neutral: undefined,
+}
+
/**
* Value-form status colors for contexts where a Tailwind class can't be used —
* e.g. SVG `fill` on recharts cells. Mirrors the class maps above so the two
diff --git a/src/lib/swatch-palette.test.ts b/src/lib/swatch-palette.test.ts
deleted file mode 100644
index a10199a..0000000
--- a/src/lib/swatch-palette.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { swatchPalette } from "@/lib/swatch-palette";
-
-describe("swatchPalette", () => {
- it("is deterministic regardless of duplicate seed inputs", () => {
- expect(swatchPalette(["cursor", "claude_code", "cursor"])).toEqual(
- swatchPalette(["claude_code", "cursor"]),
- );
- });
-
- it("returns one color per unique seed", () => {
- expect(Object.keys(swatchPalette(["cursor", "cursor", "codex"])).sort()).toEqual(
- ["codex", "cursor"],
- );
- });
-
- it("uses the swatch oklch token shape", () => {
- const palette = swatchPalette(["cursor", "claude_code"]);
-
- expect(palette.cursor).toMatch(/^oklch\(var\(--swatch-l\) 0\.14 \d+\)$/);
- expect(palette.claude_code).toMatch(/^oklch\(var\(--swatch-l\) 0\.14 \d+\)$/);
- });
-});
diff --git a/src/lib/swatch-palette.ts b/src/lib/swatch-palette.ts
deleted file mode 100644
index 2f94d3b..0000000
--- a/src/lib/swatch-palette.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-function hashString(input: string): number {
- let h = 2166136261;
- for (let i = 0; i < input.length; i++) {
- h ^= input.charCodeAt(i);
- h = Math.imul(h, 16777619);
- }
- return h >>> 0;
-}
-
-export function swatchPalette(seeds: string[]): Record {
- const palette: Record = {};
- for (const seed of new Set(seeds)) {
- const hue = hashString(seed) % 360;
- palette[seed] = `oklch(var(--swatch-l) 0.14 ${hue})`;
- }
- return palette;
-}
diff --git a/src/mocks/metric-results-fixtures.ts b/src/mocks/metric-results-fixtures.ts
index 9c417c3..bea9144 100644
--- a/src/mocks/metric-results-fixtures.ts
+++ b/src/mocks/metric-results-fixtures.ts
@@ -211,10 +211,113 @@ export const METRIC_RESULTS_RESPONSE_FIXTURE: MetricResultsResponse = {
metrics: [SUM_METRIC_FIXTURE, RATIO_METRIC_FIXTURE, MEDIAN_METRIC_FIXTURE],
};
+/**
+ * Collaboration metric metadata for the MSW factory. Only meta is consumed
+ * (the factory strips `views` and synthesizes them per request), so `views`
+ * here is a minimal placeholder. Covers the collab metrics whose
+ * format/direction/computation differ from the factory's sum/integer/
+ * higher-is-better default; the rest synthesize correctly without a fixture.
+ * Kept out of `METRIC_RESULTS_RESPONSE_FIXTURE` so that canonical response
+ * fixture stays stable.
+ */
+export const COLLAB_METRIC_FIXTURES: MetricResult[] = [
+ {
+ metric_key: "collab.messages_sent",
+ label: "Messages Sent",
+ unit: "messages",
+ format: "integer",
+ direction: "higher_is_better",
+ computation: "sum",
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 320 }] },
+ ],
+ },
+ {
+ metric_key: "collab.meeting_hours",
+ label: "Meeting Hours",
+ unit: "h",
+ format: "decimal",
+ direction: "lower_is_better",
+ computation: "sum",
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 18.5 }] },
+ ],
+ },
+ {
+ metric_key: "collab.dm_ratio",
+ label: "DM Ratio",
+ unit: "%",
+ format: "percent",
+ direction: "lower_is_better",
+ computation: "ratio",
+ scale: 100,
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 28.4 }] },
+ ],
+ },
+ {
+ metric_key: "collab.msgs_per_active_day",
+ label: "Messages per Active Day",
+ unit: "messages/day",
+ format: "decimal",
+ direction: "higher_is_better",
+ computation: "ratio",
+ scale: 1,
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 16 }] },
+ ],
+ },
+ {
+ metric_key: "collab.focus_time_pct",
+ label: "Focus Time",
+ unit: "%",
+ format: "percent",
+ direction: "higher_is_better",
+ computation: "ratio",
+ scale: 100,
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 62.5 }] },
+ ],
+ },
+ {
+ metric_key: "collab.active_days",
+ label: "Active Days",
+ unit: "days",
+ format: "integer",
+ direction: "higher_is_better",
+ computation: "distinct_count",
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 17 }] },
+ ],
+ },
+ {
+ metric_key: "collab.files_shared",
+ label: "Files Shared",
+ unit: "files",
+ format: "integer",
+ direction: "higher_is_better",
+ computation: "sum",
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 22 }] },
+ ],
+ },
+ {
+ metric_key: "collab.breadth",
+ label: "Collaboration Breadth",
+ unit: "modalities",
+ format: "integer",
+ direction: "neutral",
+ computation: "distinct_count",
+ views: [
+ { view: "period", values: [{ entity_id: "alice@example.com", value: 3 }] },
+ ],
+ },
+];
+
export function metricResultFixtureByKey(key: string): MetricResult | null {
return (
- METRIC_RESULTS_RESPONSE_FIXTURE.metrics.find(
- (m) => m.metric_key === key,
- ) ?? null
+ METRIC_RESULTS_RESPONSE_FIXTURE.metrics.find((m) => m.metric_key === key) ??
+ COLLAB_METRIC_FIXTURES.find((m) => m.metric_key === key) ??
+ null
);
}
diff --git a/src/queries/v2/ic-extras.ts b/src/queries/v2/ic-extras.ts
index 0aa22fc..46c5b53 100644
--- a/src/queries/v2/ic-extras.ts
+++ b/src/queries/v2/ic-extras.ts
@@ -58,10 +58,7 @@ export function useIcHistogram(
return useQuery(icHistogramQueryOptions(personId, metricKey, range));
}
-export type {
- CompositionRow,
- CollabActivityRow,
-} from "@/lib/insight/v2/derivations";
+export type { CompositionRow } from "@/lib/insight/v2/derivations";
export interface SectionTrendPointRow {
date: string;
@@ -75,20 +72,6 @@ interface SectionTrendLongRow {
value: number;
}
-/** Long peer-counter row: per-person value + per-org_unit cohort bands. */
-export interface PeerCounterRow {
- person_id: string;
- org_unit_id: string | null;
- metric_key: string;
- value: number | null;
- median: number | null;
- p25: number | null;
- p75: number | null;
- n: number | null;
- range_min: number | null;
- range_max: number | null;
-}
-
function pivotLongToWide(
rows: ReadonlyArray,
): SectionTrendPointRow[] {
@@ -144,43 +127,6 @@ export function useIcSectionTrend(
});
}
-/**
- * Per-person collaboration Messaging peer counters (#1527): messages_sent /
- * channel_posts with per-`org_unit_id` bands. Long peer-counter rows over the shared `PeerCounterRow` shape.
- */
-export function icCollabPeerCountersQueryOptions(
- personId: string,
- range: DateRange,
-) {
- const canonicalId = canonicalPersonId(personId);
- return {
- queryKey: [
- "v2",
- "ic-collab-peer-counters",
- canonicalId,
- range.from,
- range.to,
- ],
- enabled: Boolean(canonicalId && range.from && range.to),
- placeholderData: keepPreviousData,
- queryFn: async () => {
- const resp = await queryMetric(
- METRIC_REGISTRY.V2_IC_COLLAB_PEER_COUNTERS,
- range,
- { $filter: personFilter(canonicalId) },
- );
- return resp.items;
- },
- };
-}
-
-export function useIcCollabPeerCounters(
- personId: string,
- range: DateRange,
-): UseQueryResult {
- return useQuery(icCollabPeerCountersQueryOptions(personId, range));
-}
-
export interface DrilldownBatchData {
histograms: Map;
delivery: DeliveryDataPoint[] | null;
diff --git a/src/queries/v2/team-extras.ts b/src/queries/v2/team-extras.ts
index 271f829..01db6c4 100644
--- a/src/queries/v2/team-extras.ts
+++ b/src/queries/v2/team-extras.ts
@@ -18,18 +18,26 @@ import { useCatalog } from "@/api/use-catalog";
import type { DeptCohorts, DeptStatsMap, PeerStats } from "@/lib/peers";
import type { BulletMetric, PeriodValue } from "@/types/insight";
-// Member-value queries exist only for groups still on the legacy path;
-// deriving from the registry drops a group's fetch automatically when it
-// flips to `kind: "metrics"`.
+// Member-value queries feed the legacy-fed team heatmap + needs-attention.
+// Legacy groups derive from the registry (a group's fetch drops when it flips
+// to `kind: "metrics"`). Collaboration is the exception below: it flipped to a
+// unified IC group, but the heatmap's `meeting_hours` bullet column has no
+// unified replacement yet, so its per-member feed is retained explicitly
+// until that surface migrates.
const MEMBER_VALUE_METRIC_IDS: Partial> = {
task_delivery: METRIC_REGISTRY.V2_MEMBER_VALUES_DELIVERY,
- collaboration: METRIC_REGISTRY.V2_MEMBER_VALUES_COLLAB,
};
-const SECTION_METRIC_IDS = legacyGroups().flatMap((def) => {
- const metricId = MEMBER_VALUE_METRIC_IDS[def.id];
- return metricId ? [{ sectionId: def.id, metricId }] : [];
-});
+const SECTION_METRIC_IDS = [
+ ...legacyGroups().flatMap((def) => {
+ const metricId = MEMBER_VALUE_METRIC_IDS[def.id];
+ return metricId ? [{ sectionId: def.id, metricId }] : [];
+ }),
+ {
+ sectionId: "collaboration",
+ metricId: METRIC_REGISTRY.V2_MEMBER_VALUES_COLLAB,
+ },
+];
/** Per-person long row from a `V2_MEMBER_VALUES_*` metric. */
type RawMemberValueRow = {
@@ -143,16 +151,19 @@ export function useTeamMemberBullets(
// Dept-distribution queries mirror the legacy member-value groups; a
// group that flips to `kind: "metrics"` gets cohorts from the unified peer
-// view instead.
+// view instead. Collaboration is the exception (see SECTION_METRIC_IDS): its
+// dept distribution still colors the heatmap `meeting_hours` bullet column.
const DEPT_DIST_BULLET_BY_SECTION: Partial> = {
task_delivery: METRIC_REGISTRY.V2_DEPT_DIST_DELIVERY,
- collaboration: METRIC_REGISTRY.V2_DEPT_DIST_COLLAB,
};
-const DEPT_DIST_BULLET_IDS = legacyGroups().flatMap((def) => {
- const metricId = DEPT_DIST_BULLET_BY_SECTION[def.id];
- return metricId ? [metricId] : [];
-});
+const DEPT_DIST_BULLET_IDS = [
+ ...legacyGroups().flatMap((def) => {
+ const metricId = DEPT_DIST_BULLET_BY_SECTION[def.id];
+ return metricId ? [metricId] : [];
+ }),
+ METRIC_REGISTRY.V2_DEPT_DIST_COLLAB,
+];
const DEPT_DIST_METRIC_IDS = [
...DEPT_DIST_BULLET_IDS,
diff --git a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx
index d457d51..8822864 100644
--- a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx
+++ b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx
@@ -71,7 +71,6 @@ const LEGACY_GROUP_ROWS: Record<
(data: IcDashboardData | undefined) => BulletMetric[]
> = {
task_delivery: (data) => data?.taskDelivery ?? [],
- collaboration: (data) => data?.collaboration ?? [],
wiki: (data) => data?.wiki ?? [],
};