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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 7 additions & 75 deletions src/lib/insight/attention.test.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
import { describe, expect, it } from "vitest";

import type { CatalogMetric } from "@/api/catalog-client";
import type { MetricResult } from "@/api/metric-results-client";
import {
legacyAttentionItems,
metricAttentionItems,
} from "@/lib/insight/attention";
import { metricAttentionItems } from "@/lib/insight/attention";
import type { MetricGroup } from "@/lib/insight/groups";
import { normalizeMetricResults } from "@/lib/metrics/collection";
import type { BulletMetric } from "@/types/insight";

function bullet(overrides: Partial<BulletMetric> = {}): BulletMetric {
return {
metric_key: "meeting_hours",
label: "Meeting hours",
value: "22",
unit: "h",
peer: { p25: 4, p50: 8, p75: 12, min: 1, max: 30, n: 9 },
...overrides,
} as BulletMetric;
}

const CATALOG_ROW = {
higher_is_better: false,
} as unknown as CatalogMetric;

function aiMetric(value: number | null): MetricResult {
return {
Expand Down Expand Up @@ -67,41 +47,6 @@ const AI_DEF: MetricGroup = {
drilldown: [],
};

describe("legacyAttentionItems", () => {
it("surfaces bottom-quartile rows with a display-ready shape", () => {
const items = legacyAttentionItems(
[{ id: "collaboration", rows: [bullet()] }],
() => CATALOG_ROW,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
group: "collaboration",
label: "Meeting hours",
valueText: "22 h",
medianText: "8 h",
gapText: "2.8×",
});
expect(items[0]?.relGap).toBeGreaterThan(0);
});

it("skips schema errors, non-numeric values, and in-pack rows", () => {
const items = legacyAttentionItems(
[
{
id: "collaboration",
rows: [
bullet({ schema_error: true }),
bullet({ value: "—" }),
bullet({ value: "8" }),
],
},
],
() => CATALOG_ROW,
);
expect(items).toHaveLength(0);
});
});

describe("metricAttentionItems", () => {
it("surfaces bottom-quartile metrics with the same item shape", () => {
const byKey = normalizeMetricResults([aiMetric(2)]);
Expand All @@ -126,8 +71,8 @@ describe("metricAttentionItems", () => {
metricAttentionItems(
AI_DEF,
normalizeMetricResults([unmeasured]),
"me@x.com",
),
"me@x.com"
)
).toHaveLength(0);
});

Expand All @@ -136,28 +81,15 @@ describe("metricAttentionItems", () => {
metricAttentionItems(
AI_DEF,
normalizeMetricResults([aiMetric(10)]),
"me@x.com",
),
"me@x.com"
)
).toHaveLength(0);
expect(
metricAttentionItems(
AI_DEF,
normalizeMetricResults([aiMetric(null)]),
"me@x.com",
),
"me@x.com"
)
).toHaveLength(0);
});

it("produces the identical intermediate shape as the legacy selector", () => {
const legacy = legacyAttentionItems(
[{ id: "collaboration", rows: [bullet()] }],
() => CATALOG_ROW,
)[0]!;
const metric = metricAttentionItems(
AI_DEF,
normalizeMetricResults([aiMetric(2)]),
"me@x.com",
)[0]!;
expect(Object.keys(legacy).sort()).toEqual(Object.keys(metric).sort());
});
});
63 changes: 2 additions & 61 deletions src/lib/insight/attention.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import { formatMetricValue } from "@/lib/format";
import { formatGapMagnitude } from "@/lib/metrics/gap";
import type { MetricGroup, GroupId } from "@/lib/insight/groups";
import {
bulletCatalogKey,
type CatalogByKey,
} from "@/lib/insight/v2/peer-status";
import {
forEntity,
type NormalizedMetricResult,
} from "@/lib/metrics/collection";
import { toPeerStats } from "@/lib/metrics/peer-standing";
import { peerStatusVsQuartiles } from "@/lib/peers";
import type { BulletMetric } from "@/types/insight";

/**
* One "needs attention" row: a metric sitting in the bottom quartile of its
* cohort, display-ready. Selectors below feed the shared surface from both
* data paths; ranking (`relGap` descending) happens in the component.
* cohort, display-ready. Ranking (`relGap` descending) happens in the
* component.
*/
export interface AttentionItem {
key: string;
Expand All @@ -30,60 +25,6 @@ export interface AttentionItem {
relGap: number;
}

export interface LegacyAttentionGroup {
id: GroupId;
rows: BulletMetric[];
}

/** Legacy bullet rows + catalog direction → attention items. */
export function legacyAttentionItems(
groups: LegacyAttentionGroup[],
byMetricKey: CatalogByKey
): AttentionItem[] {
const items: AttentionItem[] = [];
for (const group of groups) {
for (const row of group.rows) {
// schema_status='error' rows never trigger the attention surface — we
// can't compare a broken metric to peers. Missing-id rows likewise
// collapse out (no catalog row → no higher_is_better signal).
if (row.schema_error) continue;
const value = Number(row.value);
if (!Number.isFinite(value)) continue;
const stats = row.peer;
if (!stats) continue;
const catalogRow = byMetricKey(bulletCatalogKey(row));
if (!catalogRow) continue;
const higherIsBetter = catalogRow.higher_is_better;
if (peerStatusVsQuartiles(value, stats, higherIsBetter) !== "bottom") {
continue;
}
const median = stats.p50;
const denom = Math.abs(median) > 1e-9 ? Math.abs(median) : 1;
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,
});
}
}
return items;
}

/** Metric-collection results → attention items; direction rides the wire. */
export function metricAttentionItems(
def: MetricGroup,
Expand Down
38 changes: 5 additions & 33 deletions src/lib/insight/kpi-row.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { describe, expect, it } from "vitest";

import type { MetricResult } from "@/api/metric-results-client";
import { kpiRowTiles, metricKpiTiles } from "@/lib/insight/kpi-row";
import { KPI_ROW } from "@/lib/insight/groups";
import { metricKpiTiles } from "@/lib/insight/kpi-row";
import { normalizeMetricResults } from "@/lib/metrics/collection";

function metricResult(
key: string,
value: number | null,
overrides: Partial<MetricResult> = {},
overrides: Partial<MetricResult> = {}
): MetricResult {
return {
metric_key: key,
Expand Down Expand Up @@ -87,7 +86,7 @@ describe("metricKpiTiles", () => {
normalizeMetricResults([result]),
null,
"me@x.com",
"all",
"all"
);
expect(tiles[0]?.valueStatus).toBe("neutral");
expect(tiles[0]?.medianLabel).toBeNull();
Expand All @@ -103,7 +102,7 @@ describe("metricKpiTiles", () => {
normalizeMetricResults([result]),
null,
"me@x.com",
"all",
"all"
);
expect(tiles[0]?.valueStatus).toBe("neutral");
});
Expand All @@ -124,35 +123,8 @@ describe("metricKpiTiles", () => {
normalizeMetricResults([current]),
normalizeMetricResults([previous]),
"me@x.com",
"all",
"all"
);
expect(tiles[0]?.delta?.text).toBe("+5 pp");
});
});

describe("kpiRowTiles", () => {
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),
]),
null,
"me@x.com",
"all",
);
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),
);
expect(ordered).toEqual(expected);
expect(ordered).toEqual([
"tasks.closed",
"git.prs_merged",
"ai.active_days",
]);
});
});
Loading