From 5c8153c7bfe7bc9e05c7ba6c374b1afc75ae79c9 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Fri, 10 Jul 2026 17:57:12 +0200 Subject: [PATCH 1/3] fix(charts): brand series colors, decimal edge shares Hash-to-hue series coloring collided (all collaboration tools landed in one blue band) on top of greyscale chart tokens. Known tools now map to vendor-brand tokens tuned per ground, unknown seeds fall back to shadcn categorical chart tokens in sorted order; composition legends switch to tenth precision when integer shares would show a nonzero part as 0%. Signed-off-by: Aleksandr Barkhatov --- .../widgets/metric-views/metric-breakdown.tsx | 15 ++-- .../widgets/metric-views/metric-trend.tsx | 15 ++-- src/index.css | 86 +++++++++++++++--- src/lib/metrics/shares.test.ts | 44 +++++---- src/lib/metrics/shares.ts | 51 ++++++++--- src/lib/series-colors.test.ts | 51 +++++++++++ src/lib/series-colors.ts | 89 +++++++++++++++++++ src/lib/swatch-palette.test.ts | 24 ----- src/lib/swatch-palette.ts | 17 ---- 9 files changed, 298 insertions(+), 94 deletions(-) create mode 100644 src/lib/series-colors.test.ts create mode 100644 src/lib/series-colors.ts delete mode 100644 src/lib/swatch-palette.test.ts delete mode 100644 src/lib/swatch-palette.ts 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-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/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/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/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/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; -} From 4841dbc5c13993dcd05e77e4180ce15c7c101f80 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 11:54:01 +0200 Subject: [PATCH 2/3] refactor(ui): derive peer standing in one layer Surfaces re-deriving standings from raw stats produced contradictory verdicts: quartile chips flattering all-zero pools, good-coloring next to Bottom 25%, favorability-signed gaps reading as position on lower-is-better metrics. derivePeerStanding owns eligibility and both judgments; displays only map the result. Median-strict quartile rule, arithmetic gap sign, shared inset-shadow status stripe, KPI tile explanation row. Signed-off-by: Aleksandr Barkhatov --- .../metric-views/metric-group-card.tsx | 29 ++-- .../widgets/metric-views/peer-story.tsx | 111 ++++++++++---- .../metric-views/team-metric-group-card.tsx | 14 +- .../v2/members-heatmap/triage-list.tsx | 7 +- .../widgets/v2/peer-story-section.tsx | 3 +- src/components/widgets/v2/section-card.tsx | 23 +-- src/lib/metrics/peer-standing.test.ts | 136 ++++++++++++++++++ src/lib/metrics/peer-standing.ts | 134 +++++++++++++++++ src/lib/metrics/peer-story.test.ts | 16 ++- src/lib/metrics/peer-story.ts | 93 +++--------- src/lib/peers.test.ts | 45 ++++++ src/lib/peers.ts | 45 ++---- src/lib/scoring.ts | 6 - src/lib/status.ts | 23 +++ 14 files changed, 493 insertions(+), 192 deletions(-) create mode 100644 src/lib/metrics/peer-standing.test.ts create mode 100644 src/lib/metrics/peer-standing.ts create mode 100644 src/lib/peers.test.ts 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/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/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/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/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/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 From 0016050d78d42d078df57e4f9ad952c348b9051f Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 11:54:08 +0200 Subject: [PATCH 3/3] feat(collab): cut collaboration over to metric results Mirrors the git/AI cutovers: one MetricGroup with 19 collab.* keys, modality summary cards with dimension breakdowns replace the trend charts, legacy messaging panel and summary strip die. Heatmap bullet columns keep the legacy collab feed until team surfaces migrate. Signed-off-by: Aleksandr Barkhatov --- src/api/metric-registry.ts | 7 +- src/api/metric-results-client.ts | 13 +- .../collection-drilldown.test.tsx | 39 ++++ .../metric-views/collection-drilldown.tsx | 35 ++- .../metric-views/metric-summary-card.test.tsx | 135 ++++++++++++ .../metric-views/metric-summary-card.tsx | 173 +++++++++++++++ .../v2/collab-messaging-panel.stories.tsx | 140 ------------ .../v2/collab-messaging-panel.test.tsx | 153 ------------- .../widgets/v2/collab-messaging-panel.tsx | 114 ---------- .../widgets/v2/group-drilldown-sheet.tsx | 30 +-- .../widgets/v2/summary-with-breakdown.tsx | 201 ------------------ src/lib/insight/attention.ts | 2 +- src/lib/insight/groups.ts | 110 +++++++++- src/lib/insight/kpi-row.test.ts | 25 +-- src/lib/insight/kpi-row.ts | 28 ++- src/lib/insight/team-metrics.test.ts | 35 +-- src/lib/insight/team-metrics.ts | 2 +- src/lib/insight/v2/bullet-defs.ts | 10 - src/lib/insight/v2/derivations.ts | 90 -------- src/lib/insight/v2/metric-order.ts | 6 - src/mocks/metric-results-fixtures.ts | 109 +++++++++- src/queries/v2/ic-extras.ts | 56 +---- src/queries/v2/team-extras.ts | 39 ++-- .../ic-dashboard/engineering-dashboard-v2.tsx | 1 - 24 files changed, 671 insertions(+), 882 deletions(-) create mode 100644 src/components/widgets/metric-views/metric-summary-card.test.tsx create mode 100644 src/components/widgets/metric-views/metric-summary-card.tsx delete mode 100644 src/components/widgets/v2/collab-messaging-panel.stories.tsx delete mode 100644 src/components/widgets/v2/collab-messaging-panel.test.tsx delete mode 100644 src/components/widgets/v2/collab-messaging-panel.tsx delete mode 100644 src/components/widgets/v2/summary-with-breakdown.tsx 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-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 ? ( + <> + + {open ? ( +
+
+ {rows.map((row) => ( + + ))} +
+
    + {rows.map((row) => ( +
  • + + + {row.label} + + + {formatMetricValue(row.value, metric.format, metric.unit)} + +
  • + ))} +
+
+ ) : null} + + ) : null} +
+
+ ); +} 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/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 ? ( - <> - - {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/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/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 ?? [], };