{comparisonMode === 'hardware' ? null : (
{strings.historyCaption(OVERVIEW_HISTORY_WINDOW_DAYS[comparisonMode])}
diff --git a/packages/app/src/components/overview/overview-switcher-focus.test.tsx b/packages/app/src/components/overview/overview-switcher-focus.test.tsx
index e4b399d4..b43f997e 100644
--- a/packages/app/src/components/overview/overview-switcher-focus.test.tsx
+++ b/packages/app/src/components/overview/overview-switcher-focus.test.tsx
@@ -32,6 +32,10 @@ function pageData(tier: OverviewTier): OverviewPageData {
comparisonMode: 'hardware',
referenceHardware: 'b200',
modelScope: 'default',
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
historicalWindow: null,
};
}
@@ -47,6 +51,8 @@ function Body() {
comparisonMode={data.comparisonMode}
referenceHardware={data.referenceHardware}
modelScope={data.modelScope}
+ rowScope={data.rowScope}
+ hardwareRowScope={data.hardwareRowScope}
locale="en"
strings={OVERVIEW_STRINGS.en}
/>
diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts
index 124e1c12..26e4621b 100644
--- a/packages/app/src/lib/api-route-catalog.ts
+++ b/packages/app/src/lib/api-route-catalog.ts
@@ -278,10 +278,10 @@ export const apiRouteCatalog = [
method: 'GET',
classification: 'page-bff',
exclusionReason: {
- en: 'Page-owned BFF aggregation whose tier, comparison, and calculator projections are coupled to the overview UI.',
- zh: '由页面拥有的 BFF 聚合;其档位、比较和计算器投影与概览界面紧密耦合。',
+ en: 'Page-owned BFF aggregation whose tier, comparison, row-scope, and calculator projections are coupled to the overview UI.',
+ zh: '由页面拥有的 BFF 聚合;其档位、比较、行范围和计算器投影与概览界面紧密耦合。',
},
- sourceSha256: '7c9830baae39d533ba7db36d44fe520f89cb36b32ffc54ef29404693d0b6b120',
+ sourceSha256: '499089d01754aa470d0ced953d9818d7d2e620dd3521685099924750249f13e2',
},
{
source: 'src/app/api/v1/reliability/route.ts',
@@ -388,7 +388,7 @@ export const apiContractSourceDigests = [
},
{
source: 'src/lib/overview-data.ts',
- sourceSha256: '36f61fe9eaca105bd524b066f73a0be828192b9e43acbdb73531288851a4e1e3',
+ sourceSha256: 'f54940c63a16d1c97a491b901aebcf9cb8893023eefef9f80ddace8936cabba8',
reviewArea: {
en: 'Overview BFF tier, engine, comparison-window, reference, and model-scope parameters plus the OverviewPageData response shape.',
zh: '概览 BFF 的档位、引擎、对比时间窗口、参考硬件和模型范围参数,以及 OverviewPageData 响应结构。',
diff --git a/packages/app/src/lib/overview-data.server.ts b/packages/app/src/lib/overview-data.server.ts
index 322173c5..38b63845 100644
--- a/packages/app/src/lib/overview-data.server.ts
+++ b/packages/app/src/lib/overview-data.server.ts
@@ -6,10 +6,14 @@ import { benchmarkCurveDate } from '@/lib/benchmark-run-selection';
import { getCachedBenchmarks, getCachedBenchmarksAsOf } from '@/lib/benchmark-data.server';
import type { Model } from '@/lib/data-mappings';
import {
+ applyOverviewHardwareRowScope,
+ applyOverviewRowScope,
assembleOverviewHistoricalPageData,
assembleOverviewPageData,
OVERVIEW_DEFAULT_COMPARISON_MODE,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
OVERVIEW_DEFAULT_MODEL_SCOPE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
OVERVIEW_PRIMARY_TIER,
OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
overviewHistoricalWindow,
@@ -17,9 +21,11 @@ import {
overviewSnapshotDate,
type OverviewComparisonMode,
type OverviewEngineScope,
+ type OverviewHardwareRowScope,
type OverviewModelScope,
type OverviewPageData,
type OverviewReferenceHardware,
+ type OverviewRowScope,
type OverviewTier,
} from '@/lib/overview-data';
import { loadFixture } from '@/lib/test-fixtures';
@@ -44,7 +50,13 @@ export async function getOverviewPageData(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): Promise
{
+ // Each filter is a no-op outside its own comparison mode, so both run on
+ // every path and the active one is whichever the matrix is actually in.
+ const scopeRows = (data: OverviewPageData): OverviewPageData =>
+ applyOverviewHardwareRowScope(applyOverviewRowScope(data, rowScope), hardwareRowScope);
const models = overviewModelsForScope(modelScope);
// Synthetic rows go through the same assemblers as the live path, so a
// contract drift breaks fixture tests instead of stranding the page.
@@ -52,12 +64,14 @@ export async function getOverviewPageData(
? loadFixture>('overview-rows')
: await loadRowsByModel(models, getCachedBenchmarks);
if (comparisonMode === 'hardware') {
- return assembleOverviewPageData(
- currentRowsByModel,
- tier,
- engineScope,
- referenceHardware,
- modelScope,
+ return scopeRows(
+ assembleOverviewPageData(
+ currentRowsByModel,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ ),
);
}
@@ -76,7 +90,7 @@ export async function getOverviewPageData(
: currentRowsByModel;
const snapshotDate = overviewSnapshotDate(snapshotRows, engineScope);
if (snapshotDate === null) {
- return {
+ return scopeRows({
...assembleOverviewPageData(
currentRowsByModel,
tier,
@@ -85,7 +99,7 @@ export async function getOverviewPageData(
modelScope,
),
comparisonMode,
- };
+ });
}
const window = overviewHistoricalWindow(snapshotDate, comparisonMode);
@@ -103,13 +117,15 @@ export async function getOverviewPageData(
]),
);
- return assembleOverviewHistoricalPageData(
- currentRowsByModel,
- baselineRowsByModel,
- window,
- tier,
- engineScope,
- referenceHardware,
- modelScope,
+ return scopeRows(
+ assembleOverviewHistoricalPageData(
+ currentRowsByModel,
+ baselineRowsByModel,
+ window,
+ tier,
+ engineScope,
+ referenceHardware,
+ modelScope,
+ ),
);
}
diff --git a/packages/app/src/lib/overview-data.test.ts b/packages/app/src/lib/overview-data.test.ts
index 62a86b87..9124674d 100644
--- a/packages/app/src/lib/overview-data.test.ts
+++ b/packages/app/src/lib/overview-data.test.ts
@@ -13,9 +13,15 @@ import {
Precision,
} from './data-mappings';
import {
+ applyOverviewHardwareRowScope,
+ applyOverviewRowScope,
assembleOverviewHistoricalPageData,
assembleOverviewPageData,
buildOverviewModelSummary,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
+ overviewRowHasAnyCost,
+ overviewRowHasHistoricalChange,
overviewCostPerMtok,
overviewHistoricalWindow,
overviewScenarioForModel,
@@ -23,8 +29,10 @@ import {
overviewTierEvidenceDate,
resolveOverviewComparisonMode,
resolveOverviewEngineScope,
+ resolveOverviewHardwareRowScope,
resolveOverviewModelScope,
resolveOverviewReferenceHardware,
+ resolveOverviewRowScope,
resolveOverviewTier,
type OverviewModelSummary,
type OverviewPageData,
@@ -1519,3 +1527,183 @@ describe('overview model scope', () => {
expect(page.modelScope).toBe('all');
});
});
+
+describe('overview row scope', () => {
+ const window = {
+ key: '30d',
+ snapshotDate: '2026-08-03',
+ targetDate: '2026-07-04',
+ earliestDate: '2026-06-04',
+ } as const;
+ // Only Qwen3.5 posts rows, so exactly one summary can be comparable and every
+ // other default row is dead weight in the 30-day matrix.
+ const currentRows = {
+ [Model.Qwen3_5]: frontier([12000, 10000, 8000, 6000], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-08-01',
+ precision: Precision.FP4,
+ }),
+ };
+ const baselineRows = {
+ [Model.Qwen3_5]: frontier([9600, 8000, 6400, 4800], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-06-20',
+ precision: Precision.FP4,
+ }),
+ };
+
+ function historyPage() {
+ return assembleOverviewHistoricalPageData(currentRows, baselineRows, window, 50, 'community');
+ }
+
+ it("resolves 'changed' as opt-in and defaults everything else to all rows", () => {
+ expect(resolveOverviewRowScope('changed')).toBe('changed');
+ expect(resolveOverviewRowScope(['changed', 'all'])).toBe('changed');
+ expect(resolveOverviewRowScope('all')).toBe('all');
+ expect(resolveOverviewRowScope('moved')).toBe('all');
+ expect(resolveOverviewRowScope('')).toBe('all');
+ expect(resolveOverviewRowScope(undefined)).toBe('all');
+ });
+
+ it('shows every row by default, so no current cost is hidden without asking', () => {
+ const page = historyPage();
+ const scoped = applyOverviewRowScope(page, OVERVIEW_DEFAULT_ROW_SCOPE);
+
+ expect(OVERVIEW_DEFAULT_ROW_SCOPE).toBe('all');
+ expect(scoped.rowScope).toBe('all');
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.unchangedRowCount).toBe(page.models.length - 1);
+ });
+
+ it("keeps only rows with a comparable platform under opt-in scope 'changed'", () => {
+ const page = historyPage();
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.rowScope).toBe('changed');
+ expect(scoped.models).toHaveLength(1);
+ expect(scoped.models[0]?.model).toBe(Model.Qwen3_5);
+ expect(scoped.unchangedRowCount).toBe(page.models.length - 1);
+ expect(
+ scoped.models[0]?.platforms.some((p) => p.historicalComparison?.status === 'comparable'),
+ ).toBe(true);
+ });
+
+ it('never drops a row that still prices a platform without a 30-day baseline', () => {
+ const page = historyPage();
+ const dropped = page.models.filter((m) => !overviewRowHasHistoricalChange(m));
+ const stillPriced = dropped.filter((m) => m.platforms.some((p) => p.costPerMtok !== null));
+
+ // The default must keep them; only the opt-in scope may remove them.
+ expect(applyOverviewRowScope(page, 'all').models).toEqual(expect.arrayContaining(stillPriced));
+ for (const summary of stillPriced) {
+ expect(applyOverviewRowScope(page, 'changed').models).not.toContain(summary);
+ }
+ });
+
+ it('filters nothing in hardware mode but still carries the reader’s answer', () => {
+ const page = assembleOverviewPageData(currentRows, 50, 'community');
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ // Carried, not cleared: the page rebuilds its own URL from this data, so
+ // clearing it here is what dropped the 30-day filter on a tab switch back.
+ expect(scoped.rowScope).toBe('changed');
+ expect(scoped.unchangedRowCount).toBe(0);
+ });
+
+ it('falls back to the full matrix when no row moved, rather than rendering nothing', () => {
+ const page = assembleOverviewHistoricalPageData({}, {}, window, 50, 'community');
+ const scoped = applyOverviewRowScope(page, 'changed');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.rowScope).toBe('all');
+ expect(scoped.unchangedRowCount).toBe(0);
+ });
+});
+
+describe('overview hardware row scope', () => {
+ const window = {
+ key: '30d',
+ snapshotDate: '2026-08-03',
+ targetDate: '2026-07-04',
+ earliestDate: '2026-06-04',
+ } as const;
+ // Only Qwen3.5 posts rows, and only on MI355X. That makes it the row the
+ // filter has to reason about carefully: it prices a platform while the
+ // default B200 reference prices nothing, so it has a cost but no delta.
+ const currentRows = {
+ [Model.Qwen3_5]: frontier([12000, 10000, 8000, 6000], {
+ hardware: 'mi355x',
+ framework: 'sglang',
+ date: '2026-08-01',
+ precision: Precision.FP4,
+ }),
+ };
+
+ function hardwarePage() {
+ return assembleOverviewPageData(currentRows, 50, 'community');
+ }
+
+ it("resolves 'priced' as opt-in and defaults everything else to all rows", () => {
+ expect(resolveOverviewHardwareRowScope('priced')).toBe('priced');
+ expect(resolveOverviewHardwareRowScope(['priced', 'all'])).toBe('priced');
+ expect(resolveOverviewHardwareRowScope('all')).toBe('all');
+ expect(resolveOverviewHardwareRowScope('empty')).toBe('all');
+ expect(resolveOverviewHardwareRowScope('')).toBe('all');
+ expect(resolveOverviewHardwareRowScope(undefined)).toBe('all');
+ });
+
+ it('shows every row by default, so no current cost is hidden without asking', () => {
+ const page = hardwarePage();
+ const scoped = applyOverviewHardwareRowScope(page, OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE);
+
+ expect(OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE).toBe('all');
+ expect(scoped.hardwareRowScope).toBe('all');
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.emptyRowCount).toBe(page.models.filter((m) => !overviewRowHasAnyCost(m)).length);
+ });
+
+ it("keeps only rows pricing a platform under opt-in scope 'priced'", () => {
+ const page = hardwarePage();
+ const priced = page.models.filter(overviewRowHasAnyCost);
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(priced.length).toBeGreaterThan(0);
+ expect(priced.length).toBeLessThan(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('priced');
+ expect(scoped.models).toEqual(priced);
+ expect(scoped.emptyRowCount).toBe(page.models.length - priced.length);
+ });
+
+ it('keeps a row priced only on a platform the reference never reached', () => {
+ // The tempting predicate — "has a delta against the reference" — would drop
+ // this row, deleting a cost that appears nowhere else, and would hide a
+ // different set of rows for every reference the reader picks.
+ const scoped = applyOverviewHardwareRowScope(hardwarePage(), 'priced');
+ const kept = scoped.models.filter((m) => m.model === Model.Qwen3_5);
+
+ expect(kept.length).toBeGreaterThan(0);
+ expect(kept.some((m) => m.platforms.some((p) => p.costPerMtok !== null))).toBe(true);
+ expect(kept.every((m) => m.platforms.every((p) => p.costVsReferencePct === null))).toBe(true);
+ });
+
+ it('filters nothing in history mode but still carries the reader’s answer', () => {
+ const page = assembleOverviewHistoricalPageData(currentRows, {}, window, 50, 'community');
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('priced');
+ expect(scoped.emptyRowCount).toBe(0);
+ });
+
+ it('falls back to the full matrix when no row prices anything', () => {
+ const page = assembleOverviewPageData({}, 50, 'community');
+ const scoped = applyOverviewHardwareRowScope(page, 'priced');
+
+ expect(scoped.models).toHaveLength(page.models.length);
+ expect(scoped.hardwareRowScope).toBe('all');
+ expect(scoped.emptyRowCount).toBe(0);
+ });
+});
diff --git a/packages/app/src/lib/overview-data.ts b/packages/app/src/lib/overview-data.ts
index f6fa2cf2..4135fc05 100644
--- a/packages/app/src/lib/overview-data.ts
+++ b/packages/app/src/lib/overview-data.ts
@@ -51,6 +51,19 @@ export type OverviewComparisonMode = 'hardware' | OverviewHistoryWindowKey;
export const OVERVIEW_DEFAULT_COMPARISON_MODE: OverviewComparisonMode = 'hardware';
export type OverviewModelScope = 'default' | 'all';
export const OVERVIEW_DEFAULT_MODEL_SCOPE: OverviewModelScope = 'default';
+/** History mode only: `changed` narrows the matrix to rows that moved in the
+ * window. It is opt-in — the default shows every row, because an unchanged row
+ * still carries current cost the reader came to audit. Ignored in hardware
+ * mode, where every row carries a comparison. */
+export type OverviewRowScope = 'changed' | 'all';
+export const OVERVIEW_DEFAULT_ROW_SCOPE: OverviewRowScope = 'all';
+/** Hardware mode only: `priced` drops rows that quote no platform at all, which
+ * carry neither a cost nor a comparison and exist purely to say "not measured".
+ * Deliberately not "rows without a delta against the reference": that would
+ * delete rows pricing three chips just because the reference happens to miss
+ * this scenario, and the count would swing with the chosen reference. */
+export type OverviewHardwareRowScope = 'priced' | 'all';
+export const OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE: OverviewHardwareRowScope = 'all';
export type OverviewScenario = 'single_turn_8k1k' | 'agentx';
/** Row order within a model: the single-turn workload first, AgentX below it. */
export const OVERVIEW_SCENARIOS = ['single_turn_8k1k', 'agentx'] as const;
@@ -89,6 +102,20 @@ export function resolveOverviewModelScope(
return candidate === 'all' ? 'all' : OVERVIEW_DEFAULT_MODEL_SCOPE;
}
+export function resolveOverviewRowScope(
+ raw: string | readonly string[] | undefined,
+): OverviewRowScope {
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
+ return candidate === 'changed' ? 'changed' : OVERVIEW_DEFAULT_ROW_SCOPE;
+}
+
+export function resolveOverviewHardwareRowScope(
+ raw: string | readonly string[] | undefined,
+): OverviewHardwareRowScope {
+ const candidate = Array.isArray(raw) ? raw[0] : raw;
+ return candidate === 'priced' ? 'priced' : OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE;
+}
+
// Note (wenyao): row order is a contract — defaults, then maintenance, then
// deprecated, each in MODEL_CONFIG declaration order; the overview e2e asserts
// inactive rows always sit below the default rows.
@@ -202,6 +229,15 @@ export interface OverviewPageData {
comparisonMode: OverviewComparisonMode;
referenceHardware: OverviewReferenceHardware;
modelScope: OverviewModelScope;
+ rowScope: OverviewRowScope;
+ hardwareRowScope: OverviewHardwareRowScope;
+ /** Rows with no 30-day change, counted over the full matrix regardless of the
+ * active scope, so the toggle can name the same number in both directions.
+ * Zero outside history mode and whenever filtering would change nothing. */
+ unchangedRowCount: number;
+ /** Rows quoting no platform at all, counted the same way for hardware mode.
+ * Zero outside hardware mode and whenever filtering would change nothing. */
+ emptyRowCount: number;
historicalWindow: OverviewHistoricalWindow | null;
}
@@ -786,10 +822,90 @@ export function assembleOverviewPageData(
comparisonMode: OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware,
modelScope,
+ rowScope: 'all',
+ hardwareRowScope: 'all',
+ unchangedRowCount: 0,
+ emptyRowCount: 0,
historicalWindow: null,
};
}
+/** A row earns its place in the 30-day matrix when at least one platform has a
+ * baseline to compare against. `comparable` is the only status carrying a
+ * `costDeltaPct`, so it is the same predicate the cells render from. */
+export function overviewRowHasHistoricalChange(model: OverviewModelSummary): boolean {
+ return model.platforms.some((platform) => platform.historicalComparison?.status === 'comparable');
+}
+
+/**
+ * Counts the rows that did not move, and narrows the matrix to the ones that
+ * did when the reader opted in.
+ *
+ * Hardware mode passes straight through. So does a window in which nothing is
+ * comparable — filtering to nothing tells the reader less than the unfiltered
+ * matrix, and the empty state is indistinguishable from a data outage.
+ *
+ * Note: an unchanged row is not an empty row. It routinely carries current
+ * costs that exist nowhere else on the page — on the live site Kimi K3's only
+ * row has no 30-day baseline yet still prices three platforms. That is why
+ * narrowing is opt-in rather than the default.
+ */
+export function applyOverviewRowScope(
+ data: OverviewPageData,
+ rowScope: OverviewRowScope,
+): OverviewPageData {
+ // Hardware mode filters on its own terms, but the reader's answer here is
+ // still carried so a tab switch can restore it. Only the count is zeroed:
+ // there is no control to label while this mode is off screen.
+ if (data.comparisonMode === 'hardware') {
+ return { ...data, rowScope, unchangedRowCount: 0 };
+ }
+
+ const changed = data.models.filter(overviewRowHasHistoricalChange);
+ const unchangedRowCount = data.models.length - changed.length;
+ if (changed.length === 0 || unchangedRowCount === 0) {
+ return { ...data, rowScope: 'all', unchangedRowCount: 0 };
+ }
+
+ return rowScope === 'changed'
+ ? { ...data, models: changed, rowScope: 'changed', unchangedRowCount }
+ : { ...data, rowScope: 'all', unchangedRowCount };
+}
+
+/** A row earns its place in the hardware matrix as soon as one platform quotes
+ * a cost. One price is still a fact about the row; zero prices is a row of
+ * dashes saying only that nothing was measured. */
+export function overviewRowHasAnyCost(model: OverviewModelSummary): boolean {
+ return model.platforms.some((platform) => platform.costPerMtok !== null);
+}
+
+/**
+ * The hardware-mode counterpart of {@link applyOverviewRowScope}: counts the
+ * rows that price nothing, and drops them when the reader opts in.
+ *
+ * History mode passes straight through, as does a matrix where every row is
+ * empty — filtering to nothing would read as an outage rather than a filter.
+ */
+export function applyOverviewHardwareRowScope(
+ data: OverviewPageData,
+ hardwareRowScope: OverviewHardwareRowScope,
+): OverviewPageData {
+ // Carried rather than cleared, for the same reason as the history scope.
+ if (data.comparisonMode !== 'hardware') {
+ return { ...data, hardwareRowScope, emptyRowCount: 0 };
+ }
+
+ const priced = data.models.filter(overviewRowHasAnyCost);
+ const emptyRowCount = data.models.length - priced.length;
+ if (priced.length === 0 || emptyRowCount === 0) {
+ return { ...data, hardwareRowScope: 'all', emptyRowCount: 0 };
+ }
+
+ return hardwareRowScope === 'priced'
+ ? { ...data, models: priced, hardwareRowScope: 'priced', emptyRowCount }
+ : { ...data, hardwareRowScope: 'all', emptyRowCount };
+}
+
function overviewPlatformKey(
model: OverviewModelSummary,
platform: OverviewPlatformResult,
diff --git a/packages/app/src/lib/overview-links.test.ts b/packages/app/src/lib/overview-links.test.ts
index 566173e6..542bc132 100644
--- a/packages/app/src/lib/overview-links.test.ts
+++ b/packages/app/src/lib/overview-links.test.ts
@@ -372,3 +372,147 @@ describe('overview model scope links', () => {
);
});
});
+
+describe('overview row scope links', () => {
+ it('leaves the canonical 30-day URL untouched and appends rows=changed last', () => {
+ expect(overviewHref('en', 50, 'community', '30d', 'b200', 'default', 'all')).toBe(
+ '/overview?compare=30d',
+ );
+ expect(overviewHref('en', 50, 'community', '30d', 'b200', 'default', 'changed')).toBe(
+ '/overview?compare=30d&rows=changed',
+ );
+ expect(overviewHref('en', 100, 'all', '30d', 'b300', 'all', 'changed')).toBe(
+ '/overview?tier=100&engine=all&ref=b300&compare=30d&models=all&rows=changed',
+ );
+ expect(overviewHref('zh', 50, 'community', '30d', 'b200', 'default', 'changed')).toBe(
+ '/zh/overview?compare=30d&rows=changed',
+ );
+ });
+
+ it('keeps the row scope on the URL in hardware mode, where it is dormant', () => {
+ // The 30-day tab is off screen, so the key changes nothing on the rendered
+ // matrix. It is still the only record of that tab's answer, and dropping it
+ // here is what used to lose the filter on refresh or a shared link.
+ expect(overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'changed')).toBe(
+ '/overview?rows=changed',
+ );
+ expect(overviewHref('en', 75, 'community', 'hardware', 'b200', 'all', 'changed')).toBe(
+ '/overview?tier=75&models=all&rows=changed',
+ );
+ });
+
+ it('preserves the row scope when changing tiers and engine scope', () => {
+ expect(overviewTierHref('en', 75, 'community', '30d', 'b200', 'default', 'changed')).toBe(
+ '/overview?tier=75&compare=30d&rows=changed',
+ );
+ expect(overviewEngineScopeHref('en', 'all', 50, '30d', 'b200', 'default', 'changed')).toBe(
+ '/overview?engine=all&compare=30d&rows=changed',
+ );
+ });
+
+ it('merges the rows control into pending overview URLs', () => {
+ const narrowed = mergeOverviewControlHref(
+ '/overview?compare=30d',
+ '/overview?compare=30d&rows=changed',
+ ['rows'],
+ );
+ expect(narrowed).toBe('/overview?compare=30d&rows=changed');
+ expect(mergeOverviewControlHref(narrowed, '/overview?tier=75', ['tier'])).toBe(
+ '/overview?tier=75&compare=30d&rows=changed',
+ );
+ expect(mergeOverviewControlHref(narrowed, '/overview?compare=30d', ['rows'])).toBe(
+ '/overview?compare=30d',
+ );
+ });
+});
+
+describe('overview hardware row scope links', () => {
+ it('leaves the canonical URL untouched and appends hwrows=priced last', () => {
+ expect(overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'all', 'all')).toBe(
+ '/overview',
+ );
+ expect(
+ overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?hwrows=priced');
+ expect(overviewHref('en', 100, 'all', 'hardware', 'b300', 'all', 'all', 'priced')).toBe(
+ '/overview?tier=100&engine=all&ref=b300&models=all&hwrows=priced',
+ );
+ expect(
+ overviewHref('zh', 50, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/zh/overview?hwrows=priced');
+ });
+
+ it('keeps the hardware row scope on the URL in history mode, where it is dormant', () => {
+ expect(overviewHref('en', 50, 'community', '30d', 'b200', 'default', 'all', 'priced')).toBe(
+ '/overview?compare=30d&hwrows=priced',
+ );
+ });
+
+ it('writes both row scopes at once, so a URL rebuilt from page data keeps them', () => {
+ // Every href the page builds comes from the current page data, including
+ // the one the client router starts from on first load. Emitting only the
+ // active mode's key there erased the other tab's filter on refresh and on
+ // any shared link carrying both.
+ expect(overviewHref('en', 50, 'community', '30d', 'b200', 'default', 'changed', 'priced')).toBe(
+ '/overview?compare=30d&rows=changed&hwrows=priced',
+ );
+ expect(
+ overviewHref('en', 50, 'community', 'hardware', 'b200', 'default', 'changed', 'priced'),
+ ).toBe('/overview?rows=changed&hwrows=priced');
+ });
+
+ it('preserves the hardware row scope when changing tiers and engine scope', () => {
+ expect(
+ overviewTierHref('en', 75, 'community', 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?tier=75&hwrows=priced');
+ expect(
+ overviewEngineScopeHref('en', 'all', 50, 'hardware', 'b200', 'default', 'all', 'priced'),
+ ).toBe('/overview?engine=all&hwrows=priced');
+ });
+
+ it('lets each mode keep its own filter across a tab switch', () => {
+ // The comparison switcher owns only `compare`, so the scope belonging to the
+ // mode being left stays in the URL and is waiting when the reader returns.
+ const hardware = mergeOverviewControlHref('/overview', '/overview?hwrows=priced', ['hwrows']);
+ expect(hardware).toBe('/overview?hwrows=priced');
+
+ const history = mergeOverviewControlHref(hardware, '/overview?compare=30d', ['compare']);
+ expect(history).toBe('/overview?compare=30d&hwrows=priced');
+
+ const narrowed = mergeOverviewControlHref(history, '/overview?compare=30d&rows=changed', [
+ 'rows',
+ ]);
+ expect(narrowed).toBe('/overview?compare=30d&rows=changed&hwrows=priced');
+
+ expect(mergeOverviewControlHref(narrowed, '/overview', ['compare'])).toBe(
+ '/overview?rows=changed&hwrows=priced',
+ );
+ });
+
+ it('lets the models control reset both scopes without clearing the dormant one', () => {
+ // The models toggle owns `models` plus both row keys, because revealing
+ // inactive models resets them. A merged key the target href does not carry
+ // is deleted, so hiding — which resets nothing — has to restate both.
+ const current = '/overview?compare=30d&models=all&rows=changed&hwrows=priced';
+ const keys = ['models', 'rows', 'hwrows'] as const;
+
+ const hiding = overviewHref(
+ 'en',
+ 50,
+ 'community',
+ '30d',
+ 'b200',
+ 'default',
+ 'changed',
+ 'priced',
+ );
+ expect(mergeOverviewControlHref(current, hiding, keys)).toBe(
+ '/overview?compare=30d&rows=changed&hwrows=priced',
+ );
+
+ const revealing = overviewHref('en', 50, 'community', '30d', 'b200', 'all', 'all', 'all');
+ expect(mergeOverviewControlHref(current, revealing, keys)).toBe(
+ '/overview?compare=30d&models=all',
+ );
+ });
+});
diff --git a/packages/app/src/lib/overview-links.ts b/packages/app/src/lib/overview-links.ts
index a61f38df..e7b9a47c 100644
--- a/packages/app/src/lib/overview-links.ts
+++ b/packages/app/src/lib/overview-links.ts
@@ -1,19 +1,30 @@
import { runIdFromRunUrl } from './known-issues';
import {
OVERVIEW_DEFAULT_COMPARISON_MODE,
+ OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
OVERVIEW_DEFAULT_MODEL_SCOPE,
OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
+ OVERVIEW_DEFAULT_ROW_SCOPE,
OVERVIEW_PRIMARY_TIER,
type OverviewComparisonMode,
type OverviewConfigView,
type OverviewEngineScope,
+ type OverviewHardwareRowScope,
type OverviewModelScope,
type OverviewModelSummary,
type OverviewReferenceHardware,
+ type OverviewRowScope,
type OverviewTier,
} from './overview-data';
-export type OverviewSearchKey = 'tier' | 'engine' | 'ref' | 'compare' | 'models';
+export type OverviewSearchKey =
+ | 'tier'
+ | 'engine'
+ | 'ref'
+ | 'compare'
+ | 'models'
+ | 'rows'
+ | 'hwrows';
export const OVERVIEW_SEARCH_ORDER: readonly OverviewSearchKey[] = [
'tier',
@@ -21,6 +32,8 @@ export const OVERVIEW_SEARCH_ORDER: readonly OverviewSearchKey[] = [
'ref',
'compare',
'models',
+ 'rows',
+ 'hwrows',
];
/** Params the client resolves without asking the server. `ref` only picks which
@@ -197,6 +210,8 @@ export function overviewHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
const base = locale === 'zh' ? '/zh/overview' : '/overview';
const query = new URLSearchParams();
@@ -207,6 +222,17 @@ export function overviewHref(
}
if (comparisonMode !== 'hardware') query.set('compare', comparisonMode);
if (modelScope !== OVERVIEW_DEFAULT_MODEL_SCOPE) query.set('models', modelScope);
+ // Each mode filters rows on its own terms and carries its own key. Both are
+ // written whenever they are set, including the one whose mode is off screen:
+ // that dormant key is the only record of the other tab's answer, and a URL
+ // rebuilt from page data — on first load, on refresh, or from a shared link —
+ // has nothing else to restore it from.
+ if (rowScope !== OVERVIEW_DEFAULT_ROW_SCOPE) query.set('rows', rowScope);
+ if (hardwareRowScope !== OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE) {
+ query.set('hwrows', hardwareRowScope);
+ }
+ // `rows=changed` and `hwrows=priced` are the only values ever emitted; `all`
+ // is the default and stays out of the URL so the canonical link is unchanged.
const search = query.toString();
return search === '' ? base : `${base}?${search}`;
}
@@ -219,8 +245,19 @@ export function overviewTierHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
- return overviewHref(locale, tier, engineScope, comparisonMode, referenceHardware, modelScope);
+ return overviewHref(
+ locale,
+ tier,
+ engineScope,
+ comparisonMode,
+ referenceHardware,
+ modelScope,
+ rowScope,
+ hardwareRowScope,
+ );
}
/** Engine-scope switch preserving the active service tier. */
@@ -231,6 +268,17 @@ export function overviewEngineScopeHref(
comparisonMode: OverviewComparisonMode = OVERVIEW_DEFAULT_COMPARISON_MODE,
referenceHardware: OverviewReferenceHardware = OVERVIEW_DEFAULT_REFERENCE_HARDWARE,
modelScope: OverviewModelScope = OVERVIEW_DEFAULT_MODEL_SCOPE,
+ rowScope: OverviewRowScope = OVERVIEW_DEFAULT_ROW_SCOPE,
+ hardwareRowScope: OverviewHardwareRowScope = OVERVIEW_DEFAULT_HARDWARE_ROW_SCOPE,
): string {
- return overviewHref(locale, tier, engineScope, comparisonMode, referenceHardware, modelScope);
+ return overviewHref(
+ locale,
+ tier,
+ engineScope,
+ comparisonMode,
+ referenceHardware,
+ modelScope,
+ rowScope,
+ hardwareRowScope,
+ );
}