diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx
index d34a491e64c..3ef33750aeb 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx
@@ -68,13 +68,12 @@ export const settingsSection: DocSectionDef = {
-
- Excludes values falling significantly outside the normal
- distribution. This is done by computing the interquartile range
- (IQR), i.e. the values falling between 25% and 75% of
- observations, and then excluding any values which are greater than
- 1.5x IQR above the third quartile or less than 1.5x IQR below the
- first quartile (aka Tukey fences).
+
+ Excludes outliers from every mean shown. Specifically, this
+ excludes values outside the Tukey fences (removing values which
+ fall greater than 1.5x the interquartile range below Q1 or greater
+ than 1.5x the interquartile range above Q3). Does not affect the
+ median, percentiles (P75, P95) or minimum and maximum.
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx
index a4872a53c22..eeadd6c4260 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx
@@ -137,7 +137,7 @@ export const siteOverviewSection: DocSectionDef = {
Dwell rows remain visible even when Exclude low samples is on,
because a small number of high-value waits can still explain real
- carrying cost. Low-sample dwell rows are labelled.
+ carrying cost. Low and limited-sample dwell rows are labelled.
Exclude low samples — hide Planning and Trend
- rows with fewer than 10 observations, so rankings are not
- dominated by noisy single-event steps.
+ rows with fewer than 5 observations. Rows with 5–9
+ observations remain visible with a "limited" sample badge.
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx
index d6699c1ac79..ae1eea4b22a 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx
@@ -82,11 +82,11 @@ export const stepDetailDoc: DocEntry = {
Time range buttons (3m, 6m, 12) filter the panel.
- Outlier count appears in the header when the Exclude
- outliers setting removes timing observations from the current step.
- See{" "}
+ Outlier count appears in the header when values are
+ excluded from the mean. The raw observations and percentile statistics
+ remain unchanged. See{" "}
- Exclude outliers
+ Exclude outliers from mean
.
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx
index d824c7e4fbb..5ed2a08b2cf 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx
@@ -8,19 +8,18 @@ export const qaDoc: DocEntry = {
render: () => (
<>
- QA hold measures the time a finished batch waits between production
+ QA hold measures the time a production campaign waits between production
completion and QA release — the quality inspection and hold
period.
- What it measures: production receipt (the batch is complete
- and received into inventory) to QA release (the batch passes inspection
- and is cleared for use). One observation per finished-good batch.
+ What it measures: the time between a production campaign
+ finishing and the associated QA release. The full 'data' table also
+ shows the time between each batch's production finish and QA release.
- Time filtering: observations are anchored to the
- production-receipt date, so the selected window picks batches that
- completed production inside that period.
+ Time filtering: observations are anchored to the campaign
+ end date.
The wait that follows QA release — from release to dispatch
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx
new file mode 100644
index 00000000000..832710ebf02
--- /dev/null
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { OutlierContext } from "./cost";
+import { AnalysisSettingsPanel } from "./header-actions";
+import { MeasureContext } from "./measure-context";
+import { TimeRangeContext } from "./time-range-context";
+
+vi.mock("@hashintel/ds-components", () => ({
+ Icon: () => null,
+ NumberInput: () => ,
+}));
+
+describe("AnalysisSettingsPanel", () => {
+ afterEach(cleanup);
+
+ it("allows the mean outlier policy to change under non-mean headline measures", () => {
+ const setExcludeOutliers = vi.fn();
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ const checkbox = screen.getByRole("checkbox", {
+ name: "Exclude outliers from mean",
+ });
+ expect(checkbox).toHaveProperty("checked", true);
+ expect(checkbox).toHaveProperty("disabled", false);
+ fireEvent.click(checkbox);
+ expect(setExcludeOutliers).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
index 886ad821cf8..c7172358853 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx
@@ -293,7 +293,7 @@ export const AnalysisSettingsPanel = ({
checked={excludeOutliers}
onChange={(event) => setExcludeOutliers(event.target.checked)}
/>
- Exclude outliers
+ Exclude outliers from mean
{children}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts
index f77d859aad5..717134a1c37 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts
@@ -55,20 +55,20 @@ describe("node R:/C: badge recompute from shipped series", () => {
expect(windowed.yield_summary?.median).toBe(88);
});
- it("drops outliers from the yield series under the outlier rule", () => {
+ it("does not apply the timing mean rule to yield observations", () => {
const withOutlier: NodeYieldSeries = {
reference: 95,
observations: [...yieldSeries.observations, obs("2026-05", 5)],
};
const node = makeNode({ type: "production", yield_series: withOutlier });
const selected = applyOutlierSelectionToNode(node, true);
- // The 5% point is far below the others' IQR fence and is dropped.
+ // Yield remains raw: the setting applies only to timing means.
expect(
selected.yield_series?.observations.some(
(observation) => observation.value === 5,
),
- ).toBe(false);
+ ).toBe(true);
const windowed = windowGraphNodeToRange(selected, "12m");
- expect(windowed.yield_summary?.n).toBe(4);
+ expect(windowed.yield_summary?.n).toBe(5);
});
});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts b/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts
index a968a9cf1d8..4f46efb2c1e 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts
@@ -104,10 +104,8 @@ function buildMonthlyFromObservations(obs: Observation[]): MonthlyBucket[] {
}
/**
- * Single-source records derive. When a step ships only the canonical
- * `detail_rows` (+ `value_col`/`ref_date_col`) and no precomputed timing series,
- * rehydrate observations/durations/monthly/stats from the rows. A strict no-op
- * whenever `observations` are already present.
+ * Single-source records derive. Campaign rows are the canonical timing source
+ * when present; detail rows remain batch evidence and the v1.2 fallback.
*/
function ensureTimingSeriesStats<
T extends {
@@ -223,13 +221,15 @@ function fillMonthlyTiming(
export function deriveTimingFromRecords(step: StepDetail): StepDetail;
export function deriveTimingFromRecords(step: StepDetailWire): StepDetailWire;
export function deriveTimingFromRecords(step: StepDetailWire): StepDetailWire {
+ const campaignRows =
+ step.timing_grain === "campaign" ? step.campaign_rows?.rows : undefined;
const existingObservations = step.observations ?? [];
- if (existingObservations.length > 0) {
+ if (!campaignRows?.length && existingObservations.length > 0) {
return step;
}
const valueCol = step.value_col;
const dateCol = step.ref_date_col;
- const rows = step.detail_rows?.rows;
+ const rows = campaignRows?.length ? campaignRows : step.detail_rows?.rows;
if (!valueCol || !dateCol || !rows || rows.length === 0) {
return step;
}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts
new file mode 100644
index 00000000000..2391eb1de30
--- /dev/null
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+
+import { countNoun, countTooltip, shortCountLabel } from "./observation-labels";
+
+describe("campaign observation labels", () => {
+ const qaCampaign = {
+ id: "prod_to_qa",
+ label: "Production to QA",
+ type: "qa_hold" as const,
+ timingGrain: "campaign" as const,
+ };
+
+ it("labels campaign-grain QA timing observations as campaigns", () => {
+ expect(countNoun(qaCampaign)).toBe("campaigns");
+ expect(shortCountLabel(7, qaCampaign)).toBe("7 campaigns");
+ expect(
+ countTooltip({ ...qaCampaign, count: 7, rangeLabel: "12m" }),
+ ).toContain("7 campaigns in the last 12 months");
+ });
+
+ it("keeps yield and consumption terminology ahead of timing grain", () => {
+ expect(countNoun({ ...qaCampaign, dimension: "yield" })).toBe(
+ "production orders",
+ );
+ expect(
+ countNoun({
+ ...qaCampaign,
+ dimension: "consumption",
+ selectedComponent: true,
+ }),
+ ).toBe("component events");
+ });
+});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts
index 8e10e990f10..3fa08ace2ca 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts
@@ -8,12 +8,14 @@ interface CountContext {
type: StepType;
dimension?: CountDimension;
selectedComponent?: boolean;
+ timingGrain?: "campaign" | null;
}
interface CountTooltipContext extends CountContext {
count: number;
rangeLabel?: string | null;
nBatches?: number | null;
+ nCampaigns?: number | null;
nMovements?: number | null;
}
@@ -34,6 +36,9 @@ export function countNoun(ctx: CountContext): string {
if (ctx.dimension === "supplier") {
return "schedule lines";
}
+ if (ctx.timingGrain === "campaign") {
+ return "campaigns";
+ }
return "events";
}
@@ -80,7 +85,9 @@ export function dateAnchorLabel(ctx: CountContext): string {
case "production":
return "schedule start";
case "qa_hold":
- return "production receipt date";
+ return ctx.timingGrain === "campaign"
+ ? "campaign reference date"
+ : "production receipt date";
case "post_qa_ship":
return "QA release date";
case "transit":
@@ -114,7 +121,9 @@ function eventMethodology(ctx: CountContext): string {
case "production":
return "each production schedule campaign contributes one duration event.";
case "qa_hold":
- return "each finished-good batch contributes one QA-hold event.";
+ return ctx.timingGrain === "campaign"
+ ? "each campaign contributes one timing observation; batch rows are supporting evidence."
+ : "each finished-good batch contributes one QA-hold event.";
case "post_qa_ship":
return "each dispatch (customer delivery or hub transfer) contributes one post-QA dwell event.";
case "transit":
@@ -130,6 +139,9 @@ export function countTooltip(ctx: CountTooltipContext): string {
const label = shortCountLabel(ctx.count, ctx);
const period = rangeLabel(ctx.rangeLabel);
const base = `${label} in ${period}. Filtered by ${dateAnchorLabel(ctx)}; ${eventMethodology(ctx)}`;
+ if (ctx.nCampaigns != null && ctx.nBatches != null) {
+ return `${base} All-time source coverage: ${ctx.nCampaigns} campaigns across ${ctx.nBatches} batches.`;
+ }
if (ctx.nBatches != null && ctx.nMovements != null) {
return `${base} All-time source coverage: ${ctx.nBatches} batches, ${ctx.nMovements} movements.`;
}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts
index 4ec3447672c..0bda8047dff 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts
@@ -10,10 +10,10 @@ import {
applyOutlierSelectionToNode,
applyOutlierSelectionToStep,
} from "./outlier-selection";
+import { computeStats } from "./stats";
-// Characterization tests for the client-side Tukey 1.5x IQR outlier rule.
-// Behaviour pinned here MUST survive the contract refactor (the selected-view
-// semantics stay even as the duplicated outlier paths were collapsed).
+// Regression tests for mean-only client-side Tukey 1.5x IQR filtering.
+// Raw observations, counts, and percentile statistics remain unchanged.
describe("applyOutlierSelectionToStep", () => {
// One clear high outlier (100) far beyond the Tukey upper fence.
@@ -30,10 +30,14 @@ describe("applyOutlierSelectionToStep", () => {
obs("2026-04", 100),
]);
- it("drops out-of-fence points and recomputes stats when excluding outliers", () => {
+ it("filters only the mean while retaining raw observations and percentiles", () => {
const selected = applyOutlierSelectionToStep(withOutlier(), true);
- expect(selected.durations).toEqual([10, 11, 12, 13, 14, 15, 16, 17]);
- expect(selected.stats.n).toBe(8);
+ expect(selected.durations).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 100]);
+ expect(selected.observations).toHaveLength(9);
+ expect(selected.stats.n).toBe(9);
+ expect(selected.stats.mean).toBe(13.5);
+ expect(selected.stats.p95).toBe(withOutlier().stats.p95);
+ expect(selected.stats.max).toBe(100);
expect(selected.excluded_count).toBe(1);
expect(selected.excluded_pct).toBeCloseTo(100 / 9, 1);
});
@@ -82,12 +86,13 @@ describe("applyOutlierSelectionToStep", () => {
// Headline untouched, secondary loses its outlier.
expect(selected.stats.n).toBe(4);
expect(selected.excluded_count).toBe(0);
- expect(selected.complete_timing?.stats.n).toBe(8);
+ expect(selected.complete_timing?.stats.n).toBe(9);
+ expect(selected.complete_timing?.stats.max).toBe(200);
expect(
selected.complete_timing?.observations.map(
(observation) => observation.value,
),
- ).not.toContain(200);
+ ).toContain(200);
});
it("leaves complete_timing untouched when including outliers", () => {
@@ -102,9 +107,6 @@ describe("applyOutlierSelectionToStep", () => {
});
describe("applyOutlierSelectionToNode", () => {
- // Shipped v1 data carries a single base series (no raw_*/filtered_*), so today
- // both toggle states return the base series untouched. The fixture uses a tight
- // distribution so this invariant also holds once Tukey IQR filtering lands.
it("returns the base series when including outliers", () => {
const out = applyOutlierSelectionToNode(makeNode(), false);
expect(out.observations?.map((observation) => observation.value)).toEqual([
@@ -118,4 +120,27 @@ describe("applyOutlierSelectionToNode", () => {
10, 12, 11, 13,
]);
});
+
+ it("keeps raw P95 while filtering an outlier from the mean", () => {
+ const observations = [
+ obs("2026-01", 10),
+ obs("2026-01", 11),
+ obs("2026-02", 12),
+ obs("2026-02", 13),
+ obs("2026-03", 14),
+ obs("2026-03", 15),
+ obs("2026-04", 16),
+ obs("2026-04", 17),
+ obs("2026-04", 100),
+ ];
+ const node = makeNode({
+ observations,
+ stats: computeStats(observations.map((observation) => observation.value)),
+ });
+ const selected = applyOutlierSelectionToNode(node, true);
+
+ expect(selected.stats.p95).toBe(node.stats.p95);
+ expect(selected.observations).toHaveLength(9);
+ expect(selected.mean_observations).toHaveLength(8);
+ });
});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts
index 84f6d2f3e49..a2bdb6f54b1 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts
@@ -1,15 +1,11 @@
import { computeIqrFences, partitionByFences } from "./outlier-selection/iqr";
-import { computeStats, percentileOf, round } from "./stats";
+import { computeStats, round } from "./stats";
import type {
GraphNode,
StepDetail,
Observation,
MonthlyBucket,
- StepStats,
- YieldData,
- ConsumptionData,
- ComponentConsumption,
TimingSeries,
} from "./types";
@@ -17,17 +13,15 @@ import type {
* Apply the client-side Tukey 1.5x IQR outlier rule to a graph node.
*
* When `excludeOutliers` is true, fences are computed from the shipped
- * `observations`, out-of-bound points are dropped, and `stats`/`monthly` are
- * recomputed from the kept series (cost columns on each monthly bucket are
- * preserved -- inventory kg-days are independent of duration outliers).
+ * `observations`, and only the overall/monthly mean is recomputed from kept
+ * points. Raw observations, sample size and percentile statistics are retained.
* `excluded_count`/`excluded_pct` describe the full-series exclusion. When
* false (or with too few points), the base series is returned unchanged.
*/
/**
- * Recompute per-month timing values (mean/median/n) from a kept observation set,
- * preserving each bucket's non-timing columns (kg-days, qty, variance). Months
- * with no kept observations keep their bucket with null timing and `n = 0`.
+ * Recompute only the per-month mean from kept observations. Median, sample size,
+ * percentile inputs and non-timing columns remain raw.
*/
function rebuildMonthlyTiming(
original: MonthlyBucket[],
@@ -49,7 +43,7 @@ function rebuildMonthlyTiming(
return original.map((bucket) => {
const vals = byMonth.get(bucket.month);
if (!vals || vals.length === 0) {
- return { ...bucket, mean: null, median: null, n: 0 };
+ return { ...bucket, mean: null };
}
const sorted = [...vals].sort((left, right) => left - right);
return {
@@ -57,24 +51,10 @@ function rebuildMonthlyTiming(
mean: round(
sorted.reduce((left, right) => left + right, 0) / sorted.length,
),
- median: round(percentileOf(sorted, 50)),
- n: sorted.length,
};
});
}
-/** Drop Tukey-IQR outliers from a bare observation array (over its own values). */ function outlierFilterObservations(
- obs: Observation[],
-): Observation[] {
- if (obs.length === 0) {
- return obs;
- }
- const { kept } = partitionByFences(
- obs,
- computeIqrFences(obs.map((observation) => observation.value)),
- );
- return kept;
-}
/**
* A series shaped like the per-family blocks shipped on a step
* (`yield_data`, `consumption_data.aggregate`, each consumption component):
@@ -98,8 +78,11 @@ function rebuildMonthlyTiming(
);
if (excluded.length > 0) {
timing = {
- stats: computeStats(kept.map((observation) => observation.value)),
- observations: kept,
+ mean_observations: kept,
+ stats: {
+ ...node.stats,
+ mean: computeStats(kept.map((observation) => observation.value)).mean,
+ },
monthly: node.monthly
? rebuildMonthlyTiming(node.monthly, kept)
: node.monthly,
@@ -110,22 +93,6 @@ function rebuildMonthlyTiming(
return {
...node,
...timing,
- yield_series: node.yield_series
- ? {
- ...node.yield_series,
- observations: outlierFilterObservations(
- node.yield_series.observations,
- ),
- }
- : node.yield_series,
- consumption_series: node.consumption_series
- ? {
- ...node.consumption_series,
- observations: outlierFilterObservations(
- node.consumption_series.observations,
- ),
- }
- : node.consumption_series,
excluded_count: excludedCount,
excluded_pct:
observations.length > 0
@@ -133,60 +100,10 @@ function rebuildMonthlyTiming(
: 0,
};
}
-interface SeriesLike {
- values: number[];
- observations: Observation[];
- monthly: MonthlyBucket[];
- stats: StepStats;
-}
-/**
- * Drop Tukey-IQR outliers from one family series (computed over its own value
- * distribution) and recompute `values`/`stats`/`monthly` from the kept points.
- * Returns the series unchanged when there is nothing to exclude. Non-timing
- * monthly columns (kg-days, qty, variance) are preserved via the spread in
- * {@link rebuildMonthlyTiming}.
- */ function applyOutlierToSeries(series: T): T {
- const observations = series.observations;
- if (observations.length === 0) {
- return series;
- }
- const { kept, excluded } = partitionByFences(
- observations,
- computeIqrFences(observations.map((observation) => observation.value)),
- );
- if (excluded.length === 0) {
- return series;
- }
- const values = kept.map((observation) => observation.value);
- return {
- ...series,
- values,
- observations: kept,
- monthly: rebuildMonthlyTiming(series.monthly, kept),
- stats: computeStats(values),
- };
-}
-/** Outlier-filter the yield series (receipt-ratio %) in place of its raw points. */ function applyOutlierToYield(
- yd: YieldData,
-): YieldData {
- return applyOutlierToSeries(yd);
-}
-/**
- * Outlier-filter the consumption block: each component series and the aggregate
- * series are partitioned independently. Downstream windowing recomputes the
- * aggregate `weighted_variance_pct` from whatever observations remain, so
- * dropping outlier points here makes that window-aware metric outlier-aware too.
- */ function applyOutlierToConsumption(cd: ConsumptionData): ConsumptionData {
- const components = cd.components.map((column) =>
- applyOutlierToSeries(column),
- );
- const aggregate = applyOutlierToSeries(cd.aggregate);
- return { ...cd, components, aggregate };
-}
/**
* Outlier-filter a secondary {@link TimingSeries} (e.g. procurement's
* full-receipt lead time) over its own value distribution, recomputing
- * `monthly`/`stats` from the kept points. Returned unchanged when there is
+ * only its mean from the kept points. Returned unchanged when there is
* nothing to exclude. Independent of the headline series so the two can have
* different fences.
*/ function applyOutlierToTimingSeries(ts: TimingSeries): TimingSeries {
@@ -203,9 +120,12 @@ interface SeriesLike {
}
return {
...ts,
- observations: kept,
+ mean_observations: kept,
monthly: rebuildMonthlyTiming(ts.monthly, kept),
- stats: computeStats(kept.map((observation) => observation.value)),
+ stats: {
+ ...ts.stats,
+ mean: computeStats(kept.map((observation) => observation.value)).mean,
+ },
};
}
/** Step-level counterpart of {@link applyOutlierSelectionToNode}. */ export function applyOutlierSelectionToStep(
@@ -228,10 +148,9 @@ interface SeriesLike {
if (excluded.length > 0) {
const values = kept.map((observation) => observation.value);
timing = {
- durations: values,
- observations: kept,
+ mean_observations: kept,
monthly: rebuildMonthlyTiming(step.monthly, kept),
- stats: computeStats(values),
+ stats: { ...step.stats, mean: computeStats(values).mean },
};
excludedCount = excluded.length;
}
@@ -239,12 +158,6 @@ interface SeriesLike {
return {
...step,
...timing,
- yield_data: step.yield_data
- ? applyOutlierToYield(step.yield_data)
- : step.yield_data,
- consumption_data: step.consumption_data
- ? applyOutlierToConsumption(step.consumption_data)
- : step.consumption_data,
complete_timing: step.complete_timing
? applyOutlierToTimingSeries(step.complete_timing)
: step.complete_timing,
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts
index 2faf6e67c7e..39e48066630 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts
@@ -204,6 +204,32 @@ describe("period helpers anchored to a fixed clock", () => {
expect(threshold.pctChange).toBeCloseTo(-70, 6);
});
+ it("uses filtered mean values without reducing raw sample counts", () => {
+ const node = siteNode({
+ observations: [
+ obs("2025-07", 10),
+ obs("2025-08", 10),
+ obs("2025-09", 1000),
+ obs("2026-01", 20),
+ obs("2026-02", 20),
+ obs("2026-03", 2000),
+ ],
+ mean_observations: [
+ obs("2025-07", 10),
+ obs("2025-08", 10),
+ obs("2026-01", 20),
+ obs("2026-02", 20),
+ ],
+ });
+
+ const trend = computeTimingTrend(node, "6m", "mean");
+
+ expect(trend.previousValue).toBe(10);
+ expect(trend.currentValue).toBe(20);
+ expect(trend.previousN).toBe(3);
+ expect(trend.currentN).toBe(3);
+ });
+
it("computeCostTrend totals carrying cost across windows", () => {
const node = siteNode({
cost: cost(100),
@@ -251,7 +277,7 @@ describe("period helpers anchored to a fixed clock", () => {
expect(deltas.previousRange).not.toBeNull();
});
- it("computePeriodDeltas can compare an outlier-filtered historical series", () => {
+ it("filters the mean delta while retaining raw percentile deltas", () => {
const step = stepFrom([
fixtureObs("2025-07", 10),
fixtureObs("2025-08", 10),
@@ -265,11 +291,29 @@ describe("period helpers anchored to a fixed clock", () => {
const selected = applyOutlierSelectionToStep(step, true);
const rawDeltas = computePeriodDeltas(step.observations, "6m");
- const filteredDeltas = computePeriodDeltas(selected.observations, "6m");
+ const filteredDeltas = computePeriodDeltas(
+ selected.observations,
+ "6m",
+ selected.mean_observations,
+ );
expect(rawDeltas.previousStats?.median).toBe(505);
expect(rawDeltas.medianPctChange).toBeCloseTo(-96.03960396039604, 6);
- expect(filteredDeltas.previousStats?.median).toBe(10);
- expect(filteredDeltas.medianPctChange).toBeCloseTo(100, 6);
+ expect(filteredDeltas.previousStats?.median).toBe(505);
+ expect(filteredDeltas.medianPctChange).toBeCloseTo(-96.03960396039604, 6);
+ expect(filteredDeltas.previousStats?.mean).toBe(10);
+ expect(filteredDeltas.statDeltas.mean).toBeCloseTo(100, 6);
+ });
+
+ it("leaves a period mean and its delta empty when no kept values remain", () => {
+ const observations = [obs("2025-07", 10), obs("2026-01", 20)];
+ const deltas = computePeriodDeltas(
+ observations,
+ "6m",
+ observations.slice(0, 1),
+ );
+
+ expect(deltas.previousStats?.mean).toBe(10);
+ expect(deltas.statDeltas.mean).toBeNull();
});
});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts
index 22f02bd4627..9cb10d419ef 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts
@@ -127,13 +127,24 @@ export function computeTimingTrend(
timeRange: TimeRange,
measure: BaseMeasure = "median",
): TimingTrend {
- const trend = computeTrend(node.observations ?? [], timeRange, measure);
+ const observations = node.observations ?? [];
+ const trend = computeTrend(
+ measure === "mean"
+ ? (node.mean_observations ?? observations)
+ : observations,
+ timeRange,
+ measure,
+ );
+ const sampleTrend =
+ measure === "mean" && node.mean_observations != null
+ ? computeTrend(observations, timeRange, measure)
+ : trend;
return {
pctChange: trend.pctChange,
currentValue: trend.currentValue,
previousValue: trend.previousValue,
- currentN: trend.currentN,
- previousN: trend.previousN,
+ currentN: sampleTrend.currentN,
+ previousN: sampleTrend.previousN,
};
}
@@ -202,6 +213,7 @@ export interface PeriodComparison {
export function computePeriodDeltas(
observations: Observation[],
range: TimeRange,
+ meanObservations: Observation[] = observations,
): PeriodComparison {
const { currentFrom, previousFrom, previousTo } = periodCutoffs(range);
@@ -219,12 +231,34 @@ export function computePeriodDeltas(
const prevStats = computeStats(
prevObs.map((observation) => observation.value),
);
+ const currentMeanValues = meanObservations
+ .filter((observation) => observation.date.slice(0, 7) >= currentFrom)
+ .map((observation) => observation.value);
+ const previousMeanValues = meanObservations
+ .filter((observation) => {
+ const month = observation.date.slice(0, 7);
+ return month >= previousFrom && month <= previousTo;
+ })
+ .map((observation) => observation.value);
+ currentStats.mean =
+ currentMeanValues.length > 0
+ ? currentMeanValues.reduce((sum, value) => sum + value, 0) /
+ currentMeanValues.length
+ : null;
+ prevStats.mean =
+ previousMeanValues.length > 0
+ ? previousMeanValues.reduce((sum, value) => sum + value, 0) /
+ previousMeanValues.length
+ : null;
- const pctDelta = (curr: number, prev: number) => {
- if (prev === 0) {
+ const pctDelta = (
+ currentValue: number | null,
+ previousValue: number | null,
+ ) => {
+ if (currentValue == null || previousValue == null || previousValue === 0) {
return null;
}
- return ((curr - prev) / prev) * 100;
+ return ((currentValue - previousValue) / previousValue) * 100;
};
const hasPrev = prevStats.n > 0;
@@ -256,10 +290,7 @@ export function computePeriodDeltas(
const statDeltas: Record = {};
for (const key of keys) {
- statDeltas[key] = pctDelta(
- currentStats[key] as number,
- prevStats[key] as number,
- );
+ statDeltas[key] = pctDelta(currentStats[key], prevStats[key]);
}
return {
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts
index de70be12f61..a5df8ae92ee 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts
@@ -9,6 +9,8 @@ import {
import {
filterGraphNodeByDateRange,
filterStepByDateRange,
+ windowGraphNodeToRange,
+ windowStepToRange,
} from "./range-filter";
describe("filterStepByDateRange", () => {
@@ -44,6 +46,25 @@ describe("filterStepByDateRange", () => {
]);
});
+ it("computes the mean fence over full history before windowing", () => {
+ const step = stepFrom([
+ obs("2026-01", 2),
+ obs("2026-02", 2),
+ obs("2026-03", 3),
+ obs("2026-03", 4),
+ obs("2026-03", 5),
+ obs("2026-04", 1),
+ obs("2026-05", 1),
+ obs("2026-06", 1),
+ obs("2026-06", 4),
+ ]);
+ const out = filterStepByDateRange(step, "3m", true);
+
+ expect(out.observations).toHaveLength(4);
+ expect(out.stats.mean).toBe(1.8);
+ expect(out.stats.p95).toBe(3.5);
+ });
+
it("windows the secondary complete_timing series to the cutoff too", () => {
const step = tightStep();
step.complete_timing = timingSeriesFrom([
@@ -60,6 +81,20 @@ describe("filterStepByDateRange", () => {
expect(out.complete_timing?.stats.n).toBe(1);
expect(out.complete_timing?.stats.median).toBe(26);
});
+
+ it("leaves empty kept means null while retaining raw windowed points", () => {
+ const step = stepFrom([obs("2026-04", 13)]);
+ step.mean_observations = [obs("2026-01", 10)];
+ step.complete_timing = timingSeriesFrom([obs("2026-04", 26)]);
+ step.complete_timing.mean_observations = [obs("2026-01", 20)];
+
+ const out = windowStepToRange(step, "3m");
+
+ expect(out.stats.n).toBe(1);
+ expect(out.stats.mean).toBeNull();
+ expect(out.complete_timing?.stats.n).toBe(1);
+ expect(out.complete_timing?.stats.mean).toBeNull();
+ });
});
describe("filterGraphNodeByDateRange", () => {
@@ -86,4 +121,44 @@ describe("filterGraphNodeByDateRange", () => {
expect(out.stats.n).toBe(1);
expect(out.stats.median).toBe(13);
});
+
+ it("computes the node mean fence over full history before windowing", () => {
+ const observations = [
+ obs("2026-01", 2),
+ obs("2026-02", 2),
+ obs("2026-03", 3),
+ obs("2026-03", 4),
+ obs("2026-03", 5),
+ obs("2026-04", 1),
+ obs("2026-05", 1),
+ obs("2026-06", 1),
+ obs("2026-06", 4),
+ ];
+ const out = filterGraphNodeByDateRange(
+ makeNode({
+ observations,
+ stats: stepFrom(observations).stats,
+ }),
+ "3m",
+ true,
+ );
+
+ expect(out.observations).toHaveLength(4);
+ expect(out.mean_observations).toBeUndefined();
+ expect(out.stats.mean).toBe(1.8);
+ expect(out.stats.p95).toBe(3.5);
+ });
+
+ it("leaves an empty kept node mean null while retaining raw points", () => {
+ const out = windowGraphNodeToRange(
+ makeNode({
+ observations: [obs("2026-04", 13)],
+ mean_observations: [obs("2026-01", 10)],
+ }),
+ "3m",
+ );
+
+ expect(out.stats.n).toBe(1);
+ expect(out.stats.mean).toBeNull();
+ });
});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts
index c3578070d89..0b00bd2e6e6 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts
@@ -91,6 +91,14 @@ function filterObservationsByCutoff(
};
}
+function meanForObservations(observations: Observation[]): number | null {
+ if (observations.length === 0) {
+ return null;
+ }
+ return computeStats(observations.map((observation) => observation.value))
+ .mean;
+}
+
function filterYieldData(yd: YieldData, cutoff: string): YieldData {
const row = filterObservationsByCutoff(yd.observations, yd.monthly, cutoff);
return {
@@ -308,12 +316,30 @@ export function applyProcurementBasisToStep(
*/
function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries {
- const { observations, monthly, stats } = filterObservationsByCutoff(
- ts.observations,
- ts.monthly,
- cutoff,
+ const {
+ observations,
+ monthly,
+ stats: rawStats,
+ } = filterObservationsByCutoff(ts.observations, ts.monthly, cutoff);
+ const meanObservations = ts.mean_observations?.filter(
+ (observation) => observation.date.slice(0, 7) >= cutoff,
);
- return { ...ts, observations, monthly, stats };
+ const stats =
+ meanObservations == null
+ ? rawStats
+ : {
+ ...rawStats,
+ mean: meanForObservations(meanObservations),
+ };
+ return {
+ ...ts,
+ observations,
+ ...(meanObservations == null
+ ? {}
+ : { mean_observations: meanObservations }),
+ monthly,
+ stats,
+ };
}
/**
@@ -335,7 +361,17 @@ function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries {
(observation: Observation) => observation.date.slice(0, 7) >= cutoff,
);
const values = filtered.map((observation: Observation) => observation.value);
- const stats = computeStats(values);
+ const meanObservations = selectedStep.mean_observations?.filter(
+ (observation: Observation) => observation.date.slice(0, 7) >= cutoff,
+ );
+ const rawStats = computeStats(values);
+ const stats =
+ meanObservations == null
+ ? rawStats
+ : {
+ ...rawStats,
+ mean: meanForObservations(meanObservations),
+ };
const filteredMonthly: MonthlyBucket[] = selectedStep.monthly.filter(
(month) => month.month >= cutoff,
);
@@ -352,6 +388,9 @@ function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries {
...selectedStep,
durations: values,
observations: filtered,
+ ...(meanObservations == null
+ ? {}
+ : { mean_observations: meanObservations }),
monthly: filteredMonthly,
stats,
cost: selectedStep.cost,
@@ -470,7 +509,17 @@ export function windowGraphNodeToRange(
(observation: Observation) => observation.date.slice(0, 7) >= cutoff,
);
const values = filtered.map((observation: Observation) => observation.value);
- const stats = computeStats(values);
+ const meanObservations = selectedNode.mean_observations?.filter(
+ (observation: Observation) => observation.date.slice(0, 7) >= cutoff,
+ );
+ const rawStats = computeStats(values);
+ const stats =
+ meanObservations == null
+ ? rawStats
+ : {
+ ...rawStats,
+ mean: meanForObservations(meanObservations),
+ };
const filteredMonthly = (selectedNode.monthly ?? []).filter(
(month) => month.month >= cutoff,
);
@@ -487,6 +536,9 @@ export function windowGraphNodeToRange(
...selectedNode,
stats,
observations: filtered,
+ ...(meanObservations == null
+ ? {}
+ : { mean_observations: meanObservations }),
monthly: filteredMonthly,
cost: selectedNode.cost,
pct_exceeding_plan: pctExceedingForObservations(
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts
index b1467113fd9..0357f939dee 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts
@@ -73,6 +73,33 @@ function recordsOnlyWireStep(
}
describe("deriveTimingFromRecords", () => {
+ it("uses campaign_rows as canonical QA observations while retaining batch evidence", () => {
+ const step = recordsOnlyStep({
+ type: "qa_hold",
+ timing_grain: "campaign",
+ n_campaigns: 2,
+ n_batches: 5,
+ campaign_rows: {
+ columns: detailRows.columns,
+ rows: [
+ { consumption_date: "2026-01-12", dwell_days: 4 },
+ { consumption_date: "2026-02-15", dwell_days: 8 },
+ ],
+ },
+ observations: [{ date: "2025-01-01", value: 999 }],
+ });
+
+ const out = ensureStepStats(step);
+ expect(out.observations).toEqual([
+ { date: "2026-01-12", value: 4 },
+ { date: "2026-02-15", value: 8 },
+ ]);
+ expect(out.stats.n).toBe(2);
+ expect(out.detail_rows).toBe(detailRows);
+ expect(out.n_campaigns).toBe(2);
+ expect(out.n_batches).toBe(5);
+ });
+
it("rehydrates observations/durations/monthly/stats from detail_rows", () => {
const derived = deriveTimingFromRecords(recordsOnlyStep());
expect(
@@ -203,6 +230,32 @@ describe("deriveTimingFromRecords", () => {
},
);
+ it("uses detail_rows as the explicit v1.2 fallback for campaign timing", () => {
+ const step = recordsOnlyStep({
+ type: "qa_hold",
+ timing_grain: "campaign",
+ campaign_rows: null,
+ detail_rows: {
+ columns: [],
+ rows: [
+ { campaign_date: "2026-04-01", qa_days: 4 },
+ { campaign_date: "2026-05-01", qa_days: 8 },
+ ],
+ },
+ ref_date_col: "campaign_date",
+ value_col: "qa_days",
+ });
+
+ const out = ensureStepStats(step);
+
+ expect(out.observations).toEqual([
+ { date: "2026-04-01", value: 4 },
+ { date: "2026-05-01", value: 8 },
+ ]);
+ expect(out.durations).toEqual([4, 8]);
+ expect(out.stats.n).toBe(2);
+ });
+
it("derives procurement timing as one first/full observation per PO", () => {
const step = recordsOnlyStep({
type: "procurement",
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts
new file mode 100644
index 00000000000..b6ed0419fa0
--- /dev/null
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ combinedSampleTier,
+ isExcludedLowSample,
+ sampleTier,
+} from "./sample-confidence";
+
+describe("sample confidence", () => {
+ it.each([
+ [0, "none"],
+ [1, "low"],
+ [4, "low"],
+ [5, "limited"],
+ [9, "limited"],
+ [10, "good"],
+ ] as const)("classifies %i observations as %s", (count, expected) => {
+ expect(sampleTier(count)).toBe(expected);
+ });
+
+ it("uses the weakest populated period", () => {
+ expect(combinedSampleTier(8, 3)).toBe("low");
+ expect(combinedSampleTier(12, 7)).toBe("limited");
+ expect(combinedSampleTier(12, 0)).toBe("good");
+ });
+
+ it("excludes only the low tier", () => {
+ expect(isExcludedLowSample(4)).toBe(true);
+ expect(isExcludedLowSample(5)).toBe(false);
+ expect(isExcludedLowSample(9)).toBe(false);
+ });
+});
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts
new file mode 100644
index 00000000000..904fc7ac3c4
--- /dev/null
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts
@@ -0,0 +1,40 @@
+export const LOW_SAMPLE_MIN = 5;
+export const GOOD_SAMPLE_MIN = 10;
+
+export type SampleTier = "none" | "low" | "limited" | "good";
+
+export const sampleTier = (count: number): SampleTier => {
+ if (count <= 0) {
+ return "none";
+ }
+ if (count < LOW_SAMPLE_MIN) {
+ return "low";
+ }
+ if (count < GOOD_SAMPLE_MIN) {
+ return "limited";
+ }
+ return "good";
+};
+
+/** Return the weakest populated tier across current/previous periods. */
+export const combinedSampleTier = (
+ ...counts: (number | null | undefined)[]
+): SampleTier => {
+ const tiers = counts
+ .filter((count): count is number => count != null && count > 0)
+ .map(sampleTier);
+ if (tiers.includes("low")) {
+ return "low";
+ }
+ if (tiers.includes("limited")) {
+ return "limited";
+ }
+ if (tiers.includes("good")) {
+ return "good";
+ }
+ return "none";
+};
+
+/** The "exclude low samples" setting retains limited (5–9) samples. */
+export const isExcludedLowSample = (count: number): boolean =>
+ count < LOW_SAMPLE_MIN;
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
index 95c21efc208..53ba797ddfd 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx
@@ -603,8 +603,14 @@ export const StepDetailPanel = ({
return comparisonStep.observations;
}, [comparisonStep, dimension, selectedComponent]);
const periodComparison = useMemo(() => {
- return computePeriodDeltas(comparisonObservations, timeRange);
- }, [comparisonObservations, timeRange]);
+ return computePeriodDeltas(
+ comparisonObservations,
+ timeRange,
+ dimension === "timing"
+ ? (comparisonStep?.mean_observations ?? comparisonObservations)
+ : comparisonObservations,
+ );
+ }, [comparisonObservations, comparisonStep, dimension, timeRange]);
const selectedComponentReconciliationCount = useMemo(() => {
if (
dimension !== "consumption" ||
@@ -839,7 +845,7 @@ export const StepDetailPanel = ({
{filteredStep.excluded_count}
{" "}
- outliers excluded
+ excluded from mean
)}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx
index 86456e2409f..dacb1b7ecec 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx
@@ -379,6 +379,7 @@ export const DistributionChart = ({
type: step.type,
dimension,
selectedComponent: selectedComponent != null,
+ timingGrain: step.timing_grain,
})}`,
"Count",
]}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx
index d68faac7886..fe32ae28e48 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx
@@ -434,6 +434,7 @@ export const ObservationCount = ({
id: step.id,
label: step.label,
type: step.type,
+ timingGrain: step.timing_grain,
dimension,
selectedComponent,
});
@@ -446,11 +447,13 @@ export const ObservationCount = ({
id: step.id,
label: step.label,
type: step.type,
+ timingGrain: step.timing_grain,
dimension,
selectedComponent,
count,
rangeLabel: timeRange,
nBatches: step.n_batches,
+ nCampaigns: step.n_campaigns,
nMovements: step.n_movements,
})}
>
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx
index 90f8f566053..9e2a59c614b 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx
@@ -181,6 +181,7 @@ export const TimeSeriesChart = ({
type: step.type,
dimension,
selectedComponent: selectedComponent != null,
+ timingGrain: step.timing_grain,
});
const base =
count != null ? `${_label}: ${count} ${noun}` : String(_label);
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx
index 703ed6963f0..bbf5aabe079 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx
@@ -323,7 +323,13 @@ export const KeyMetricsRow = ({
? `${formatNumber(pep, { maximumFractionDigits: 0 })}%`
: "–"}
- {pep != null && of batches}
+ {pep != null && (
+
+ {step.timing_grain === "campaign"
+ ? "of campaigns"
+ : "of batches"}
+
+ )}
diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts
index 6ee00ac12a5..92936290cc8 100644
--- a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts
+++ b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts
@@ -201,6 +201,9 @@ export interface GraphNode {
cost: CostData | null;
material_value?: MaterialValueData | null;
observations?: Observation[];
+ /** Client-derived Tukey-kept timing points used only for mean trends. */
+ mean_observations?: Observation[];
+ timing_grain?: "campaign" | null;
/** Client-side cache of combined procurement node observations from the wire. */
procurement_observations?: ProcurementNodeObservation[];
monthly?: MonthlyBucket[];
@@ -209,6 +212,7 @@ export interface GraphNode {
/** Client-computed exclusion rate (%) under the current outlier setting. */
excluded_pct?: number;
n_batches?: number;
+ n_campaigns?: number;
n_movements?: number;
/** Recomputed client-side from `yield_series` under window + outlier; not shipped by the generator. */
yield_summary?: YieldSummary | null;
@@ -472,6 +476,8 @@ export interface ProcurementNodeObservation {
export interface TimingSeries {
label?: string;
observations: Observation[];
+ /** Client-derived Tukey-kept timing points used only for mean calculations. */
+ mean_observations?: Observation[];
monthly: MonthlyBucket[];
stats: StepStats;
}
@@ -614,6 +620,8 @@ export interface StepDetail {
type: StepType;
durations: number[];
observations: Observation[];
+ /** Client-derived Tukey-kept timing points used only for mean trends. */
+ mean_observations?: Observation[];
monthly: MonthlyBucket[];
stats: StepStats;
/** Client-computed by the Tukey IQR outlier selection (lib/utils); not shipped by the generator. */
@@ -626,12 +634,18 @@ export interface StepDetail {
pct_exceeding_plan?: number | null;
cost: CostData | null;
material_value?: MaterialValueData | null;
+ /** Observation grain for timing. Campaign timing is one canonical QA observation per campaign. */
+ timing_grain?: "campaign" | null;
+ /**
+ * Canonical campaign-level timing records. When present these take precedence
+ * over `detail_rows`, which remains the underlying batch evidence.
+ */
+ campaign_rows?: DetailRows | null;
detail_rows?: DetailRows | null;
ref_date_col?: string | null;
/**
- * Canonical value column within `detail_rows.rows`. With `ref_date_col`, the
- * timing series (observations/durations/monthly/stats) is fully derivable from
- * `detail_rows` on load.
+ * Canonical value column within the selected timing rows. Campaign timing
+ * derives from `campaign_rows`; legacy timing derives from `detail_rows`.
*/
value_col?: string | null;
/**
@@ -646,6 +660,7 @@ export interface StepDetail {
* the inactive first/last receipt basis after deriving both from detail rows.
*/
complete_timing?: TimingSeries | null;
+ n_campaigns?: number;
n_batches?: number;
n_movements?: number;
yield_data?: YieldData | null;
diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx
index 917821497f4..49036c2ab77 100644
--- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx
+++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx
@@ -17,6 +17,7 @@ import { buildCsvContent, downloadCsv } from "../shared/export-utils";
import { useSupplierPerformanceEnabled } from "../shared/feature-flags";
import { ErrorState, SupplyChainAppSkeleton } from "../shared/load-state";
import { ensureNodeStats } from "../shared/normalize-contract";
+import { countNoun } from "../shared/observation-labels";
import { applyOutlierSelectionToStep } from "../shared/outlier-selection";
import {
computePeriodDeltas,
@@ -602,13 +603,14 @@ function formatTrendSummary(
previousN: number;
},
range: TimeRange,
+ observationNoun: string,
): string {
if (
trend.pctChange == null ||
trend.currentValue == null ||
trend.previousValue == null
) {
- return `No comparison (${formatNumber(trend.currentN)} current / ${formatNumber(trend.previousN)} previous samples)`;
+ return `No comparison (${formatNumber(trend.currentN)} current / ${formatNumber(trend.previousN)} previous ${observationNoun})`;
}
return `${formatNumber(trend.currentValue, { maximumFractionDigits: 1 })}d vs ${formatNumber(trend.previousValue, { maximumFractionDigits: 1 })}d previous ${range} (${formatDeviation(trend.pctChange)})`;
}
@@ -859,8 +861,10 @@ const DwellExecutiveSummary = ({
const PlanningExecutiveSummary = ({
brief,
+ observationNoun,
}: {
brief: PlanningOpportunityBrief;
+ observationNoun: string;
}) => {
return (