From 813379f5446dc29e9c9d6f20c43f88c86d37d977 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 19:42:24 +0200 Subject: [PATCH 01/13] feat(tasks): cut task delivery over to metric results Flip the task_delivery group from the legacy bullet path to the unified metrics system: 14 tasks.* metrics, tasks.closed as the KPI tile, a throughput timeseries drilldown replacing the legacy trend extra. Legacy delivery plumbing stays for the v1 dashboard fallback and the team heatmap feed, which still read the bullet path until those surfaces migrate. Signed-off-by: Aleksandr Barkhatov --- src/api/metric-registry.ts | 8 +- .../widgets/v2/group-drilldown-sheet.tsx | 39 +---- .../widgets/v2/kpi-tile.stories.tsx | 2 +- src/lib/insight/groups.test.ts | 2 +- src/lib/insight/groups.ts | 73 ++++++++- src/lib/insight/kpi-row.test.ts | 60 +------ src/lib/insight/v2/bullet-defs.ts | 10 -- src/lib/insight/v2/metric-order.ts | 13 -- src/mocks/metric-results-fixtures.ts | 153 ++++++++++++++++++ src/queries/v2/ic-extras.ts | 15 +- src/queries/v2/team-extras.ts | 26 +-- .../ic-dashboard/engineering-dashboard-v2.tsx | 1 - 12 files changed, 252 insertions(+), 150 deletions(-) diff --git a/src/api/metric-registry.ts b/src/api/metric-registry.ts index 25ad19f..77043d1 100644 --- a/src/api/metric-registry.ts +++ b/src/api/metric-registry.ts @@ -29,11 +29,11 @@ export const METRIC_REGISTRY = { V2_IC_SECTION_TREND: "00000000-0000-0000-0001-000000000036", // Per-person "member values" for the team heatmap + needs-attention widgets: - // long rows (person_id, metric_key, value) for a roster, no cohort. + // long rows (person_id, metric_key, value) for a roster, no cohort. Task + // delivery and collaboration both flipped to unified IC metrics groups, but + // their per-member values still feed the legacy team heatmap bullet + // columns + needs-attention until that surface migrates to unified metrics. V2_MEMBER_VALUES_DELIVERY: "00000000-0000-0000-0001-000000000040", - // Collaboration flipped to a unified IC metrics group, but its per-member - // values still feed the legacy team heatmap meeting-hours bullet column + - // needs-attention until that surface migrates to unified metrics. V2_MEMBER_VALUES_COLLAB: "00000000-0000-0000-0001-000000000041", // Per-person PRs merged for a roster (period-bounded, from the weekly git diff --git a/src/components/widgets/v2/group-drilldown-sheet.tsx b/src/components/widgets/v2/group-drilldown-sheet.tsx index a84f6f1..ac2f219 100644 --- a/src/components/widgets/v2/group-drilldown-sheet.tsx +++ b/src/components/widgets/v2/group-drilldown-sheet.tsx @@ -9,11 +9,6 @@ import { TeamCollectionDrilldown, type TeamMemberRef, } from "@/components/widgets/metric-views/team-collection-drilldown"; -import { - SectionTrend, - type SectionTrendPoint, - type SectionTrendSeries, -} from "@/components/widgets/v2/section-trend"; import { Dialog, DialogClose, @@ -26,10 +21,7 @@ import { Spinner } from "@/components/ui/spinner"; import type { DateRange } from "@/api/period-to-date-range"; import type { GroupDef } from "@/lib/insight/groups"; import { partitionBullets } from "@/lib/insight/v2/partition"; -import { - useIcDrilldownBatch, - type DrilldownBatchData, -} from "@/queries/v2/ic-extras"; +import { useIcDrilldownBatch } from "@/queries/v2/ic-extras"; import type { PeerCohortLabel } from "@/lib/peers"; import type { MetricCollectionResult } from "@/queries/metric-results"; import { cn } from "@/lib/utils"; @@ -197,7 +189,6 @@ function LegacyDrilldownBody({ counters.length === 0 && distributions.length === 0 && batch?.histograms.size === 0 && - !batch?.delivery?.length && !batch?.sectionTrend?.length; const showFullSpinner = isFirstLoad || (isBodyEmpty && batchQ.isFetching); @@ -216,7 +207,6 @@ function LegacyDrilldownBody({ batchQ.isFetching && "opacity-60", )} > - {batch ? : null} {counters.length > 0 ? ( ) : null} @@ -251,30 +241,3 @@ function LegacyDrilldownBody({ ); } - -function DrilldownExtras({ - sectionId, - batch, -}: { - sectionId: string; - batch: DrilldownBatchData; -}) { - if (sectionId === "task_delivery") { - const data: SectionTrendPoint[] = (batch.delivery ?? []).map((d) => ({ - date: d.label, - tasksDone: d.tasksDone, - })); - const series: SectionTrendSeries[] = [ - { key: "tasksDone", label: "Tasks closed" }, - ]; - return ( - - ); - } - return null; -} diff --git a/src/components/widgets/v2/kpi-tile.stories.tsx b/src/components/widgets/v2/kpi-tile.stories.tsx index 931eafa..98c8994 100644 --- a/src/components/widgets/v2/kpi-tile.stories.tsx +++ b/src/components/widgets/v2/kpi-tile.stories.tsx @@ -29,7 +29,7 @@ import { KpiTile } from "./kpi-tile"; function makeTile(overrides: Partial = {}): KpiTileData { return { - key: "tasks_closed", + key: "tasks.closed", label: "Bugs Fixed", value: "12", valueStatus: "good", diff --git a/src/lib/insight/groups.test.ts b/src/lib/insight/groups.test.ts index ddc0a45..d489043 100644 --- a/src/lib/insight/groups.test.ts +++ b/src/lib/insight/groups.test.ts @@ -25,7 +25,7 @@ describe("groups registry", () => { expect(groupIdForMetricKey("ai.active_days")).toBe("ai_adoption"); expect(groupIdForMetricKey("git.prs_merged")).toBe("git_output"); expect(groupIdForMetricKey("git.pr_cycle_time_h")).toBe("git_output"); - expect(groupIdForMetricKey("tasks_closed")).toBeNull(); + expect(groupIdForMetricKey("tasks.closed")).toBe("task_delivery"); expect(groupIdForMetricKey("nope.unknown")).toBeNull(); }); diff --git a/src/lib/insight/groups.ts b/src/lib/insight/groups.ts index b33aa89..1310699 100644 --- a/src/lib/insight/groups.ts +++ b/src/lib/insight/groups.ts @@ -61,6 +61,60 @@ export type GroupId = | "ai_adoption" | "wiki"; +const TASK_DELIVERY_COLLECTION: MetricCollectionConfig = { + metrics: [ + { + key: "tasks.closed", + views: [ + { view: "period" }, + { view: "peer" }, + { view: "timeseries", bucket: "auto" }, + ], + }, + { + key: "tasks.bugs_fixed", + views: [ + { view: "period" }, + { view: "peer" }, + { view: "timeseries", bucket: "auto" }, + ], + }, + { key: "tasks.dev_time", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.resolution_time", + views: [{ view: "period" }, { view: "peer" }], + }, + { key: "tasks.pickup_time", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.flow_efficiency", + views: [{ view: "period" }, { view: "peer" }], + }, + { key: "tasks.reopen_rate", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.due_date_compliance", + views: [{ view: "period" }, { view: "peer" }], + }, + { + key: "tasks.on_time_delivery", + views: [{ view: "period" }, { view: "peer" }], + }, + { key: "tasks.avg_slip", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.estimation_accuracy", + views: [{ view: "period" }, { view: "peer" }], + }, + { + key: "tasks.worklog_accuracy", + views: [{ view: "period" }, { view: "peer" }], + }, + { key: "tasks.bugs_ratio", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.stale_in_progress", + views: [{ view: "period" }, { view: "peer" }], + }, + ], +}; + const AI_ADOPTION_COLLECTION: MetricCollectionConfig = { metrics: [ { @@ -226,7 +280,22 @@ const COLLABORATION_COLLECTION: MetricCollectionConfig = { }; export const GROUPS: readonly GroupDef[] = [ - { kind: "legacy", id: "task_delivery", title: "Task delivery" }, + { + kind: "metrics", + id: "task_delivery", + title: "Task delivery", + collection: TASK_DELIVERY_COLLECTION, + card: { + preview: ["tasks.closed", "tasks.resolution_time", "tasks.reopen_rate"], + }, + drilldown: [ + { + chart: "line", + view: "timeseries", + metrics: ["tasks.closed", "tasks.bugs_fixed"], + }, + ], + }, { kind: "metrics", id: "git_output", @@ -328,7 +397,7 @@ export type KpiTileSource = | { kind: "metric"; metricKey: string }; export const KPI_ROW: readonly KpiTileSource[] = [ - { kind: "legacy", key: "tasks_closed", groupId: "task_delivery" }, + { kind: "metric", metricKey: "tasks.closed" }, { kind: "metric", metricKey: "collab.focus_time_pct" }, { kind: "metric", metricKey: "git.prs_merged" }, { kind: "metric", metricKey: "ai.active_days" }, diff --git a/src/lib/insight/kpi-row.test.ts b/src/lib/insight/kpi-row.test.ts index 69f40d4..001ca66 100644 --- a/src/lib/insight/kpi-row.test.ts +++ b/src/lib/insight/kpi-row.test.ts @@ -1,39 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { CatalogMetric } from "@/api/catalog-client"; import type { MetricResult } from "@/api/metric-results-client"; -import { - kpiRowTiles, - legacyKpiTiles, - metricKpiTiles, -} from "@/lib/insight/kpi-row"; +import { kpiRowTiles, metricKpiTiles } from "@/lib/insight/kpi-row"; import { KPI_ROW } from "@/lib/insight/groups"; import { normalizeMetricResults } from "@/lib/metrics/collection"; -import type { IcKpi } from "@/types/insight"; - -function icKpi(overrides: Partial = {}): IcKpi { - return { - period: "month", - metric_key: "tasks_closed", - label: "Tasks closed", - value: "12", - raw_value: 12, - unit: "", - sublabel: "", - delta: "+9%", - delta_type: "good", - peer_median: 10, - peer_n: 8, - ...overrides, - }; -} - -const CATALOG_ROW = { - higher_is_better: true, - schema_status: "ok", - format: "integer", - source_tags: ["jira"], -} as unknown as CatalogMetric; function metricResult( key: string, @@ -69,20 +39,6 @@ function metricResult( } as MetricResult; } -describe("legacyKpiTiles", () => { - it("keeps legacy formatting/scoring semantics", () => { - const tiles = legacyKpiTiles([icKpi()], () => CATALOG_ROW, "all"); - expect(tiles).toHaveLength(1); - const tile = tiles[0]!; - expect(tile.value).toBe("12"); - expect(tile.valueStatus).toBe("good"); // 12 >= median 10 - expect(tile.delta).toEqual({ text: "+9%", status: "good", down: false }); - expect(tile.groupId).toBe("task_delivery"); - expect(tile.context).toBe("jira"); - }); - -}); - describe("metricKpiTiles", () => { it("builds display-ready tiles with median status and delta", () => { const byKey = normalizeMetricResults([ @@ -171,14 +127,10 @@ describe("metricKpiTiles", () => { }); describe("kpiRowTiles", () => { - it("orders tiles by KPI_ROW display order across legacy and metric sources", () => { - const legacy = legacyKpiTiles( - [icKpi({ metric_key: "tasks_closed" })], - () => CATALOG_ROW, - "all", - ); + it("orders tiles by KPI_ROW display order", () => { const metric = metricKpiTiles( normalizeMetricResults([ + metricResult("tasks.closed", 12), metricResult("git.prs_merged", 9), metricResult("ai.active_days", 14), ]), @@ -186,15 +138,15 @@ describe("kpiRowTiles", () => { "me@x.com", "all", ); - const ordered = kpiRowTiles(legacy, metric).map((t) => t.key); + const ordered = kpiRowTiles([], metric).map((t) => t.key); const expected = KPI_ROW.map((s) => s.kind === "legacy" ? s.key : s.metricKey, ).filter((k) => - ["tasks_closed", "git.prs_merged", "ai.active_days"].includes(k), + ["tasks.closed", "git.prs_merged", "ai.active_days"].includes(k), ); expect(ordered).toEqual(expected); expect(ordered).toEqual([ - "tasks_closed", + "tasks.closed", "git.prs_merged", "ai.active_days", ]); diff --git a/src/lib/insight/v2/bullet-defs.ts b/src/lib/insight/v2/bullet-defs.ts index 0900ddc..afc9d08 100644 --- a/src/lib/insight/v2/bullet-defs.ts +++ b/src/lib/insight/v2/bullet-defs.ts @@ -27,16 +27,6 @@ export const BULLET_DESCRIPTION_BY_KEY: ReadonlyMap = new Map< string, string >(Object.entries({ - tasks_completed: "Closed tasks per developer", - task_dev_time: "Median time in development statuses", - task_reopen_rate: "Closed tasks reopened later", - due_date_compliance: "Tasks closed by their due date", - estimation_accuracy: "Estimate vs actual dev time", - bugs_to_task_ratio: "Bugs as a share of closed tasks", - mean_time_to_resolution: "Median issue lifetime, create to close", - flow_efficiency: "Dev time vs total lifetime", - pickup_time: "Created to first dev status", - prs_per_dev: "Merged PRs per developer", build_success: "CI runs passed vs total", pr_cycle_time: "Hours from PR open to merge", diff --git a/src/lib/insight/v2/metric-order.ts b/src/lib/insight/v2/metric-order.ts index 0d74afc..e11b3c2 100644 --- a/src/lib/insight/v2/metric-order.ts +++ b/src/lib/insight/v2/metric-order.ts @@ -1,17 +1,4 @@ export const METRIC_ORDER_BY_SECTION: Record = { - task_delivery: [ - "tasks_completed", - "mean_time_to_resolution", - "pickup_time", - "stale_in_progress", - "task_reopen_rate", - "bugs_to_task_ratio", - "flow_efficiency", - "estimation_accuracy", - "due_date_compliance", - "worklog_logging_accuracy", - "task_dev_time", - ], code_quality: [ "build_success", "bugs_fixed", diff --git a/src/mocks/metric-results-fixtures.ts b/src/mocks/metric-results-fixtures.ts index bea9144..6b011e1 100644 --- a/src/mocks/metric-results-fixtures.ts +++ b/src/mocks/metric-results-fixtures.ts @@ -314,10 +314,163 @@ export const COLLAB_METRIC_FIXTURES: MetricResult[] = [ }, ]; +/** + * Task-delivery 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 task 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 TASK_METRIC_FIXTURES: MetricResult[] = [ + { + metric_key: "tasks.dev_time", + label: "Dev Time", + unit: "h", + format: "decimal", + direction: "lower_is_better", + computation: "median", + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 14.2 }] }, + ], + }, + { + metric_key: "tasks.resolution_time", + label: "Resolution Time", + unit: "d", + format: "decimal", + direction: "lower_is_better", + computation: "median", + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 3.5 }] }, + ], + }, + { + metric_key: "tasks.pickup_time", + label: "Pickup Time", + unit: "d", + format: "decimal", + direction: "lower_is_better", + computation: "median", + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 1.2 }] }, + ], + }, + { + metric_key: "tasks.flow_efficiency", + label: "Flow Efficiency", + unit: "%", + format: "percent", + direction: "higher_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 54.0 }] }, + ], + }, + { + metric_key: "tasks.reopen_rate", + label: "Reopen Rate", + unit: "%", + format: "percent", + direction: "lower_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 6.5 }] }, + ], + }, + { + metric_key: "tasks.due_date_compliance", + label: "Due Date Compliance", + unit: "%", + format: "percent", + direction: "higher_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 88.0 }] }, + ], + }, + { + metric_key: "tasks.on_time_delivery", + label: "On-Time Delivery", + unit: "%", + format: "percent", + direction: "higher_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 91.5 }] }, + ], + }, + { + metric_key: "tasks.avg_slip", + label: "Average Slip", + unit: "d", + format: "decimal", + direction: "lower_is_better", + computation: "ratio", + scale: 1, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 0.8 }] }, + ], + }, + { + metric_key: "tasks.estimation_accuracy", + label: "Estimation Accuracy", + unit: "%", + format: "percent", + direction: "higher_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 82.0 }] }, + ], + }, + { + metric_key: "tasks.worklog_accuracy", + label: "Worklog Accuracy", + unit: "%", + format: "percent", + direction: "higher_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 95.0 }] }, + ], + }, + { + metric_key: "tasks.bugs_ratio", + label: "Bugs Ratio", + unit: "%", + format: "percent", + direction: "lower_is_better", + computation: "ratio", + scale: 100, + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 12.0 }] }, + ], + }, + { + metric_key: "tasks.stale_in_progress", + label: "Stale In Progress", + unit: "tasks", + format: "integer", + direction: "lower_is_better", + computation: "sum", + views: [ + { view: "period", values: [{ entity_id: "alice@example.com", value: 4 }] }, + ], + }, +]; + export function metricResultFixtureByKey(key: string): MetricResult | null { return ( METRIC_RESULTS_RESPONSE_FIXTURE.metrics.find((m) => m.metric_key === key) ?? COLLAB_METRIC_FIXTURES.find((m) => m.metric_key === key) ?? + TASK_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 46c5b53..587107a 100644 --- a/src/queries/v2/ic-extras.ts +++ b/src/queries/v2/ic-extras.ts @@ -9,9 +9,7 @@ import { import { METRIC_REGISTRY } from "@/api/metric-registry"; import { odataEscapeValue } from "@/api/odata"; import type { DateRange } from "@/api/period-to-date-range"; -import type { RawDeliveryTrendRow } from "@/api/raw-types"; -import type { DeliveryDataPoint, PeriodValue } from "@/types/insight"; -import { transformDeliveryTrend } from "@/api/transforms"; +import type { PeriodValue } from "@/types/insight"; function canonicalPersonId(personId: string): string { return personId.trim().toLowerCase(); @@ -129,7 +127,6 @@ export function useIcSectionTrend( export interface DrilldownBatchData { histograms: Map; - delivery: DeliveryDataPoint[] | null; sectionTrend: SectionTrendPointRow[] | null; } @@ -169,7 +166,6 @@ export function icDrilldownBatchQueryOptions(opts: IcDrilldownBatchOpts) { if (!sectionId || !personId || !range || !period) { return { histograms: new Map(), - delivery: null, sectionTrend: null, }; } @@ -183,13 +179,6 @@ export function icDrilldownBatchQueryOptions(opts: IcDrilldownBatchOpts) { }, ]; - if (sectionId === "task_delivery") { - items.push({ - id: "delivery", - metric_id: METRIC_REGISTRY.IC_CHART_DELIVERY, - $filter: pfilter, - }); - } if (sectionId === "code_quality" || sectionId === "ai_adoption") { items.push({ id: "section_trend", @@ -216,12 +205,10 @@ export function icDrilldownBatchQueryOptions(opts: IcDrilldownBatchOpts) { } } - const deliveryRows = getItems(byId, "delivery"); const sectionTrendRows = getItems(byId, "section_trend"); return { histograms, - delivery: deliveryRows ? transformDeliveryTrend(deliveryRows, period) : null, sectionTrend: sectionTrendRows ? pivotLongToWide(sectionTrendRows) : null, diff --git a/src/queries/v2/team-extras.ts b/src/queries/v2/team-extras.ts index 01db6c4..9aa5a5a 100644 --- a/src/queries/v2/team-extras.ts +++ b/src/queries/v2/team-extras.ts @@ -20,19 +20,21 @@ import type { BulletMetric, PeriodValue } from "@/types/insight"; // 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, -}; +// to `kind: "metrics"`). Task delivery and collaboration are the exceptions +// below: both flipped to unified IC groups, but the heatmap's bullet columns +// have no unified replacement yet, so their per-member feeds are retained +// explicitly until those surfaces migrate. +const MEMBER_VALUE_METRIC_IDS: Partial> = {}; const SECTION_METRIC_IDS = [ ...legacyGroups().flatMap((def) => { const metricId = MEMBER_VALUE_METRIC_IDS[def.id]; return metricId ? [{ sectionId: def.id, metricId }] : []; }), + { + sectionId: "task_delivery", + metricId: METRIC_REGISTRY.V2_MEMBER_VALUES_DELIVERY, + }, { sectionId: "collaboration", metricId: METRIC_REGISTRY.V2_MEMBER_VALUES_COLLAB, @@ -151,17 +153,17 @@ 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. 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, -}; +// view instead. Task delivery and collaboration are the exceptions (see +// SECTION_METRIC_IDS): their dept distributions still color the heatmap +// bullet columns. +const DEPT_DIST_BULLET_BY_SECTION: Partial> = {}; 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_DELIVERY, METRIC_REGISTRY.V2_DEPT_DIST_COLLAB, ]; diff --git a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx index 8822864..835fb4c 100644 --- a/src/screens/ic-dashboard/engineering-dashboard-v2.tsx +++ b/src/screens/ic-dashboard/engineering-dashboard-v2.tsx @@ -70,7 +70,6 @@ const LEGACY_GROUP_ROWS: Record< string, (data: IcDashboardData | undefined) => BulletMetric[] > = { - task_delivery: (data) => data?.taskDelivery ?? [], wiki: (data) => data?.wiki ?? [], }; From f00495245acf069400bdf5b060f7836203ffc0ab Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 20:04:13 +0200 Subject: [PATCH 02/13] feat(tasks): add duration distributions to the drilldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Histogram views plus Distributions blocks for the three median duration metrics — development time, time to resolution, pickup time. Signed-off-by: Aleksandr Barkhatov --- src/lib/insight/groups.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/lib/insight/groups.ts b/src/lib/insight/groups.ts index 1310699..d7629ae 100644 --- a/src/lib/insight/groups.ts +++ b/src/lib/insight/groups.ts @@ -79,12 +79,18 @@ const TASK_DELIVERY_COLLECTION: MetricCollectionConfig = { { view: "timeseries", bucket: "auto" }, ], }, - { key: "tasks.dev_time", views: [{ view: "period" }, { view: "peer" }] }, + { + key: "tasks.dev_time", + views: [{ view: "period" }, { view: "peer" }, { view: "histogram" }], + }, { key: "tasks.resolution_time", - views: [{ view: "period" }, { view: "peer" }], + views: [{ view: "period" }, { view: "peer" }, { view: "histogram" }], + }, + { + key: "tasks.pickup_time", + views: [{ view: "period" }, { view: "peer" }, { view: "histogram" }], }, - { key: "tasks.pickup_time", views: [{ view: "period" }, { view: "peer" }] }, { key: "tasks.flow_efficiency", views: [{ view: "period" }, { view: "peer" }], @@ -294,6 +300,17 @@ export const GROUPS: readonly GroupDef[] = [ view: "timeseries", metrics: ["tasks.closed", "tasks.bugs_fixed"], }, + { + chart: "histogram", + view: "histogram", + metrics: ["tasks.resolution_time"], + }, + { + chart: "histogram", + view: "histogram", + metrics: ["tasks.pickup_time"], + }, + { chart: "histogram", view: "histogram", metrics: ["tasks.dev_time"] }, ], }, { From c2472b4b4ebe0572489236a769c93463030f2fd4 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 20:04:13 +0200 Subject: [PATCH 03/13] feat(metrics): sort empty distributions last, refine empty state Populated histograms lead; distributions with no events for the entity in the period sort to the end so the section never opens on a placeholder. Empty tiles now carry the same header as populated ones and center the message in the chart-height area instead of collapsing into the top-left corner. Signed-off-by: Aleksandr Barkhatov --- .../metric-views/collection-drilldown.tsx | 13 +++- .../metric-views/metric-histogram.test.tsx | 4 +- .../widgets/metric-views/metric-histogram.tsx | 70 +++++++++++-------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/src/components/widgets/metric-views/collection-drilldown.tsx b/src/components/widgets/metric-views/collection-drilldown.tsx index 2613f27..f7698e9 100644 --- a/src/components/widgets/metric-views/collection-drilldown.tsx +++ b/src/components/widgets/metric-views/collection-drilldown.tsx @@ -12,7 +12,7 @@ import { MetricSummaryCard } from "@/components/widgets/metric-views/metric-summ import { MetricTrend } from "@/components/widgets/metric-views/metric-trend"; import { PeerStory } from "@/components/widgets/metric-views/peer-story"; import type { DrilldownBlock, MetricGroup } from "@/lib/insight/groups"; -import type { NormalizedMetricResult } from "@/lib/metrics/collection"; +import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; import { buildPeerStoryEntries } from "@/lib/metrics/peer-story"; import type { PeerCohortLabel } from "@/lib/peers"; import type { MetricCollectionResult } from "@/queries/metric-results"; @@ -124,9 +124,18 @@ export function CollectionDrilldown({ !isSummaryBlock(block) && blockMetrics(block, data.byKey).length > 0, ); - const distributionMetrics = def.drilldown + // Populated distributions lead; those with no events for this entity in the + // period sort to the end so the section doesn't open on an empty placeholder. + // Stable partition keeps declared order within each group. + const declaredDistributions = def.drilldown .filter((block) => block.view === "histogram") .flatMap((block) => blockMetrics(block, data.byKey)); + const hasDistribution = (metric: NormalizedMetricResult) => + (forEntity(metric, entityId).histogram[0]?.bins?.length ?? 0) > 0; + const distributionMetrics = [ + ...declaredDistributions.filter(hasDistribution), + ...declaredDistributions.filter((metric) => !hasDistribution(metric)), + ]; return (
{ it("shows the empty state for an entity with no bins", () => { render(); - expect(screen.getByText("No distribution yet.")).toBeInTheDocument(); + expect(screen.getByText("No values in this period")).toBeInTheDocument(); }); it("shows the empty state for an entity absent from the view", () => { render(); - expect(screen.getByText("No distribution yet.")).toBeInTheDocument(); + expect(screen.getByText("No values in this period")).toBeInTheDocument(); }); }); diff --git a/src/components/widgets/metric-views/metric-histogram.tsx b/src/components/widgets/metric-views/metric-histogram.tsx index 959969c..a32a675 100644 --- a/src/components/widgets/metric-views/metric-histogram.tsx +++ b/src/components/widgets/metric-views/metric-histogram.tsx @@ -99,47 +99,57 @@ export function MetricHistogram({ metric, entityId }: MetricHistogramProps) { const data = forEntity(metric, entityId); const bins = data.histogram[0]?.bins ?? []; const unit = metric.unit ? ` ${metric.unit}` : ""; + const ownMedian = data.value; + const peerMedian = data.peer?.median ?? null; + const direction = directionText(metric); + + // Shared header so an empty tile reads as the same chart, laid out to match + // its populated neighbours in the grid rather than collapsing to a corner. + const header = ( +
+
+ {metric.label} + + {[ + direction, + peerMedian != null + ? `vs peer median ${formatMetricNumber(peerMedian, metric.format)}${unit}` + : null, + ] + .filter(Boolean) + .join(" · ")} + +
+ {ownMedian != null ? ( + + Median{" "} + + {formatMetricNumber(ownMedian, metric.format)} + {unit} + + + ) : null} +
+ ); if (bins.length === 0) { return ( -
- {metric.label} - No distribution yet. +
+ {header} +
+ + No values in this period + +
); } - const ownMedian = data.value; - const peerMedian = data.peer?.median ?? null; const { rows, pivotLabel } = buildRows(bins, peerMedian, metric); - const direction = directionText(metric); return (
-
-
- {metric.label} - - {[ - direction, - peerMedian != null - ? `vs peer median ${formatMetricNumber(peerMedian, metric.format)}${unit}` - : null, - ] - .filter(Boolean) - .join(" · ")} - -
- {ownMedian != null ? ( - - Median{" "} - - {formatMetricNumber(ownMedian, metric.format)} - {unit} - - - ) : null} -
+ {header} From a76e5eef722b9fb1c435d6aee6f20b225a357550 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Tue, 14 Jul 2026 20:25:41 +0200 Subject: [PATCH 04/13] refactor(ui): refine needs-attention panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the destructive Alert, warning icon, and "N below peers" title — the section heading and red values already signal severity, and "below" misreads on lower-is-better metrics (cost above the median is worse, not below). Neutral Card container; member rows read "trailing". Metric rows adopt the drilldown treatment: colored value then a muted "· median X", with the middot as its own flex item so the row gap frames it evenly. Signed-off-by: Aleksandr Barkhatov --- .../widgets/v2/ic-needs-attention.test.tsx | 2 +- .../widgets/v2/ic-needs-attention.tsx | 33 +++++++++++-------- .../v2/team-members-attention.test.tsx | 6 ++-- .../widgets/v2/team-members-attention.tsx | 19 +++++------ src/lib/insight/attention.test.ts | 4 +-- src/lib/insight/attention.ts | 9 ++--- 6 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/components/widgets/v2/ic-needs-attention.test.tsx b/src/components/widgets/v2/ic-needs-attention.test.tsx index c60d293..60d0691 100644 --- a/src/components/widgets/v2/ic-needs-attention.test.tsx +++ b/src/components/widgets/v2/ic-needs-attention.test.tsx @@ -15,7 +15,7 @@ function item(overrides: Partial = {}): AttentionItem { group: "ai_adoption", label: "Active AI days", valueText: "2 days", - medianText: "Median 11 days", + medianText: "11 days", relGap: 0.8, ...overrides, }; diff --git a/src/components/widgets/v2/ic-needs-attention.tsx b/src/components/widgets/v2/ic-needs-attention.tsx index fc18cbe..a196d62 100644 --- a/src/components/widgets/v2/ic-needs-attention.tsx +++ b/src/components/widgets/v2/ic-needs-attention.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; -import { AlertTriangle } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent } from "@/components/ui/card"; import { useSettings } from "@/hooks/use-settings"; import type { AttentionItem } from "@/lib/insight/attention"; import type { GroupId } from "@/lib/insight/groups"; @@ -41,13 +40,11 @@ export function IcNeedsAttention({ return (
-

+

Needs attention

- - - {attentionAll.length} metrics below peers - + +
    {visible.map((item) => (
  • @@ -61,16 +58,24 @@ export function IcNeedsAttention({ {item.valueText} {item.medianText ? ( - - {item.medianText} - + <> + + · + + + median {item.medianText} + + ) : null}
  • @@ -89,8 +94,8 @@ export function IcNeedsAttention({ ) : null}
-
-
+ +
); } diff --git a/src/components/widgets/v2/team-members-attention.test.tsx b/src/components/widgets/v2/team-members-attention.test.tsx index 9909f94..73310ce 100644 --- a/src/components/widgets/v2/team-members-attention.test.tsx +++ b/src/components/widgets/v2/team-members-attention.test.tsx @@ -5,7 +5,7 @@ * department distribution (`deptCohorts` keyed by `org_unit_id → metric_key * → PeerStats`). Covers the wave-1 DESIGN §3.3 rendering rules: * - bullet that lands in the bottom quartile of the member's dept counts - * toward "N below peers". + * toward the attention count (subtitle + per-row "trailing"). * - `schema_status='error'` bullets are filtered out of the count. * - Missing-id bullets (no catalog row) are filtered out. * - A degenerate department cohort (`n < MIN_DEPT_COHORT_N`) is not counted. @@ -159,7 +159,7 @@ describe("", () => { ); await waitFor(() => { expect( - screen.getByText("1 members below peers"), + screen.getByText("1 members · vs department peers"), ).toBeInTheDocument(); }); expect(screen.getByText("Alice")).toBeInTheDocument(); @@ -211,7 +211,7 @@ describe("", () => { />, ); await waitFor(() => { - expect(screen.getByText("1 members below peers")).toBeInTheDocument(); + expect(screen.getByText("1 members · vs department peers")).toBeInTheDocument(); }); expect(screen.getByText("Alice")).toBeInTheDocument(); }); diff --git a/src/components/widgets/v2/team-members-attention.tsx b/src/components/widgets/v2/team-members-attention.tsx index 8f5a46e..bdf967c 100644 --- a/src/components/widgets/v2/team-members-attention.tsx +++ b/src/components/widgets/v2/team-members-attention.tsx @@ -1,8 +1,7 @@ -import { AlertTriangle } from "lucide-react"; import { Link } from "@tanstack/react-router"; import { useCatalog } from "@/api/use-catalog"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Card, CardContent } from "@/components/ui/card"; import { useSettings } from "@/hooks/use-settings"; import { memberMetricPeerStatus } from "@/lib/insight/v2/team-member-status"; import { normalizePersonId } from "@/lib/metrics/entity"; @@ -65,13 +64,11 @@ export function TeamMembersAttention({ return (
-

+

Members needing attention

- - - {attention.length} members below peers - + + {subtitle}
    {attention.map(({ member, belowCount }) => ( @@ -87,20 +84,20 @@ export function TeamMembersAttention({ {belowCount} - below peers + trailing ))}
-
-
+ +
); } diff --git a/src/lib/insight/attention.test.ts b/src/lib/insight/attention.test.ts index 987ef90..f2ff5d4 100644 --- a/src/lib/insight/attention.test.ts +++ b/src/lib/insight/attention.test.ts @@ -78,7 +78,7 @@ describe("legacyAttentionItems", () => { group: "collaboration", label: "Meeting hours", valueText: "22 h", - medianText: "Median 8 h", + medianText: "8 h", }); expect(items[0]?.relGap).toBeGreaterThan(0); }); @@ -110,7 +110,7 @@ describe("metricAttentionItems", () => { group: "ai_adoption", label: "Active AI days", valueText: "2 days", - medianText: "Median 11 days", + medianText: "11 days", }); }); diff --git a/src/lib/insight/attention.ts b/src/lib/insight/attention.ts index 2fb67e4..c893025 100644 --- a/src/lib/insight/attention.ts +++ b/src/lib/insight/attention.ts @@ -22,6 +22,7 @@ export interface AttentionItem { group: GroupId; label: string; valueText: string; + /** Formatted peer-median value only (no label); the view frames it. */ medianText: string | null; relGap: number; } @@ -34,7 +35,7 @@ export interface LegacyAttentionGroup { /** Legacy bullet rows + catalog direction → attention items. */ export function legacyAttentionItems( groups: LegacyAttentionGroup[], - byMetricKey: CatalogByKey, + byMetricKey: CatalogByKey ): AttentionItem[] { const items: AttentionItem[] = []; for (const group of groups) { @@ -63,7 +64,7 @@ export function legacyAttentionItems( group: group.id, label: row.label, valueText: `${row.value}${row.unit ? ` ${row.unit}` : ""}`, - medianText: `Median ${Math.round(median * 10) / 10}${row.unit ? ` ${row.unit}` : ""}`, + medianText: `${Math.round(median * 10) / 10}${row.unit ? ` ${row.unit}` : ""}`, relGap, }); } @@ -75,7 +76,7 @@ export function legacyAttentionItems( export function metricAttentionItems( def: MetricGroup, byKey: Map, - entityId: string, + entityId: string ): AttentionItem[] { const items: AttentionItem[] = []; for (const metricConfig of def.collection.metrics) { @@ -103,7 +104,7 @@ export function metricAttentionItems( group: def.id, label: metric.label, valueText: formatMetricValue(value, metric.format, metric.unit), - medianText: `Median ${formatMetricValue(median, metric.format, metric.unit)}`, + medianText: formatMetricValue(median, metric.format, metric.unit), relGap, }); } From 831f80cfc52a183a2e1ed51c1c3666024b585a82 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 11:12:40 +0200 Subject: [PATCH 05/13] feat(metrics): show peer-gap magnitude on dashboard surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the drilldown's gap formatter (× / signed-% / pp / signed absolute) into a shared util so the KPI tiles and the needs-attention list convey the scale of divergence, not just the median. Compact on the dashboard ("3.5× vs median 3,563", "16× vs median 35 lines"), verbose in the drilldown — one magnitude computation underneath. Percentage metrics diverge in points, not a ratio: "-35 pp", never a "-39%" that misreads as points beside a 90% median (and never a meaningless "0.6×"). Signed-off-by: Aleksandr Barkhatov --- .../widgets/metric-views/peer-story.tsx | 43 +++--------- .../widgets/v2/ic-needs-attention.test.tsx | 13 ++++ .../widgets/v2/ic-needs-attention.tsx | 10 +++ .../widgets/v2/kpi-tile.stories.tsx | 4 +- src/components/widgets/v2/kpi-tile.test.tsx | 20 +++++- src/components/widgets/v2/kpi-tile.tsx | 13 +++- src/lib/insight/attention.test.ts | 2 + src/lib/insight/attention.ts | 21 ++++++ src/lib/insight/kpi-row.test.ts | 2 +- src/lib/insight/kpi-row.ts | 31 ++++++++- src/lib/metrics/gap.test.ts | 58 ++++++++++++++++ src/lib/metrics/gap.ts | 67 +++++++++++++++++++ 12 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 src/lib/metrics/gap.test.ts create mode 100644 src/lib/metrics/gap.ts diff --git a/src/components/widgets/metric-views/peer-story.tsx b/src/components/widgets/metric-views/peer-story.tsx index 54681fc..de29e17 100644 --- a/src/components/widgets/metric-views/peer-story.tsx +++ b/src/components/widgets/metric-views/peer-story.tsx @@ -23,6 +23,7 @@ import { partitionPeerStory, type PeerStoryEntry, } from "@/lib/metrics/peer-story"; +import { formatGapMagnitude } from "@/lib/metrics/gap"; 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"; @@ -34,41 +35,15 @@ interface PeerStoryProps { className?: string; } -/** - * At/above this multiple of the cohort median a signed percent runs past - * ±100% and reads as noise ("-1500%"), so show "N×" instead. Only the - * above-median side explodes; below-median gaps are bounded to −100% and - * stay as a percent. - */ -const GAP_MULTIPLE_THRESHOLD = 2; - -function formatMultiple(ratio: number): string { - const rounded = ratio >= 10 ? Math.round(ratio) : Math.round(ratio * 10) / 10; - return `${rounded}×`; -} - -function formatGapPct(gap: number): string { - const pct = Math.round(Math.abs(gap) * 100); - if (pct === 0) return "0%"; - return `${gap >= 0 ? "+" : "-"}${pct}%`; -} - function formatGap(entry: PeerStoryEntry): string { - const median = entry.stats?.p50; - if ( - median != null && - Math.abs(median) > 1e-9 && - entry.value / median >= GAP_MULTIPLE_THRESHOLD - ) { - return formatMultiple(entry.value / median); - } - if (entry.gapPct != null) return formatGapPct(entry.gapPct); - const sign = entry.gapDelta >= 0 ? "+" : "-"; - return `${sign}${formatMetricValue( - Math.abs(entry.gapDelta), - entry.format, - entry.unit, - )}`; + return formatGapMagnitude({ + value: entry.value, + median: entry.stats?.p50 ?? null, + gapPct: entry.gapPct, + gapDelta: entry.gapDelta, + format: entry.format, + unit: entry.unit, + }); } function outlierText(status: PeerStoryEntry["status"]): string { diff --git a/src/components/widgets/v2/ic-needs-attention.test.tsx b/src/components/widgets/v2/ic-needs-attention.test.tsx index 60d0691..cf45b93 100644 --- a/src/components/widgets/v2/ic-needs-attention.test.tsx +++ b/src/components/widgets/v2/ic-needs-attention.test.tsx @@ -16,6 +16,7 @@ function item(overrides: Partial = {}): AttentionItem { label: "Active AI days", valueText: "2 days", medianText: "11 days", + gapText: "-82%", relGap: 0.8, ...overrides, }; @@ -44,6 +45,18 @@ describe("IcNeedsAttention", () => { expect(rows[1]).toHaveTextContent("Small gap"); }); + it("shows the divergence gap next to the median", () => { + render( + , + ); + const row = screen.getByRole("button"); + expect(row).toHaveTextContent("-82%"); + expect(row).toHaveTextContent("vs median 11 days"); + }); + it("routes clicks to the owning group", async () => { const onOpenGroup = vi.fn(); render( diff --git a/src/components/widgets/v2/ic-needs-attention.tsx b/src/components/widgets/v2/ic-needs-attention.tsx index a196d62..2d33e01 100644 --- a/src/components/widgets/v2/ic-needs-attention.tsx +++ b/src/components/widgets/v2/ic-needs-attention.tsx @@ -73,6 +73,16 @@ export function IcNeedsAttention({ · + {item.gapText ? ( + <> + + {item.gapText} + {" "} + vs{" "} + + ) : null} median {item.medianText} diff --git a/src/components/widgets/v2/kpi-tile.stories.tsx b/src/components/widgets/v2/kpi-tile.stories.tsx index 98c8994..7f4fde9 100644 --- a/src/components/widgets/v2/kpi-tile.stories.tsx +++ b/src/components/widgets/v2/kpi-tile.stories.tsx @@ -34,7 +34,9 @@ function makeTile(overrides: Partial = {}): KpiTileData { value: "12", valueStatus: "good", delta: { text: "+9%", status: "good", down: false }, - medianLabel: "Median 6", + medianLabel: "median 6", + gapText: null, + gapStatus: "neutral", context: "Jira", groupId: "task_delivery", ...overrides, diff --git a/src/components/widgets/v2/kpi-tile.test.tsx b/src/components/widgets/v2/kpi-tile.test.tsx index 42d62f9..4caf561 100644 --- a/src/components/widgets/v2/kpi-tile.test.tsx +++ b/src/components/widgets/v2/kpi-tile.test.tsx @@ -20,7 +20,9 @@ function tile(overrides: Partial = {}): KpiTileData { value: "14", valueStatus: "good", delta: { text: "+17%", status: "good", down: false }, - medianLabel: "Median 11", + medianLabel: "median 11", + gapText: null, + gapStatus: "neutral", context: "Days with any AI tool activity", groupId: "ai_adoption", ...overrides, @@ -32,12 +34,26 @@ describe("KpiTile", () => { render(); expect(screen.getByText("14")).toBeInTheDocument(); expect(screen.getByText("+17%")).toBeInTheDocument(); - expect(screen.getByText("Median 11")).toBeInTheDocument(); + expect(screen.getByText("median 11")).toBeInTheDocument(); expect( screen.getByText("Days with any AI tool activity"), ).toBeInTheDocument(); }); + it("shows the divergence gap next to the median", () => { + render( + , + ); + expect(screen.getByText("3.5×")).toBeInTheDocument(); + expect(screen.getByText(/vs median 3,563/)).toBeInTheDocument(); + }); + it("falls back to 'No peer data' without a median label", () => { render(); expect(screen.getByText("No peer data")).toBeInTheDocument(); diff --git a/src/components/widgets/v2/kpi-tile.tsx b/src/components/widgets/v2/kpi-tile.tsx index 8238ea9..cc76843 100644 --- a/src/components/widgets/v2/kpi-tile.tsx +++ b/src/components/widgets/v2/kpi-tile.tsx @@ -81,7 +81,18 @@ export function KpiTile({ tile, onOpenGroup }: KpiTileProps) { - {tile.medianLabel ?? "No peer data"} + {tile.gapText && tile.medianLabel ? ( + + + {tile.gapText} + {" "} + vs {tile.medianLabel} + + ) : ( + (tile.medianLabel ?? "No peer data") + )} ); diff --git a/src/lib/insight/attention.test.ts b/src/lib/insight/attention.test.ts index f2ff5d4..99bdaf3 100644 --- a/src/lib/insight/attention.test.ts +++ b/src/lib/insight/attention.test.ts @@ -79,6 +79,7 @@ describe("legacyAttentionItems", () => { label: "Meeting hours", valueText: "22 h", medianText: "8 h", + gapText: "2.8×", }); expect(items[0]?.relGap).toBeGreaterThan(0); }); @@ -111,6 +112,7 @@ describe("metricAttentionItems", () => { label: "Active AI days", valueText: "2 days", medianText: "11 days", + gapText: "-82%", }); }); diff --git a/src/lib/insight/attention.ts b/src/lib/insight/attention.ts index c893025..1e1ebe7 100644 --- a/src/lib/insight/attention.ts +++ b/src/lib/insight/attention.ts @@ -1,4 +1,5 @@ import { formatMetricValue } from "@/lib/format"; +import { formatGapMagnitude } from "@/lib/metrics/gap"; import type { MetricGroup, GroupId } from "@/lib/insight/groups"; import { bulletCatalogKey, @@ -24,6 +25,8 @@ export interface AttentionItem { valueText: string; /** Formatted peer-median value only (no label); the view frames it. */ medianText: string | null; + /** Scale of divergence from the median ("16×", "−40%"); null at the median. */ + gapText: string | null; relGap: number; } @@ -59,12 +62,21 @@ export function legacyAttentionItems( const relGap = higherIsBetter ? (median - value) / denom : (value - median) / denom; + const gapDelta = value - median; items.push({ key: row.metric_key, group: group.id, label: row.label, valueText: `${row.value}${row.unit ? ` ${row.unit}` : ""}`, medianText: `${Math.round(median * 10) / 10}${row.unit ? ` ${row.unit}` : ""}`, + gapText: formatGapMagnitude({ + value, + median, + gapPct: Math.abs(median) > 1e-9 ? gapDelta / Math.abs(median) : null, + gapDelta, + format: "decimal", + unit: row.unit ?? null, + }), relGap, }); } @@ -99,12 +111,21 @@ export function metricAttentionItems( const relGap = higherIsBetter ? (median - value) / denom : (value - median) / denom; + const gapDelta = value - median; items.push({ key: metric.metric_key, group: def.id, label: metric.label, valueText: formatMetricValue(value, metric.format, metric.unit), medianText: formatMetricValue(median, metric.format, metric.unit), + gapText: formatGapMagnitude({ + value, + median, + gapPct: Math.abs(median) > 1e-9 ? gapDelta / Math.abs(median) : null, + gapDelta, + format: metric.format, + unit: metric.unit, + }), relGap, }); } diff --git a/src/lib/insight/kpi-row.test.ts b/src/lib/insight/kpi-row.test.ts index 001ca66..05b8bc7 100644 --- a/src/lib/insight/kpi-row.test.ts +++ b/src/lib/insight/kpi-row.test.ts @@ -58,7 +58,7 @@ describe("metricKpiTiles", () => { expect(active.value).toBe("14"); expect(active.valueStatus).toBe("good"); // >= median 10 expect(active.delta?.text).toBe("+17%"); - expect(active.medianLabel).toBe("Median 10"); + expect(active.medianLabel).toBe("median 10"); expect(active.groupId).toBe("ai_adoption"); const lines = tiles[1]!; expect(lines.delta).toEqual({ text: "-10%", status: "bad", down: true }); diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts index 43f14a1..36c6478 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 { formatGapMagnitude } from "@/lib/metrics/gap"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; import { computeDelta, type MetricDelta } from "@/lib/metrics/delta"; import type { FocusMode } from "@/lib/peers"; @@ -32,6 +33,13 @@ export interface KpiTileData { valueStatus: Status; delta: { text: string; status: Status; down: boolean } | null; medianLabel: string | null; + /** + * Scale of divergence from the peer median ("3.5×", "−39%", "−35 pp"), shown + * beside the median; null at the median or without an honest comparison. + * Colored by `gapStatus`. + */ + gapText: string | null; + gapStatus: Status; /** Secondary context line, shown when explanations are enabled. */ context: string | null; groupId: GroupId | null; @@ -84,7 +92,7 @@ export function legacyKpiTiles( const medianLabel = hasMedian && catalogRow !== undefined && !isSchemaError - ? `Median ${formatKpiValue(peerMedian, catalogRow.format)}${isPercent ? "%" : ""}` + ? `median ${formatKpiValue(peerMedian, catalogRow.format)}${isPercent ? "%" : ""}` : null; return [ @@ -95,6 +103,9 @@ export function legacyKpiTiles( valueStatus, delta, medianLabel, + // The legacy batch reconstructs no arithmetic gap; median alone shows. + gapText: null, + gapStatus: "neutral", context: catalogRow?.source_tags.length ? catalogRow.source_tags.join(", ") : null, @@ -177,6 +188,20 @@ export function metricKpiTiles( } : null; + // Divergence magnitude vs the median — only for an eligible standing with + // a real gap (at the median there's nothing to scream about). + const gapText = + standing.eligible && value != null && Math.abs(standing.gapDelta) > 1e-9 + ? formatGapMagnitude({ + value, + median, + gapPct: standing.gapPct, + gapDelta: standing.gapDelta, + format: metric.format, + unit: metric.unit, + }) + : null; + return [ { key: metric.metric_key, @@ -191,12 +216,14 @@ export function metricKpiTiles( delta, medianLabel: median != null - ? `Median ${ + ? `median ${ metric.format === "percent" ? formatMetricValue(median, metric.format, metric.unit) : formatMetricNumber(median, metric.format) }` : null, + gapText, + gapStatus: valueStatus, context: metric.description ?? null, groupId: groupIdForMetricKey(metric.metric_key), }, diff --git a/src/lib/metrics/gap.test.ts b/src/lib/metrics/gap.test.ts new file mode 100644 index 0000000..524a2ec --- /dev/null +++ b/src/lib/metrics/gap.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { formatGapMagnitude } from "@/lib/metrics/gap"; + +describe("formatGapMagnitude", () => { + it("renders percent metrics as points, never a relative percent or ratio", () => { + // Focus 55% vs median 90%: the gap is 35 points below, not "-39%". + expect( + formatGapMagnitude({ + value: 55, + median: 90, + gapPct: (55 - 90) / 90, + gapDelta: 55 - 90, + format: "percent", + unit: null, + }) + ).toBe("-35.0 pp"); + }); + + it("uses a multiple far above the median", () => { + expect( + formatGapMagnitude({ + value: 12424, + median: 3563, + gapPct: (12424 - 3563) / 3563, + gapDelta: 12424 - 3563, + format: "integer", + unit: "lines", + }) + ).toBe("3.5×"); + }); + + it("uses a signed percent for sub-2x gaps", () => { + expect( + formatGapMagnitude({ + value: 3, + median: 5, + gapPct: (3 - 5) / 5, + gapDelta: 3 - 5, + format: "integer", + unit: "tasks", + }) + ).toBe("-40%"); + }); + + it("falls back to a signed absolute delta when the median is ~0", () => { + expect( + formatGapMagnitude({ + value: 2, + median: 0, + gapPct: null, + gapDelta: 2, + format: "integer", + unit: "lines", + }) + ).toBe("+2 lines"); + }); +}); diff --git a/src/lib/metrics/gap.ts b/src/lib/metrics/gap.ts new file mode 100644 index 0000000..e9c968e --- /dev/null +++ b/src/lib/metrics/gap.ts @@ -0,0 +1,67 @@ +import type { MetricFormat } from "@/api/metric-results-client"; +import { formatMetricValue, formatPp } from "@/lib/format"; + +/** + * The single "scale of divergence" formatter shared by every surface that + * shows how far a value sits from its peer median — the drilldown peer story, + * the KPI tiles, and the needs-attention list. Only the surrounding wording + * differs per surface (verbose in the drilldown, compact on the dashboard); + * the magnitude itself is computed once, here. + * + * At/above `GAP_MULTIPLE_THRESHOLD`× the median a signed percent runs away + * (300%, 500%…), so the far-above side reads as a multiple ("5.6×"); nearer + * gaps and the whole below-median side (bounded to −100%) stay a signed + * percent; a sub-unit gap with no usable median falls back to a signed + * absolute delta. + */ +const GAP_MULTIPLE_THRESHOLD = 2; + +function formatMultiple(ratio: number): string { + const rounded = ratio >= 10 ? Math.round(ratio) : Math.round(ratio * 10) / 10; + return `${rounded}×`; +} + +function formatGapPct(gap: number): string { + const pct = Math.round(Math.abs(gap) * 100); + if (pct === 0) return "0%"; + return `${gap >= 0 ? "+" : "-"}${pct}%`; +} + +export interface GapInput { + value: number; + /** Peer-cohort median; null when suppressed. */ + median: number | null; + /** Arithmetic (value − median) / |median|; null when median ~ 0. */ + gapPct: number | null; + /** Arithmetic value − median. */ + gapDelta: number; + format: MetricFormat; + unit: string | null; +} + +export function formatGapMagnitude({ + value, + median, + gapPct, + gapDelta, + format, + unit, +}: GapInput): string { + // Percentage metrics diverge in percentage POINTS, not a ratio: a relative + // "−39%" beside a "90%" median reads as points and misstates the gap, and a + // multiple ("0.6×") is meaningless. gapDelta is already the point spread — + // render it like the period delta pill. + if (format === "percent") { + return formatPp(gapDelta); + } + if ( + median != null && + Math.abs(median) > 1e-9 && + value / median >= GAP_MULTIPLE_THRESHOLD + ) { + return formatMultiple(value / median); + } + if (gapPct != null) return formatGapPct(gapPct); + const sign = gapDelta >= 0 ? "+" : "-"; + return `${sign}${formatMetricValue(Math.abs(gapDelta), format, unit)}`; +} From fc80076b287789b09c146f50170afec850edaf96 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 13:34:26 +0200 Subject: [PATCH 06/13] fix(ui): fill needs-attention grid with six items The collapsed count of 3 left an orphan slot in the two-column grid. Show two rows before the fold; raise the collapse threshold so an exactly-six list has no dead "Show 0 more" toggle. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- src/components/widgets/v2/ic-needs-attention.test.tsx | 4 ++-- src/components/widgets/v2/ic-needs-attention.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/widgets/v2/ic-needs-attention.test.tsx b/src/components/widgets/v2/ic-needs-attention.test.tsx index cf45b93..3dc3930 100644 --- a/src/components/widgets/v2/ic-needs-attention.test.tsx +++ b/src/components/widgets/v2/ic-needs-attention.test.tsx @@ -70,10 +70,10 @@ describe("IcNeedsAttention", () => { }); it("collapses beyond the threshold with a show-more toggle", () => { - const items = Array.from({ length: 7 }, (_, i) => + const items = Array.from({ length: 9 }, (_, i) => item({ key: `m${i}`, label: `Metric ${i}`, relGap: i }), ); render(); - expect(screen.getByText("Show 4 more")).toBeInTheDocument(); + expect(screen.getByText("Show 3 more")).toBeInTheDocument(); }); }); diff --git a/src/components/widgets/v2/ic-needs-attention.tsx b/src/components/widgets/v2/ic-needs-attention.tsx index 2d33e01..a72620a 100644 --- a/src/components/widgets/v2/ic-needs-attention.tsx +++ b/src/components/widgets/v2/ic-needs-attention.tsx @@ -7,8 +7,8 @@ import type { GroupId } from "@/lib/insight/groups"; import { PEER_TEXT, applyFocus } from "@/lib/peers"; import { cn } from "@/lib/utils"; -const COLLAPSED_ATTENTION = 3; -const COLLAPSE_THRESHOLD = 6; +const COLLAPSED_ATTENTION = 6; +const COLLAPSE_THRESHOLD = 7; export interface IcNeedsAttentionProps { items: AttentionItem[]; From b4e62769f2ad96a4c3a8d5aa855a4c72c76be50c Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 13:34:35 +0200 Subject: [PATCH 07/13] refactor(metrics): unify peer-standing coloring on quartile rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every peer surface now paints one scale — the quartile rank from the shared standing derivation. Red means bottom quartile, green means top quartile, being with the pack renders calm. - peerStatusToStatus maps in_pack to neutral, not amber: a mid-pack metric is normal, not a warning. - KPI tiles and the drilldown summary card color by rank, not median side. Median-side split painted ~50% of any cohort red by construction; rank draws the ~25% bottom-quartile line the needs-attention list already uses. - Section grading, group cards, and team rollups carry rank (top/in_pack/bottom/unranked) end to end instead of re-deriving a display status per surface; amber survives only where a section earns it (one bottom below the pattern bar). - Drop medianSide from PeerStanding — no remaining consumers. The unified group-card headline drops the redundant median absolute, keeping "value · gap vs median". Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- .../metric-views/metric-group-card.test.tsx | 9 +- .../metric-views/metric-group-card.tsx | 124 +++++++++++------- .../metric-views/metric-summary-card.tsx | 17 +-- .../team-metric-group-card.test.tsx | 6 +- .../metric-views/team-metric-group-card.tsx | 28 ++-- .../widgets/v2/section-card.test.tsx | 26 ++-- src/components/widgets/v2/section-card.tsx | 52 ++++---- src/lib/insight/kpi-row.test.ts | 8 +- src/lib/insight/kpi-row.ts | 14 +- src/lib/insight/team-metrics.test.ts | 4 +- src/lib/insight/team-metrics.ts | 26 ++-- src/lib/insight/v2/peer-status.test.ts | 20 +-- src/lib/insight/v2/peer-status.ts | 15 +-- src/lib/insight/v2/team-member-status.test.ts | 30 ++--- src/lib/insight/v2/team-member-status.ts | 26 ++-- src/lib/metrics/peer-standing.test.ts | 9 +- src/lib/metrics/peer-standing.ts | 24 +--- src/lib/scoring.ts | 100 ++++++++++---- src/screens/team-view-v2.tsx | 4 +- 19 files changed, 294 insertions(+), 248 deletions(-) diff --git a/src/components/widgets/metric-views/metric-group-card.test.tsx b/src/components/widgets/metric-views/metric-group-card.test.tsx index ccbeeb3..2038f1d 100644 --- a/src/components/widgets/metric-views/metric-group-card.test.tsx +++ b/src/components/widgets/metric-views/metric-group-card.test.tsx @@ -120,8 +120,8 @@ describe("MetricGroupCard", () => { onOpen={vi.fn()} />, ); - // No metric is scorable → no "in top" tally. - expect(screen.getByText("No peer data")).toBeInTheDocument(); + // No metric is scorable → no rankable peers. + expect(screen.getByText("no peer data")).toBeInTheDocument(); }); it("scores measured people against quartiles", () => { @@ -136,8 +136,9 @@ describe("MetricGroupCard", () => { onOpen={vi.fn()} />, ); - // active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 of 2 in top. - expect(screen.getByText("1 of 2 in top")).toBeInTheDocument(); + // active_days 20 ≥ p75 15 → top; cost 2 ≤ p25 5 → bottom → 1 bottom wins + // the phrase (behind beats ahead), single bottom below the red bar → amber. + expect(screen.getByText("1 behind peers")).toBeInTheDocument(); }); it("owns its error state with retry", () => { diff --git a/src/components/widgets/metric-views/metric-group-card.tsx b/src/components/widgets/metric-views/metric-group-card.tsx index a28b7cb..0afa9d1 100644 --- a/src/components/widgets/metric-views/metric-group-card.tsx +++ b/src/components/widgets/metric-views/metric-group-card.tsx @@ -12,19 +12,23 @@ import { formatMetricValue } from "@/lib/format"; import type { MetricGroup } from "@/lib/insight/groups"; import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; -import { derivePeerStanding } from "@/lib/metrics/peer-standing"; +import { formatGapMagnitude } from "@/lib/metrics/gap"; import { - aggregateSectionStatus, - pickSectionHeadline, - sectionCounts, - type ScoredMetric, + derivePeerStanding, + type PeerStanding, +} from "@/lib/metrics/peer-standing"; +import type { PeerStatusWithNeutral } from "@/lib/peers"; +import { + gradeSectionStanding, + rankCounts, + rankableCount, + sectionStandingPhrase, } from "@/lib/scoring"; import { STATUS_BG_CLASS, STATUS_STRIPE_LEFT, STATUS_TEXT_CLASS, applyFocusStatus, - type Status, } from "@/lib/status"; import type { MetricCollectionResult } from "@/queries/metric-results"; import { cn } from "@/lib/utils"; @@ -37,18 +41,26 @@ export interface MetricGroupCardProps { onHover?: () => void; subtitle?: string; /** - * Per-metric status override (team view): replaces the single-entity + * Per-metric rank override (team view): replaces the single-entity * quartile scoring with a roster rollup computed by the caller. */ - statusByMetricKey?: Map; + rankByMetricKey?: Map; } interface CardRow { metric: NormalizedMetricResult; value: number | null; - status: Status; + rank: PeerStatusWithNeutral; + standing: PeerStanding; } +const HEADLINE_TIER: Record = { + bottom: 3, + in_pack: 2, + top: 1, + neutral: 0, +}; + export function MetricGroupCard({ def, data, @@ -56,7 +68,7 @@ export function MetricGroupCard({ onOpen, onHover, subtitle, - statusByMetricKey, + rankByMetricKey, }: MetricGroupCardProps) { const { focusMode } = useSettings(); @@ -97,31 +109,28 @@ export function MetricGroupCard({ const metric = data.byKey.get(metricConfig.key); if (!metric) return []; const entityData = forEntity(metric, entityId); - const status = - statusByMetricKey?.get(metric.metric_key) ?? - rowStatusFromPeer(metric, entityData.value, entityId); - return [{ metric, value: entityData.value, status }]; + const peerRow = + metric.peer?.values.find((v) => v.entity_id === entityId) ?? null; + // All eligibility gates (observed / suppressed / flat pool / neutral + // direction) live in the shared standing derivation, reused for the + // headline's severity ordering and gap. + const standing = derivePeerStanding(metric.direction, { + value: entityData.value, + peer: peerRow, + }); + const rank = rankByMetricKey?.get(metric.metric_key) ?? standing.rank; + return [{ metric, value: entityData.value, rank, standing }]; }); - const scored: ScoredMetric[] = rows.map((row) => ({ - row, - status: row.status, - })); - const status = applyFocusStatus(aggregateSectionStatus(scored), focusMode); - const counts = sectionCounts(scored); - const evaluated = counts.good + counts.warn + counts.bad; - const badgeText = - evaluated === 0 ? "No peer data" : `${counts.good} of ${evaluated} in top`; + const counts = rankCounts(rows.map((row) => ({ row, rank: row.rank }))); + const evaluated = rankableCount(counts); + const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); + const badgeText = sectionStandingPhrase(counts); - const headline = pickSectionHeadline(scored); - const summary = - headline && headline.row.value != null - ? `${headline.row.metric.label}: ${formatMetricValue( - headline.row.value, - headline.row.metric.format, - headline.row.metric.unit, - )}` - : "No data for this period."; + const headlineRow = pickHeadlineRow(rows); + const summary = headlineRow + ? headlineSummary(headlineRow) + : "No data for this period."; const preview = def.card.preview .map((key) => rows.find((row) => row.metric.metric_key === key)) @@ -174,7 +183,10 @@ export function MetricGroupCard({ {preview.length > 0 ? (
    {preview.map((row) => { - const previewStatus = applyFocusStatus(row.status, focusMode); + const previewStatus = applyFocusStatus( + peerStatusToStatus(row.rank), + focusMode, + ); return (
  • v.entity_id === entityId) ?? null; - // All eligibility gates (observed / suppressed / flat pool / neutral - // direction) live in the shared standing derivation. - const standing = derivePeerStanding(metric.direction, { +/** + * The card's spotlight metric: the worst-ranked row, breaking ties by the + * largest peer divergence so the number the header shows is the one most + * worth explaining. Falls back to any row with a value when nothing ranks, + * so a data-bearing card never headlines "No data". + */ +function pickHeadlineRow(rows: CardRow[]): CardRow | null { + const ranked = rows + .filter((row) => row.rank !== "neutral") + .reduce((best, row) => { + if (!best) return row; + if (HEADLINE_TIER[row.rank] !== HEADLINE_TIER[best.rank]) + return HEADLINE_TIER[row.rank] > HEADLINE_TIER[best.rank] + ? row + : best; + return row.standing.severity > best.standing.severity ? row : best; + }, null); + return ranked ?? rows.find((row) => row.value != null) ?? null; +} + +function headlineSummary(row: CardRow): string { + const { metric, value, standing } = row; + if (value == null) return "No data for this period."; + const base = `${metric.label}: ${formatMetricValue(value, metric.format, metric.unit)}`; + const stats = standing.stats; + if (!standing.eligible || stats == null || Math.abs(standing.gapDelta) <= 1e-9) + return base; + const gap = formatGapMagnitude({ value, - peer: peerRow, + median: stats.p50, + gapPct: standing.gapPct, + gapDelta: standing.gapDelta, + format: metric.format, + unit: metric.unit, }); - return peerStatusToStatus(standing.rank); + return `${base} · ${gap} vs median`; } diff --git a/src/components/widgets/metric-views/metric-summary-card.tsx b/src/components/widgets/metric-views/metric-summary-card.tsx index 91d3524..5ae5af7 100644 --- a/src/components/widgets/metric-views/metric-summary-card.tsx +++ b/src/components/widgets/metric-views/metric-summary-card.tsx @@ -14,6 +14,7 @@ import { formatMetricValue, metricDisplayUnit, } from "@/lib/format"; +import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; import { seriesColors } from "@/lib/series-colors"; @@ -43,19 +44,11 @@ export function MetricSummaryCard({ metric, entityId }: MetricSummaryCardProps) 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. + // the quartile rank come from the shared standing derivation — same + // verdict as the KPI tiles and the peer story by construction: red means + // bottom quartile, in-pack is normal and stays uncolored. const standing = derivePeerStanding(metric.direction, data); - const status = applyFocusStatus( - standing.medianSide === "favorable" - ? "good" - : standing.medianSide === "unfavorable" - ? "bad" - : "neutral", - focusMode, - ); + const status = applyFocusStatus(peerStatusToStatus(standing.rank), 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]; diff --git a/src/components/widgets/metric-views/team-metric-group-card.test.tsx b/src/components/widgets/metric-views/team-metric-group-card.test.tsx index 0de86fd..751036c 100644 --- a/src/components/widgets/metric-views/team-metric-group-card.test.tsx +++ b/src/components/widgets/metric-views/team-metric-group-card.test.tsx @@ -119,8 +119,8 @@ describe("TeamMetricGroupCard", () => { onOpen={vi.fn()} />, ); - // Metric scores good (plurality top) → 1 of 1 metrics ahead. - expect(screen.getByText("1 of 1 metrics ahead")).toBeInTheDocument(); + // Metric scores good (plurality top) → 1 ahead of peers. + expect(screen.getByText("1 ahead of peers")).toBeInTheDocument(); // Preview row reports the top/scored split. expect(screen.getByText("2 of 3 in top")).toBeInTheDocument(); }); @@ -140,7 +140,7 @@ describe("TeamMetricGroupCard", () => { onOpen={vi.fn()} />, ); - expect(screen.getByText("No peer data")).toBeInTheDocument(); + expect(screen.getByText("no peer data")).toBeInTheDocument(); expect( screen.getByText(/No metrics with peer data/i), ).toBeInTheDocument(); 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 115ed61..9c4b2a3 100644 --- a/src/components/widgets/metric-views/team-metric-group-card.tsx +++ b/src/components/widgets/metric-views/team-metric-group-card.tsx @@ -9,13 +9,15 @@ import { Spinner } from "@/components/ui/spinner"; import { ComingSoon } from "@/components/widgets/coming-soon"; import { useSettings } from "@/hooks/use-settings"; import type { MetricGroup } from "@/lib/insight/groups"; +import { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { teamMetricStandings, type TeamMetricStanding, } from "@/lib/insight/team-metrics"; import { - aggregateSectionStatus, - sectionCounts, + gradeSectionStanding, + rankCounts, + sectionStandingPhrase, } from "@/lib/scoring"; import { STATUS_BG_CLASS, @@ -82,20 +84,11 @@ export function TeamMetricGroupCard({ const standings = teamMetricStandings(def, data.byKey, memberIds); const scored = standings.filter((s) => s.scored > 0); - const scoredMetrics = standings.map((standing) => ({ - row: standing, - status: standing.status, - })); - const status = applyFocusStatus( - aggregateSectionStatus(scoredMetrics), - focusMode, + const counts = rankCounts( + standings.map((standing) => ({ row: standing, rank: standing.verdict })), ); - const counts = sectionCounts(scoredMetrics); - const evaluated = counts.good + counts.warn + counts.bad; - const badgeText = - evaluated === 0 - ? "No peer data" - : `${counts.good} of ${evaluated} metrics ahead`; + const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); + const badgeText = sectionStandingPhrase(counts); const preview: TeamMetricStanding[] = def.card.preview .map((key) => scored.find((s) => s.metric.metric_key === key)) @@ -143,7 +136,10 @@ export function TeamMetricGroupCard({
      {(preview.length > 0 ? preview : scored.slice(0, 3)).map( (standing) => { - const rowStatus = applyFocusStatus(standing.status, focusMode); + const rowStatus = applyFocusStatus( + peerStatusToStatus(standing.verdict), + focusMode, + ); return (
    • ` status coloring. * - * Two status sources: - * - IC view (no override): `rowStatus` colors each row off its own `peer` + * Two rank sources: + * - IC view (no override): each row ranks off its own `peer` * (p25/p50/p75) + the catalog's `higher_is_better` — no FE math. - * - Team view: a `statusByMetricKey` override replaces that per-row scoring, + * - Team view: a `rankByMetricKey` override replaces that per-row scoring, * so the card rolls up per-member-vs-own-department standings instead of * comparing a team aggregate against an individual band. * The headline always reflects the row's own label/value (the aggregate), @@ -74,7 +74,7 @@ describe(" peer-driven coloring", () => { }, ]), ); - // value 12 ≥ peer.p75 (8), higher_is_better ⇒ 'top' ⇒ good ⇒ "1 of 1 in top". + // value 12 ≥ peer.p75 (8), higher_is_better ⇒ 'top' ⇒ good ⇒ "1 ahead of peers". const row = makeBullet({ value: "12", peer: { p25: 4, p50: 6, p75: 8, min: 2, max: 15, n: 12 }, @@ -89,7 +89,7 @@ describe(" peer-driven coloring", () => { />, ); await waitFor(() => { - expect(screen.getByText("1 of 1 in top")).toBeInTheDocument(); + expect(screen.getByText("1 ahead of peers")).toBeInTheDocument(); }); // Headline reflects the team aggregate row, not the cohort. expect(screen.getByText("Tasks Closed: 12 tasks")).toBeInTheDocument(); @@ -98,7 +98,7 @@ describe(" peer-driven coloring", () => { ).toBeInTheDocument(); }); - it("statusByMetricKey override drives the badge, overriding the row's own peer", async () => { + it("rankByMetricKey override drives the badge, overriding the row's own peer", async () => { fetchCatalog.mockResolvedValue( buildCatalogResponse([ { @@ -108,8 +108,8 @@ describe(" peer-driven coloring", () => { }, ]), ); - // No peer on the row ⇒ rowStatus would be neutral ("No peer data"). The - // team override says 'good' ⇒ badge becomes "1 of 1 in top". + // No peer on the row ⇒ its own rank would be neutral ("no peer data"). + // The team override says 'top' ⇒ badge becomes "1 ahead of peers". const row = makeBullet({ value: "12", peer: undefined }); renderWithCatalogClient( peer-driven coloring", () => { rows={[row]} onOpen={() => {}} subtitle="vs department peers" - statusByMetricKey={new Map([["tasks_completed", "good"]])} + rankByMetricKey={new Map([["tasks_completed", "top"]])} />, ); await waitFor(() => { - expect(screen.getByText("1 of 1 in top")).toBeInTheDocument(); + expect(screen.getByText("1 ahead of peers")).toBeInTheDocument(); }); - expect(screen.queryByText("No peer data")).not.toBeInTheDocument(); + expect(screen.queryByText("no peer data")).not.toBeInTheDocument(); // Headline still the aggregate row. expect(screen.getByText("Tasks Closed: 12 tasks")).toBeInTheDocument(); }); - it("a row with no peer scores neutral — 'No peer data' badge", async () => { + it("a row with no peer scores neutral — 'no peer data' badge", async () => { fetchCatalog.mockResolvedValue( buildCatalogResponse([ { @@ -149,7 +149,7 @@ describe(" peer-driven coloring", () => { />, ); await waitFor(() => { - expect(screen.getByText("No peer data")).toBeInTheDocument(); + expect(screen.getByText("no peer data")).toBeInTheDocument(); }); }); }); diff --git a/src/components/widgets/v2/section-card.tsx b/src/components/widgets/v2/section-card.tsx index 80042ef..62b30ce 100644 --- a/src/components/widgets/v2/section-card.tsx +++ b/src/components/widgets/v2/section-card.tsx @@ -9,19 +9,25 @@ import { CardTitle, } from "@/components/ui/card"; import { useSettings } from "@/hooks/use-settings"; -import { hasBulletValue, rowStatus } from "@/lib/insight/v2/peer-status"; import { - aggregateSectionStatus, + hasBulletValue, + peerStatusForRow, + peerStatusToStatus, +} from "@/lib/insight/v2/peer-status"; +import type { PeerStatusWithNeutral } from "@/lib/peers"; +import { + gradeSectionStanding, pickSectionHeadline, - sectionCounts, - type ScoredMetric, + rankCounts, + rankableCount, + sectionStandingPhrase, + type RankedMetric, } from "@/lib/scoring"; import { STATUS_BG_CLASS, STATUS_STRIPE_LEFT, STATUS_TEXT_CLASS, applyFocusStatus, - type Status, } from "@/lib/status"; import { cn } from "@/lib/utils"; import type { BulletMetric } from "@/types/insight"; @@ -42,12 +48,13 @@ export interface SectionCardProps { subtitle?: string; unavailable?: boolean; /** - * Per-metric status override. When supplied (team view), it replaces the - * single-row `rowStatus` scoring — the team card rolls up each metric from + * Per-metric rank override. When supplied (team view), it replaces the + * single-row quartile scoring — the team card rolls up each metric from * per-member-vs-own-department standings instead of comparing a team - * aggregate against an individual band. Absent (IC view) → `rowStatus`. + * aggregate against an individual band. Absent (IC view) → the row's own + * cohort rank. */ - statusByMetricKey?: Map; + rankByMetricKey?: Map; } export function SectionCard({ @@ -58,12 +65,12 @@ export function SectionCard({ sectionId, subtitle, unavailable, - statusByMetricKey, + rankByMetricKey, }: SectionCardProps) { const { focusMode } = useSettings(); const { byMetricKey } = useCatalog(); - const metricStatus = (r: BulletMetric): Status => - statusByMetricKey?.get(r.metric_key) ?? rowStatus(r, byMetricKey); + const metricRank = (r: BulletMetric): PeerStatusWithNeutral => + rankByMetricKey?.get(r.metric_key) ?? peerStatusForRow(r, byMetricKey); if (unavailable) { return ( @@ -82,21 +89,16 @@ export function SectionCard({ ); } - const scored: ScoredMetric[] = rows.map((r) => ({ + const ranked: RankedMetric[] = rows.map((r) => ({ row: r, - status: metricStatus(r), + rank: metricRank(r), })); - const rawStatus = aggregateSectionStatus(scored); - const status = applyFocusStatus(rawStatus, focusMode); - const counts = sectionCounts(scored); - const evaluated = counts.good + counts.warn + counts.bad; - const topCount = counts.good; - const badgeText = - evaluated === 0 - ? "No peer data" - : `${topCount} of ${evaluated} in top`; + const counts = rankCounts(ranked); + const evaluated = rankableCount(counts); + const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); + const badgeText = sectionStandingPhrase(counts); - const headline = pickSectionHeadline(scored); + const headline = pickSectionHeadline(ranked); const unit = headline?.row.unit ?? ""; const summary = headline ? `${headline.row.label}: ${headline.row.value}${unit ? ` ${unit}` : ""}` @@ -149,7 +151,7 @@ export function SectionCard({
        {preview.map((r) => { const previewStatus = applyFocusStatus( - metricStatus(r), + peerStatusToStatus(metricRank(r)), focusMode, ); return ( diff --git a/src/lib/insight/kpi-row.test.ts b/src/lib/insight/kpi-row.test.ts index 05b8bc7..b8b280c 100644 --- a/src/lib/insight/kpi-row.test.ts +++ b/src/lib/insight/kpi-row.test.ts @@ -40,7 +40,7 @@ function metricResult( } describe("metricKpiTiles", () => { - it("builds display-ready tiles with median status and delta", () => { + it("builds display-ready tiles with rank status and delta", () => { const byKey = normalizeMetricResults([ metricResult("ai.active_days", 14), metricResult("ai.accepted_lines", 900), @@ -56,11 +56,15 @@ describe("metricKpiTiles", () => { ]); const active = tiles[0]!; expect(active.value).toBe("14"); - expect(active.valueStatus).toBe("good"); // >= median 10 + // 14 sits inside the IQR (5..15) — with the pack, so no color even + // though it's above the median. + expect(active.valueStatus).toBe("neutral"); expect(active.delta?.text).toBe("+17%"); expect(active.medianLabel).toBe("median 10"); expect(active.groupId).toBe("ai_adoption"); const lines = tiles[1]!; + // 900 ≥ p75 15 — top quartile earns the color. + expect(lines.valueStatus).toBe("good"); expect(lines.delta).toEqual({ text: "-10%", status: "bad", down: true }); }); diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts index 36c6478..6dca7e1 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 { peerStatusToStatus } from "@/lib/insight/v2/peer-status"; import { formatGapMagnitude } from "@/lib/metrics/gap"; import { derivePeerStanding } from "@/lib/metrics/peer-standing"; import { computeDelta, type MetricDelta } from "@/lib/metrics/delta"; @@ -151,17 +152,12 @@ export function metricKpiTiles( const value = data.value; const median = data.peer?.median ?? 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). + // and the quartile rank come from the shared standing derivation; the + // color follows the same rank mapping as every card and the peer story + // — red means bottom quartile, in-pack is normal and stays uncolored. const standing = derivePeerStanding(metric.direction, data); const valueStatus = applyFocusStatus( - standing.medianSide === "favorable" - ? "good" - : standing.medianSide === "unfavorable" - ? "bad" - : "neutral", + peerStatusToStatus(standing.rank), focusMode, ); diff --git a/src/lib/insight/team-metrics.test.ts b/src/lib/insight/team-metrics.test.ts index 53b4765..3d37f6a 100644 --- a/src/lib/insight/team-metrics.test.ts +++ b/src/lib/insight/team-metrics.test.ts @@ -117,10 +117,10 @@ describe("teamMetricStandings / metricBelowCounts", () => { const def = defWith(byKey.get("ai.active_days")!); const members = ["a@x.com", "b@x.com", "c@x.com"]; - it("rolls up by plurality: more bottom than top → bad", () => { + it("rolls up by plurality: more bottom than top → bottom", () => { const standings = teamMetricStandings(def, byKey, members); expect(standings).toHaveLength(1); - expect(standings[0]?.status).toBe("bad"); + expect(standings[0]?.verdict).toBe("bottom"); expect(standings[0]?.bottom).toBe(2); expect(standings[0]?.top).toBe(1); }); diff --git a/src/lib/insight/team-metrics.ts b/src/lib/insight/team-metrics.ts index 5af6c07..0c8976c 100644 --- a/src/lib/insight/team-metrics.ts +++ b/src/lib/insight/team-metrics.ts @@ -12,7 +12,6 @@ import { peerStatusVsQuartiles, type PeerStatusWithNeutral, } from "@/lib/peers"; -import type { Status } from "@/lib/status"; /** * One member's standing on one collection metric vs their OWN cohort (the @@ -46,15 +45,16 @@ export interface TeamMetricStanding { inPack: number; bottom: number; scored: number; - status: Status; + /** + * Plurality rank across the roster: more members below their own cohort + * than in any other band → `bottom`; more above → `top`; a tie or an + * on-par majority → `in_pack` (no plurality means no pattern); none + * scorable → `neutral`. + */ + verdict: PeerStatusWithNeutral; } -/** - * Roster rollup per collection metric — the same plurality rule as the - * legacy team card's `teamSectionStatusByMetric`: more members below - * their own cohort than above → `bad`; more above → `good`; tie or on-par - * majority → `warn`; none scorable → `neutral`. - */ +/** Roster rollup per collection metric. */ export function teamMetricStandings( def: MetricGroup, byKey: Map, @@ -73,15 +73,15 @@ export function teamMetricStandings( else if (standing === "in_pack") inPack += 1; } const scored = top + inPack + bottom; - const status: Status = + const verdict: PeerStatusWithNeutral = scored === 0 ? "neutral" : bottom > top && bottom > inPack - ? "bad" + ? "bottom" : top > bottom && top > inPack - ? "good" - : "warn"; - return [{ metric, top, inPack, bottom, scored, status }]; + ? "top" + : "in_pack"; + return [{ metric, top, inPack, bottom, scored, verdict }]; }); } diff --git a/src/lib/insight/v2/peer-status.test.ts b/src/lib/insight/v2/peer-status.test.ts index 50b73fe..b7af230 100644 --- a/src/lib/insight/v2/peer-status.test.ts +++ b/src/lib/insight/v2/peer-status.test.ts @@ -22,7 +22,7 @@ import { bulletCatalogKey, hasBulletValue, peerStatusForRow, - rowStatus, + peerStatusToStatus, type CatalogByKey, } from "./peer-status"; @@ -196,17 +196,11 @@ describe("peerStatusForRow — rendering rules", () => { }); }); -describe("rowStatus — maps peer status to display status", () => { - const byKey = makeByMetricKey([ - makeCatalogMetric({ higher_is_better: true }), - ]); - - it("top → good, bottom → bad, in_pack → warn, neutral → neutral", () => { - expect(rowStatus(makeRow({ value: "12", peer: STATS }), byKey)).toBe("good"); - expect(rowStatus(makeRow({ value: "2", peer: STATS }), byKey)).toBe("bad"); - expect(rowStatus(makeRow({ value: "6", peer: STATS }), byKey)).toBe("warn"); - expect(rowStatus(makeRow({ value: "—", peer: STATS }), byKey)).toBe( - "neutral", - ); +describe("peerStatusToStatus — maps rank to display status", () => { + it("top → good, bottom → bad, in_pack and neutral stay calm", () => { + expect(peerStatusToStatus("top")).toBe("good"); + expect(peerStatusToStatus("bottom")).toBe("bad"); + expect(peerStatusToStatus("in_pack")).toBe("neutral"); + expect(peerStatusToStatus("neutral")).toBe("neutral"); }); }); diff --git a/src/lib/insight/v2/peer-status.ts b/src/lib/insight/v2/peer-status.ts index 875bc9a..48e7b4c 100644 --- a/src/lib/insight/v2/peer-status.ts +++ b/src/lib/insight/v2/peer-status.ts @@ -57,16 +57,15 @@ export function peerStatusForRow( return peerStatusVsQuartiles(value, row.peer, m.higher_is_better); } +/** + * THE rank → display-status mapping, shared by every surface that paints a + * peer standing. Red means bottom quartile, green means top quartile, and + * being with the pack is the normal state — it renders calm (neutral), never + * amber. Amber is reserved for aggregate judgments that earn it (section + * grading). + */ export function peerStatusToStatus(p: PeerStatusWithNeutral): Status { if (p === "top") return "good"; if (p === "bottom") return "bad"; - if (p === "in_pack") return "warn"; return "neutral"; } - -export function rowStatus( - row: BulletMetric, - byMetricKey: CatalogByKey, -): Status { - return peerStatusToStatus(peerStatusForRow(row, byMetricKey)); -} diff --git a/src/lib/insight/v2/team-member-status.test.ts b/src/lib/insight/v2/team-member-status.test.ts index 3eeccee..86b6e84 100644 --- a/src/lib/insight/v2/team-member-status.test.ts +++ b/src/lib/insight/v2/team-member-status.test.ts @@ -4,7 +4,7 @@ import type { CatalogByKey } from "@/lib/insight/v2/peer-status"; import { MIN_DEPT_COHORT_N, memberMetricPeerStatus, - teamSectionStatusByMetric, + teamSectionRankByMetric, } from "@/lib/insight/v2/team-member-status"; import type { DeptCohorts, PeerStats } from "@/lib/peers"; import type { CatalogMetric } from "@/api/catalog-client"; @@ -102,11 +102,11 @@ function rosterBullets( return m; } -describe("teamSectionStatusByMetric", () => { +describe("teamSectionRankByMetric", () => { const dept = deptMap([["eng", "tasks", stats()]]); const members = [member("a", "eng"), member("b", "eng"), member("c", "eng")]; - it("flags 'bad' when more members are below their department than above", () => { + it("flags 'bottom' when more members are below their department than above", () => { // a,b below p25 (bottom); c above p75 (top) → bottom 2 > top 1 → bad. const bullets = rosterBullets( [ @@ -116,17 +116,17 @@ describe("teamSectionStatusByMetric", () => { ], "tasks", ); - const map = teamSectionStatusByMetric( + const map = teamSectionRankByMetric( [bullet("tasks", 0)], members, bullets, dept, byMetricKey, ); - expect(map.get("tasks")).toBe("bad"); + expect(map.get("tasks")).toBe("bottom"); }); - it("flags 'good' when more members are above than below", () => { + it("flags 'top' when more members are above than below", () => { const bullets = rosterBullets( [ ["a", 15], @@ -135,17 +135,17 @@ describe("teamSectionStatusByMetric", () => { ], "tasks", ); - const map = teamSectionStatusByMetric( + const map = teamSectionRankByMetric( [bullet("tasks", 0)], members, bullets, dept, byMetricKey, ); - expect(map.get("tasks")).toBe("good"); + expect(map.get("tasks")).toBe("top"); }); - it("falls to 'warn' on a top/bottom tie", () => { + it("falls to 'in_pack' on a top/bottom tie", () => { // one top, one bottom, one on-par → no plurality → warn. const bullets = rosterBullets( [ @@ -155,17 +155,17 @@ describe("teamSectionStatusByMetric", () => { ], "tasks", ); - const map = teamSectionStatusByMetric( + const map = teamSectionRankByMetric( [bullet("tasks", 0)], members, bullets, dept, byMetricKey, ); - expect(map.get("tasks")).toBe("warn"); + expect(map.get("tasks")).toBe("in_pack"); }); - it("falls to 'warn' when most members are on-par (in_pack majority)", () => { + it("falls to 'in_pack' when most members are on-par (in_pack majority)", () => { // two on-par, one below → bottom(1) not > in_pack(2) → warn, not bad. const bullets = rosterBullets( [ @@ -175,14 +175,14 @@ describe("teamSectionStatusByMetric", () => { ], "tasks", ); - const map = teamSectionStatusByMetric( + const map = teamSectionRankByMetric( [bullet("tasks", 0)], members, bullets, dept, byMetricKey, ); - expect(map.get("tasks")).toBe("warn"); + expect(map.get("tasks")).toBe("in_pack"); }); it("returns 'neutral' when no member has a usable department cohort", () => { @@ -195,7 +195,7 @@ describe("teamSectionStatusByMetric", () => { ], "tasks", ); - const map = teamSectionStatusByMetric( + const map = teamSectionRankByMetric( [bullet("tasks", 0)], members, bullets, diff --git a/src/lib/insight/v2/team-member-status.ts b/src/lib/insight/v2/team-member-status.ts index 1109601..0bb1f8e 100644 --- a/src/lib/insight/v2/team-member-status.ts +++ b/src/lib/insight/v2/team-member-status.ts @@ -4,7 +4,6 @@ import { type DeptCohorts, type PeerStatusWithNeutral, } from "@/lib/peers"; -import type { Status } from "@/lib/status"; import type { BulletMetric, TeamMember } from "@/types/insight"; // A department cohort must hold at least this many people before a member is @@ -39,20 +38,21 @@ export function memberMetricPeerStatus( } /** - * Team section-card status per metric, rolled up from per-member standings + * Team section-card rank per metric, rolled up from per-member standings * rather than the (statistically miscalibrated) team-aggregate-vs-individual- - * band comparison. Each metric takes the PLURALITY direction across the roster: - * more members below their own department than above → `bad`; more above → - * `good`; a tie or an on-par majority → `warn`; none scorable → `neutral`. + * band comparison. Each metric takes the PLURALITY direction across the + * roster: more members below their own department than in any other band → + * `bottom`; more above → `top`; a tie or an on-par majority → `in_pack` (no + * plurality means no pattern); none scorable → `neutral`. */ -export function teamSectionStatusByMetric( +export function teamSectionRankByMetric( rows: BulletMetric[], members: TeamMember[], bulletsByPerson: Map | undefined, deptCohorts: DeptCohorts | undefined, byMetricKey: CatalogByKey, -): Map { - const out = new Map(); +): Map { + const out = new Map(); for (const row of rows) { let top = 0; let inPack = 0; @@ -70,15 +70,15 @@ export function teamSectionStatusByMetric( else if (ps === "bottom") bottom += 1; else if (ps === "in_pack") inPack += 1; } - const status: Status = + const rank: PeerStatusWithNeutral = scored === 0 ? "neutral" : bottom > top && bottom > inPack - ? "bad" + ? "bottom" : top > bottom && top > inPack - ? "good" - : "warn"; - out.set(row.metric_key, status); + ? "top" + : "in_pack"; + out.set(row.metric_key, rank); } return out; } diff --git a/src/lib/metrics/peer-standing.test.ts b/src/lib/metrics/peer-standing.test.ts index 4e9272b..d09258a 100644 --- a/src/lib/metrics/peer-standing.test.ts +++ b/src/lib/metrics/peer-standing.test.ts @@ -18,7 +18,7 @@ function peer(overrides: Partial = {}): PeerEntityStats { } describe("derivePeerStanding", () => { - it("ranks an eligible value and takes the direction-adjusted median side", () => { + it("ranks an eligible value against the quartiles", () => { const standing = derivePeerStanding("higher_is_better", { value: 14, peer: peer(), @@ -27,7 +27,6 @@ describe("derivePeerStanding", () => { eligible: true, reason: "ok", rank: "in_pack", - medianSide: "favorable", observed: true, }); expect(standing.gapDelta).toBe(4); @@ -38,7 +37,6 @@ describe("derivePeerStanding", () => { value: 14, peer: peer(), }); - expect(lower.medianSide).toBe("unfavorable"); expect(lower.gapDelta).toBe(4); expect(lower.gapPct).toBeCloseTo(0.4); @@ -46,7 +44,6 @@ describe("derivePeerStanding", () => { value: 6, peer: peer(), }); - expect(below.medianSide).toBe("favorable"); expect(below.gapDelta).toBe(-4); }); @@ -108,11 +105,10 @@ describe("derivePeerStanding", () => { eligible: false, reason: "flat_pool", rank: "neutral", - medianSide: null, }); }); - it("marks a median tie as at, never an outlier", () => { + it("marks a median tie as in-pack, 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 }), @@ -120,7 +116,6 @@ describe("derivePeerStanding", () => { expect(standing).toMatchObject({ eligible: true, rank: "in_pack", - medianSide: "at", }); }); diff --git a/src/lib/metrics/peer-standing.ts b/src/lib/metrics/peer-standing.ts index 8bfa226..69269f7 100644 --- a/src/lib/metrics/peer-standing.ts +++ b/src/lib/metrics/peer-standing.ts @@ -9,16 +9,16 @@ import { /** * 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. + * the quartile-rank judgment 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. */ + /** Rankable — `rank` is meaningful. */ | "ok" /** No period value for the entity. */ | "no_value" @@ -39,12 +39,10 @@ export interface PeerStanding { 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. + * favorability — good/bad is `rank`'s (and the color's) job. */ gapDelta: number; gapPct: number | null; @@ -91,7 +89,6 @@ export function derivePeerStanding( 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) @@ -103,7 +100,6 @@ export function derivePeerStanding( eligible: false, reason, rank: "neutral", - medianSide: null, gapDelta, gapPct, severity: 0, @@ -121,12 +117,6 @@ export function derivePeerStanding( 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/scoring.ts b/src/lib/scoring.ts index 31db01f..9580482 100644 --- a/src/lib/scoring.ts +++ b/src/lib/scoring.ts @@ -1,48 +1,91 @@ +import type { PeerStatusWithNeutral } from "./peers" import type { Status } from "./status" -export type ScoredMetric = { +export type RankedMetric = { row: T - status: Status + rank: PeerStatusWithNeutral } -export type SectionCounts = { - good: number - warn: number - bad: number - neutral: number +export type RankCounts = { + top: number + inPack: number + bottom: number + unranked: number } -export function sectionCounts(metrics: ScoredMetric[]): SectionCounts { - const c: SectionCounts = { good: 0, warn: 0, bad: 0, neutral: 0 } - for (const m of metrics) c[m.status]++ +export function rankCounts(metrics: RankedMetric[]): RankCounts { + const c: RankCounts = { top: 0, inPack: 0, bottom: 0, unranked: 0 } + for (const m of metrics) { + if (m.rank === "top") c.top++ + else if (m.rank === "in_pack") c.inPack++ + else if (m.rank === "bottom") c.bottom++ + else c.unranked++ + } return c } -export function aggregateSectionStatus( - metrics: ScoredMetric[] -): Status { - if (metrics.length === 0) return "neutral" - const c = sectionCounts(metrics) - const evaluated = c.good + c.warn + c.bad - if (evaluated === 0) return "neutral" - if (c.bad > 0) return "bad" - if (c.warn >= c.good) return "warn" - return "good" +export function rankableCount(counts: RankCounts): number { + return counts.top + counts.inPack + counts.bottom +} + +/** + * Fraction of the rankable set a rank must clear to count as a section-wide + * pattern rather than quartile noise. A quartile hands ~25% of any healthy + * cohort to the bottom by construction, so a lone weak metric is expected, not + * a signal. + */ +const SECTION_PATTERN_BAR = 0.25 + +/** + * Section stripe/dot status from its rankable metrics — symmetric and + * base-rate aware. Red demands a pattern of weakness (≥2 bottoms AND more than + * a quarter of the rankable set); green mirrors it for strength and yields to + * any red. A single bottom below the red bar is amber; an all-in-pack or + * unrankable section stays calm (neutral). `in_pack` never pushes toward amber + * on its own — being with the pack is the normal state, not a warning. + */ +export function gradeSectionStanding(counts: RankCounts): Status { + const rankable = rankableCount(counts) + if (rankable === 0) return "neutral" + if (counts.bottom >= 2 && counts.bottom / rankable > SECTION_PATTERN_BAR) + return "bad" + if (counts.bottom >= 1) return "warn" + if (counts.top >= 2 && counts.top / rankable > SECTION_PATTERN_BAR) + return "good" + return "neutral" +} + +/** + * Factual one-liner for the section badge — the text states a count, the color + * (from `gradeSectionStanding`) carries the judgment. "Behind" wins over + * "ahead" on mixed profiles: the badge is a triage signal, and a strength + * never prompts opening a card. + */ +export function sectionStandingPhrase(counts: RankCounts): string { + if (rankableCount(counts) === 0) return "no peer data" + if (counts.bottom > 0) return `${counts.bottom} behind peers` + if (counts.top > 0) return `${counts.top} ahead of peers` + return "on par with peers" } +/** + * Worst-rank-first headline pick: a bottom outranks an in-pack outranks a + * top, ties broken by declaration order. Surfaces with a severity signal + * (the unified group card) refine the tiebreak themselves. + */ export function pickSectionHeadline( - metrics: ScoredMetric[] -): ScoredMetric | null { - const priority: Record = { - bad: 3, - warn: 2, - good: 1, + metrics: RankedMetric[] +): RankedMetric | null { + const priority: Record = { + bottom: 3, + in_pack: 2, + top: 1, neutral: 0, } - let best: ScoredMetric | null = null + let best: RankedMetric | null = null let bestPriority = -1 for (const m of metrics) { - const p = priority[m.status] + const p = priority[m.rank] if (p > bestPriority) { best = m bestPriority = p @@ -50,4 +93,3 @@ export function pickSectionHeadline( } return best } - diff --git a/src/screens/team-view-v2.tsx b/src/screens/team-view-v2.tsx index e235d87..eab439b 100644 --- a/src/screens/team-view-v2.tsx +++ b/src/screens/team-view-v2.tsx @@ -32,7 +32,7 @@ import { } from "@/lib/insight/team-metrics"; import { orderRowsForSection } from "@/lib/insight/v2/metric-order"; import { hasBulletValue } from "@/lib/insight/v2/peer-status"; -import { teamSectionStatusByMetric } from "@/lib/insight/v2/team-member-status"; +import { teamSectionRankByMetric } from "@/lib/insight/v2/team-member-status"; import { projectViews } from "@/lib/metrics/collection"; import { normalizePersonId } from "@/lib/metrics/entity"; import { useIcPerson } from "@/queries/ic-dashboard"; @@ -315,7 +315,7 @@ export function TeamViewV2Screen({ teamId, viewerEmail }: TeamViewV2ScreenProps) title={def.title} sectionId={def.id} rows={legacyRowsByGroup[def.id] ?? []} - statusByMetricKey={teamSectionStatusByMetric( + rankByMetricKey={teamSectionRankByMetric( legacyRowsByGroup[def.id] ?? [], members, bulletsQ.data, From ec0feb8d4f36b1ad41a485b8586bcaf5f3efb15e Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 13:49:54 +0200 Subject: [PATCH 08/13] fix(metrics): reconcile percentage-point gaps with displayed operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pp gap computed from raw fractions never added up to the rounded value and median shown beside it (55% · -35.3 pp vs median 90%), reading as a bug on the one gap form users check by addition. Compute the pp spread from the displayed (rounded) operands so the on-screen arithmetic closes, and drop the gap text entirely when rounding collapses it to zero rather than printing "0 pp" beside two equal numbers. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- .../metric-views/metric-group-card.tsx | 2 +- .../widgets/metric-views/peer-story.tsx | 9 ++-- src/lib/metrics/gap.test.ts | 43 ++++++++++++++++++- src/lib/metrics/gap.ts | 27 +++++++++--- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/components/widgets/metric-views/metric-group-card.tsx b/src/components/widgets/metric-views/metric-group-card.tsx index 0afa9d1..03ef940 100644 --- a/src/components/widgets/metric-views/metric-group-card.tsx +++ b/src/components/widgets/metric-views/metric-group-card.tsx @@ -263,5 +263,5 @@ function headlineSummary(row: CardRow): string { format: metric.format, unit: metric.unit, }); - return `${base} · ${gap} vs median`; + return gap == null ? base : `${base} · ${gap} vs median`; } diff --git a/src/components/widgets/metric-views/peer-story.tsx b/src/components/widgets/metric-views/peer-story.tsx index de29e17..1f7b681 100644 --- a/src/components/widgets/metric-views/peer-story.tsx +++ b/src/components/widgets/metric-views/peer-story.tsx @@ -35,7 +35,8 @@ interface PeerStoryProps { className?: string; } -function formatGap(entry: PeerStoryEntry): string { +/** Null when the gap collapses at display precision — render "at the median". */ +function formatGap(entry: PeerStoryEntry): string | null { return formatGapMagnitude({ value: entry.value, median: entry.stats?.p50 ?? null, @@ -109,7 +110,7 @@ function HeroCard({ · - {Math.abs(entry.gapDelta) <= 1e-9 ? ( + {formatGap(entry) == null ? ( <>at the {cohortLabel} median ) : ( <> @@ -232,7 +233,7 @@ function SideCard({ · - {Math.abs(entry.gapDelta) <= 1e-9 ? ( + {formatGap(entry) == null ? ( <>at the {cohortLabel} median ) : ( <> @@ -281,7 +282,7 @@ function ChipTooltip({ {entry.stats ? ( - {Math.abs(entry.gapDelta) <= 1e-9 + {formatGap(entry) == null ? `at the ${cohortLabel} median ` : `gap ${formatGap(entry)} · ${cohortLabel} median `} {formatMetricValue(entry.stats.p50, entry.format, entry.unit)} diff --git a/src/lib/metrics/gap.test.ts b/src/lib/metrics/gap.test.ts index 524a2ec..b853fcc 100644 --- a/src/lib/metrics/gap.test.ts +++ b/src/lib/metrics/gap.test.ts @@ -14,7 +14,48 @@ describe("formatGapMagnitude", () => { format: "percent", unit: null, }) - ).toBe("-35.0 pp"); + ).toBe("-35 pp"); + }); + + it("computes points from the displayed operands so the arithmetic closes", () => { + // 54.7% renders as 55% and the median as 90% — the gap must reconcile + // with those (55 + 35 = 90), not the raw fractions (35.3). + expect( + formatGapMagnitude({ + value: 54.7, + median: 90, + gapPct: (54.7 - 90) / 90, + gapDelta: 54.7 - 90, + format: "percent", + unit: null, + }) + ).toBe("-35 pp"); + }); + + it("drops the gap when rounding collapses it to zero", () => { + // 89.6% and 90.4% both render as 90% — a "-0.8 pp" beside two equal + // numbers reads as a bug. + expect( + formatGapMagnitude({ + value: 89.6, + median: 90.4, + gapPct: (89.6 - 90.4) / 90.4, + gapDelta: 89.6 - 90.4, + format: "percent", + unit: null, + }) + ).toBeNull(); + // Same for a sub-half-percent relative gap on a count metric. + expect( + formatGapMagnitude({ + value: 1001, + median: 1000, + gapPct: 1 / 1000, + gapDelta: 1, + format: "integer", + unit: "lines", + }) + ).toBeNull(); }); it("uses a multiple far above the median", () => { diff --git a/src/lib/metrics/gap.ts b/src/lib/metrics/gap.ts index e9c968e..a6ee667 100644 --- a/src/lib/metrics/gap.ts +++ b/src/lib/metrics/gap.ts @@ -21,9 +21,9 @@ function formatMultiple(ratio: number): string { return `${rounded}×`; } -function formatGapPct(gap: number): string { +function formatGapPct(gap: number): string | null { const pct = Math.round(Math.abs(gap) * 100); - if (pct === 0) return "0%"; + if (pct === 0) return null; return `${gap >= 0 ? "+" : "-"}${pct}%`; } @@ -39,6 +39,11 @@ export interface GapInput { unit: string | null; } +/** + * Returns `null` when the magnitude would render as zero — a "0 pp" or "0%" + * beside two visibly equal numbers reads as a bug, so callers drop the gap + * text (or say "at the median") instead. + */ export function formatGapMagnitude({ value, median, @@ -46,13 +51,19 @@ export function formatGapMagnitude({ gapDelta, format, unit, -}: GapInput): string { +}: GapInput): string | null { // Percentage metrics diverge in percentage POINTS, not a ratio: a relative // "−39%" beside a "90%" median reads as points and misstates the gap, and a - // multiple ("0.6×") is meaningless. gapDelta is already the point spread — - // render it like the period delta pill. + // multiple ("0.6×") is meaningless. The value and median render as rounded + // percents beside this gap, and points are the one form readers reconcile + // by addition — so the spread is computed from the DISPLAYED (rounded) + // operands, not the raw fractions, to make the on-screen arithmetic close. if (format === "percent") { - return formatPp(gapDelta); + const displayGap = + median != null + ? Math.round(value) - Math.round(median) + : Math.round(gapDelta); + return displayGap === 0 ? null : formatPp(displayGap, 0); } if ( median != null && @@ -62,6 +73,8 @@ export function formatGapMagnitude({ return formatMultiple(value / median); } if (gapPct != null) return formatGapPct(gapPct); + const magnitude = formatMetricValue(Math.abs(gapDelta), format, unit); + if (/^0(?:\.0)?(?:\D|$)/.test(magnitude)) return null; const sign = gapDelta >= 0 ? "+" : "-"; - return `${sign}${formatMetricValue(Math.abs(gapDelta), format, unit)}`; + return `${sign}${magnitude}`; } From 30ebc15b5abc02af8e1a552524a1e679da1eec67 Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 13:51:37 +0200 Subject: [PATCH 09/13] fix(ui): round trend pp deltas to integer on KPI tiles The period-over-period pp badge showed one decimal while the peer-gap footer and the relative-change badge round to integer, mixing precision on the same tile. Round pp trend deltas to whole points. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- src/lib/insight/kpi-row.test.ts | 2 +- src/lib/insight/kpi-row.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/insight/kpi-row.test.ts b/src/lib/insight/kpi-row.test.ts index b8b280c..b90e818 100644 --- a/src/lib/insight/kpi-row.test.ts +++ b/src/lib/insight/kpi-row.test.ts @@ -126,7 +126,7 @@ describe("metricKpiTiles", () => { "me@x.com", "all", ); - expect(tiles[0]?.delta?.text).toBe("+5.0 pp"); + expect(tiles[0]?.delta?.text).toBe("+5 pp"); }); }); diff --git a/src/lib/insight/kpi-row.ts b/src/lib/insight/kpi-row.ts index 6dca7e1..b532a37 100644 --- a/src/lib/insight/kpi-row.ts +++ b/src/lib/insight/kpi-row.ts @@ -129,7 +129,9 @@ function deltaStatus( /** Display-rounded delta; null when it rounds to zero (no "+0%" badges). */ function formatTileDelta(delta: MetricDelta): string | null { if (delta.kind === "pp_change") { - return Math.abs(delta.value) < 0.05 ? null : formatPp(delta.value); + return Math.round(Math.abs(delta.value)) === 0 + ? null + : formatPp(delta.value, 0); } const rounded = Math.round(delta.value); if (rounded === 0) return null; From dcd6f55d9e46223e2e1821ff77cfd062d23f7f5f Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 14:06:53 +0200 Subject: [PATCH 10/13] feat(ui): stabilize group-card previews and empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview rows are the card's fixed identity: keep a configured key with no value on the card as an em dash instead of dropping it, so card shape stays stable across periods. Retune the preview sets — task delivery shows pickup time over reopen rate, git shows code lines over PR cycle time, AI shows accepted lines over acceptance rate. Replace the one-line empty body with a shared shadcn Empty state sized to a populated card, so empty cards keep the grid footprint. Drop the standing badge on empty cards — "no peer data" above "no metrics with data" restated the same absence twice. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- src/components/ui/empty.tsx | 104 ++++++++++++++++++ src/components/widgets/group-card-empty.tsx | 24 ++++ .../metric-views/metric-group-card.test.tsx | 38 +++++++ .../metric-views/metric-group-card.tsx | 51 +++++---- src/components/widgets/v2/section-card.tsx | 38 ++++--- src/lib/insight/groups.ts | 6 +- 6 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 src/components/ui/empty.tsx create mode 100644 src/components/widgets/group-card-empty.tsx diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx new file mode 100644 index 0000000..57d9aec --- /dev/null +++ b/src/components/ui/empty.tsx @@ -0,0 +1,104 @@ +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
        + ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
        + ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
        + ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
        + ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
        a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
        + ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/src/components/widgets/group-card-empty.tsx b/src/components/widgets/group-card-empty.tsx new file mode 100644 index 0000000..04a3def --- /dev/null +++ b/src/components/widgets/group-card-empty.tsx @@ -0,0 +1,24 @@ +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; + +/** + * Empty body for a dashboard group card. `min-h` approximates the populated + * card's content (summary line + three preview rows) so an empty card holds + * the same footprint in the grid instead of collapsing. + */ +export function GroupCardEmpty() { + return ( + + + No data + + No metrics with data for this period. + + + + ); +} diff --git a/src/components/widgets/metric-views/metric-group-card.test.tsx b/src/components/widgets/metric-views/metric-group-card.test.tsx index 2038f1d..e42339f 100644 --- a/src/components/widgets/metric-views/metric-group-card.test.tsx +++ b/src/components/widgets/metric-views/metric-group-card.test.tsx @@ -89,6 +89,44 @@ describe("MetricGroupCard", () => { expect(screen.getByText("3 days")).toBeInTheDocument(); }); + it("shows a single empty state without a standing badge when no metric has data", () => { + render( + , + ); + expect( + screen.getByText("No metrics with data for this period."), + ).toBeInTheDocument(); + // The badge would only restate the absence — one message, not two. + expect(screen.queryByText("no peer data")).not.toBeInTheDocument(); + }); + + it("keeps a fixed preview key with no value, rendering an em dash", () => { + render( + , + ); + // Both preview rows stay on the card — the valueless one shows "—", not + // dropped, so the card's identity is stable across periods. + expect(screen.getByText("ai.active_days")).toBeInTheDocument(); + expect(screen.getByText("ai.cost")).toBeInTheDocument(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + it("keeps the card name and shows a spinner while loading", () => { render( ({ row, rank: row.rank }))); - const evaluated = rankableCount(counts); const status = applyFocusStatus(gradeSectionStanding(counts), focusMode); const badgeText = sectionStandingPhrase(counts); @@ -132,10 +131,14 @@ export function MetricGroupCard({ ? headlineSummary(headlineRow) : "No data for this period."; + // The preview is a FIXED set of keys — the card's stable identity. Keep a + // key even when its value is null (renders "—"); only drop a key the + // response never carried. A present-but-empty metric still belongs on the + // card. const preview = def.card.preview .map((key) => rows.find((row) => row.metric.metric_key === key)) - .filter((row): row is CardRow => row != null && row.value != null); - const isEmpty = evaluated === 0 && preview.length === 0; + .filter((row): row is CardRow => row != null); + const isEmpty = !rows.some((row) => row.value != null); const stripeClass = STATUS_STRIPE_LEFT[status]; return ( @@ -156,27 +159,31 @@ export function MetricGroupCard({ > {def.title} - - {subtitle ? ( - {subtitle} - ) : null} - - - {badgeText} - - + {subtitle || !isEmpty ? ( + + {subtitle ? ( + {subtitle} + ) : null} + {/* An empty card carries no standing — the badge would only + restate the empty state below. */} + {!isEmpty ? ( + + + {badgeText} + + ) : null} + + ) : null} {isEmpty ? ( -

        - No metrics with data for this period. -

        + ) : ( <>

        {summary}

        diff --git a/src/components/widgets/v2/section-card.tsx b/src/components/widgets/v2/section-card.tsx index 62b30ce..1889aed 100644 --- a/src/components/widgets/v2/section-card.tsx +++ b/src/components/widgets/v2/section-card.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { GroupCardEmpty } from "@/components/widgets/group-card-empty"; import { useSettings } from "@/hooks/use-settings"; import { hasBulletValue, @@ -126,24 +127,31 @@ export function SectionCard({ > {title} - - {subtitle ? ( - {subtitle} - ) : null} - - - {badgeText} - - + {subtitle || !isEmpty ? ( + + {subtitle ? ( + {subtitle} + ) : null} + {/* An empty card carries no standing — the badge would only + restate the empty state below. */} + {!isEmpty ? ( + + + {badgeText} + + ) : null} + + ) : null} {isEmpty ? ( -

        - No metrics with data for this period. -

        + ) : ( <>

        {summary}

        diff --git a/src/lib/insight/groups.ts b/src/lib/insight/groups.ts index d7629ae..47bc759 100644 --- a/src/lib/insight/groups.ts +++ b/src/lib/insight/groups.ts @@ -292,7 +292,7 @@ export const GROUPS: readonly GroupDef[] = [ title: "Task delivery", collection: TASK_DELIVERY_COLLECTION, card: { - preview: ["tasks.closed", "tasks.resolution_time", "tasks.reopen_rate"], + preview: ["tasks.closed", "tasks.resolution_time", "tasks.pickup_time"], }, drilldown: [ { @@ -319,7 +319,7 @@ export const GROUPS: readonly GroupDef[] = [ title: "Git output", collection: GIT_OUTPUT_COLLECTION, card: { - preview: ["git.commits", "git.prs_merged", "git.pr_cycle_time_h"], + preview: ["git.commits", "git.prs_merged", "git.code_lines"], }, drilldown: [ { @@ -376,7 +376,7 @@ export const GROUPS: readonly GroupDef[] = [ title: "AI adoption", collection: AI_ADOPTION_COLLECTION, card: { - preview: ["ai.active_days", "ai.cost", "ai.tool_acceptance_rate"], + preview: ["ai.active_days", "ai.accepted_lines", "ai.cost"], }, drilldown: [ { From 3d59c4d88bf35a78f761abeef607c88087f8a40d Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 14:26:05 +0200 Subject: [PATCH 11/13] refactor(ui): give each drilldown distribution its own card Histograms self-card like the trend and breakdown charts and drop straight into the chart grid, replacing the single wrapping "Distributions" card and its nested borders. Empty charts render a shared shadcn Empty placeholder sized to the plot area, so an empty chart keeps its neighbours' footprint instead of collapsing. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- .../widgets/metric-views/chart-empty.tsx | 24 +++ .../collection-drilldown.test.tsx | 6 +- .../metric-views/collection-drilldown.tsx | 46 ++---- .../metric-views/metric-breakdown.test.tsx | 2 +- .../widgets/metric-views/metric-breakdown.tsx | 9 +- .../widgets/metric-views/metric-histogram.tsx | 152 ++++++++++-------- .../metric-views/metric-trend.test.tsx | 2 +- .../widgets/metric-views/metric-trend.tsx | 9 +- 8 files changed, 138 insertions(+), 112 deletions(-) create mode 100644 src/components/widgets/metric-views/chart-empty.tsx diff --git a/src/components/widgets/metric-views/chart-empty.tsx b/src/components/widgets/metric-views/chart-empty.tsx new file mode 100644 index 0000000..a139b0e --- /dev/null +++ b/src/components/widgets/metric-views/chart-empty.tsx @@ -0,0 +1,24 @@ +import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; +import { cn } from "@/lib/utils"; + +/** + * Placeholder for a chart with no data — centers the message in the plot area + * so an empty chart keeps its neighbours' footprint instead of collapsing to + * a line of text. No frame of its own: the enclosing card is the border. + * `className` carries the plot height so it matches the chart it stands in for. + */ +export function ChartEmpty({ + message, + className, +}: { + message: string; + className?: string; +}) { + return ( + + + {message} + + + ); +} diff --git a/src/components/widgets/metric-views/collection-drilldown.test.tsx b/src/components/widgets/metric-views/collection-drilldown.test.tsx index a489521..d0b26cb 100644 --- a/src/components/widgets/metric-views/collection-drilldown.test.tsx +++ b/src/components/widgets/metric-views/collection-drilldown.test.tsx @@ -114,9 +114,9 @@ describe("CollectionDrilldown", () => { entityId="alice@example.com" />, ); - // Histograms live in a single labeled "Distributions" card, shaded vs the - // peer median (the subtitle proves the diverging render, not a bare chart). - expect(screen.getByText("Distributions")).toBeInTheDocument(); + // Each histogram is its own card in the grid (no wrapping "Distributions" + // card), shaded vs the peer median (the subtitle proves the diverging + // render, not a bare chart). expect(screen.getByText(/vs peer median/)).toBeInTheDocument(); }); diff --git a/src/components/widgets/metric-views/collection-drilldown.tsx b/src/components/widgets/metric-views/collection-drilldown.tsx index f7698e9..43f3842 100644 --- a/src/components/widgets/metric-views/collection-drilldown.tsx +++ b/src/components/widgets/metric-views/collection-drilldown.tsx @@ -1,10 +1,4 @@ import { ComingSoon } from "@/components/widgets/coming-soon"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { MetricBreakdown } from "@/components/widgets/metric-views/metric-breakdown"; import { MetricHistogram } from "@/components/widgets/metric-views/metric-histogram"; @@ -183,30 +177,22 @@ export function CollectionDrilldown({ ) : null} {distributionMetrics.length > 0 ? ( - // One labeled card holds every distribution; the charts inside are - // un-carded so there are no nested borders and "distribution" isn't - // repeated per chart. - - - - Distributions - - - 1 && "lg:grid-cols-2", - )} - > - {distributionMetrics.map((metric) => ( - - ))} - - + // Each histogram is its own card, dropped straight into the grid like + // the chart blocks above — no wrapping "Distributions" card. +
        1 && "lg:grid-cols-2", + )} + > + {distributionMetrics.map((metric) => ( + + ))} +
        ) : null}
        ); diff --git a/src/components/widgets/metric-views/metric-breakdown.test.tsx b/src/components/widgets/metric-views/metric-breakdown.test.tsx index 717e2ec..d7fa2d2 100644 --- a/src/components/widgets/metric-views/metric-breakdown.test.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.test.tsx @@ -61,6 +61,6 @@ describe("MetricBreakdown", () => { entityId="me@x.com" />, ); - expect(screen.getByText("No composition data yet.")).toBeInTheDocument(); + expect(screen.getByText("No composition data yet")).toBeInTheDocument(); }); }); diff --git a/src/components/widgets/metric-views/metric-breakdown.tsx b/src/components/widgets/metric-views/metric-breakdown.tsx index 6778aa4..04d6f6f 100644 --- a/src/components/widgets/metric-views/metric-breakdown.tsx +++ b/src/components/widgets/metric-views/metric-breakdown.tsx @@ -5,6 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty"; import { dimensionColorSeed, dimensionLabel, @@ -38,11 +39,13 @@ export function MetricBreakdown({ metric, entityId }: MetricBreakdownProps) { if (rows.length === 0) { return ( - - + + {metric.label} - No composition data yet. + + + ); } diff --git a/src/components/widgets/metric-views/metric-histogram.tsx b/src/components/widgets/metric-views/metric-histogram.tsx index a32a675..851b5b6 100644 --- a/src/components/widgets/metric-views/metric-histogram.tsx +++ b/src/components/widgets/metric-views/metric-histogram.tsx @@ -11,6 +11,8 @@ import { YAxis, type ChartConfig, } from "@/components/ui/chart"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty"; import { formatMetricNumber } from "@/lib/format"; import { forEntity, type NormalizedMetricResult } from "@/lib/metrics/collection"; import { STATUS_COLOR_VAR, statusVsMedian, type Status } from "@/lib/status"; @@ -106,90 +108,98 @@ export function MetricHistogram({ metric, entityId }: MetricHistogramProps) { // Shared header so an empty tile reads as the same chart, laid out to match // its populated neighbours in the grid rather than collapsing to a corner. const header = ( -
        -
        - {metric.label} - - {[ - direction, - peerMedian != null - ? `vs peer median ${formatMetricNumber(peerMedian, metric.format)}${unit}` - : null, - ] - .filter(Boolean) - .join(" · ")} - -
        - {ownMedian != null ? ( - - Median{" "} - - {formatMetricNumber(ownMedian, metric.format)} - {unit} + +
        +
        + {metric.label} + + {[ + direction, + peerMedian != null + ? `vs peer median ${formatMetricNumber(peerMedian, metric.format)}${unit}` + : null, + ] + .filter(Boolean) + .join(" · ")} + +
        + {ownMedian != null ? ( + + Median{" "} + + {formatMetricNumber(ownMedian, metric.format)} + {unit} + - - ) : null} -
        + ) : null} +
        +
        ); if (bins.length === 0) { return ( -
        + {header} -
        - - No values in this period - -
        -
        + + + +
        ); } const { rows, pivotLabel } = buildRows(bins, peerMedian, metric); return ( -
        + {header} - - - - - - - } /> - - {rows.map((row) => ( - - ))} - - {pivotLabel ? ( - // The peer median: named in the subtitle ("vs peer median …") and - // the only dashed line on the chart, so it needs no on-chart label - // (which clipped at the top edge). - + + + - ) : null} - - -
        + + + } /> + + {rows.map((row) => ( + + ))} + + {pivotLabel ? ( + // The peer median: named in the subtitle ("vs peer median …") + // and the only dashed line on the chart, so it needs no + // on-chart label (which clipped at the top edge). + + ) : null} + + + + ); } diff --git a/src/components/widgets/metric-views/metric-trend.test.tsx b/src/components/widgets/metric-views/metric-trend.test.tsx index a687698..565cda7 100644 --- a/src/components/widgets/metric-views/metric-trend.test.tsx +++ b/src/components/widgets/metric-views/metric-trend.test.tsx @@ -70,7 +70,7 @@ describe("MetricTrend", () => { chart="line" />, ); - expect(screen.getByText("No trend data yet.")).toBeInTheDocument(); + expect(screen.getByText("No trend data yet")).toBeInTheDocument(); }); it("puts each part's total and share in the legend for a composition", () => { diff --git a/src/components/widgets/metric-views/metric-trend.tsx b/src/components/widgets/metric-views/metric-trend.tsx index cbbee14..90da5ee 100644 --- a/src/components/widgets/metric-views/metric-trend.tsx +++ b/src/components/widgets/metric-views/metric-trend.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { ChartEmpty } from "@/components/widgets/metric-views/chart-empty"; import { BarChart, CartesianGrid, @@ -210,11 +211,13 @@ export function MetricTrend({ metrics, entityId, chart }: MetricTrendProps) { if (data.length === 0 || series.length === 0) { return ( - - + + {title} - No trend data yet. + + + ); } From 59f5bdf6db30a1cb0cf02c41cc2c4f40c2700dff Mon Sep 17 00:00:00 2001 From: Aleksandr Barkhatov Date: Wed, 15 Jul 2026 14:27:55 +0200 Subject: [PATCH 12/13] fix(ui): make empty group cards non-interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty card has no drilldown to open, so render it as a plain div instead of a button — not focusable, no click target, no hover affordance. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Aleksandr Barkhatov --- .../metric-views/metric-group-card.test.tsx | 2 ++ .../metric-views/metric-group-card.tsx | 20 +++++++++++-------- src/components/widgets/v2/section-card.tsx | 20 +++++++++++-------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/components/widgets/metric-views/metric-group-card.test.tsx b/src/components/widgets/metric-views/metric-group-card.test.tsx index e42339f..6ff1f17 100644 --- a/src/components/widgets/metric-views/metric-group-card.test.tsx +++ b/src/components/widgets/metric-views/metric-group-card.test.tsx @@ -106,6 +106,8 @@ describe("MetricGroupCard", () => { ).toBeInTheDocument(); // The badge would only restate the absence — one message, not two. expect(screen.queryByText("no peer data")).not.toBeInTheDocument(); + // Nothing to drill into — the card is not an interactive button. + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("keeps a fixed preview key with no value, rendering an em dash", () => { diff --git a/src/components/widgets/metric-views/metric-group-card.tsx b/src/components/widgets/metric-views/metric-group-card.tsx index 46159dc..c87be9c 100644 --- a/src/components/widgets/metric-views/metric-group-card.tsx +++ b/src/components/widgets/metric-views/metric-group-card.tsx @@ -143,17 +143,21 @@ export function MetricGroupCard({ return ( + isEmpty ? undefined : ( +