From 1cc0a3aafab214fcd472270e2a81192897a9e476 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Thu, 15 Oct 2026 18:51:48 +0200
Subject: [PATCH 1/7] feat(techreport): add experimental soft navigation Core
Web Vitals
- Add overall_sn, LCP_sn, CLS_sn, INP_sn to cwv_subcategories, thresholds, and labels
- Configure metric histogram buckets for LCP_sn, INP_sn, CLS_sn
- Add dropdown options in drilldown and comparison views
- Support soft navigation columns in geographic breakdown table
Closes #1372
---
config/techreport.json | 108 ++++++++++++++++++++++++++-
src/js/techreport/cwvDistribution.js | 8 +-
src/js/techreport/geoBreakdown.js | 25 +++++--
3 files changed, 131 insertions(+), 10 deletions(-)
diff --git a/config/techreport.json b/config/techreport.json
index e8580669..17dd9b99 100644
--- a/config/techreport.json
+++ b/config/techreport.json
@@ -13,14 +13,21 @@
"LCP",
"FCP",
"INP",
- "TTFB"
+ "TTFB",
+ "overall_sn",
+ "LCP_sn",
+ "CLS_sn",
+ "INP_sn"
],
"cwv_thresholds": {
"LCP": [{ "value": 2500, "label": "Good" }, { "value": 4000, "label": "Needs improvement" }],
+ "LCP_sn": [{ "value": 2500, "label": "Good" }, { "value": 4000, "label": "Needs improvement" }],
"FCP": [{ "value": 1800, "label": "Good" }, { "value": 3000, "label": "Needs improvement" }],
"TTFB": [{ "value": 800, "label": "Good" }, { "value": 1800, "label": "Needs improvement" }],
"INP": [{ "value": 200, "label": "Good" }, { "value": 500, "label": "Needs improvement" }],
- "CLS": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }]
+ "INP_sn": [{ "value": 200, "label": "Good" }, { "value": 500, "label": "Needs improvement" }],
+ "CLS": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }],
+ "CLS_sn": [{ "value": 0.1, "label": "Good" }, { "value": 0.25, "label": "Needs improvement" }]
},
"lighthouse_subcategories": [
"accessibility",
@@ -324,6 +331,22 @@
{
"label": "Good TTFB",
"value": "TTFB"
+ },
+ {
+ "label": "Good Core Web Vitals (with soft navs)",
+ "value": "overall_sn"
+ },
+ {
+ "label": "Good LCP (with soft navs)",
+ "value": "LCP_sn"
+ },
+ {
+ "label": "Good INP (with soft navs)",
+ "value": "INP_sn"
+ },
+ {
+ "label": "Good CLS (with soft navs)",
+ "value": "CLS_sn"
}
]
},
@@ -739,7 +762,11 @@
"INP": "Good INP",
"CLS": "Good CLS",
"FCP": "Good FCP",
- "TTFB": "Good TTFB"
+ "TTFB": "Good TTFB",
+ "overall_sn": "Good Core Web Vitals (with soft navs)",
+ "LCP_sn": "Good LCP (with soft navs)",
+ "INP_sn": "Good INP (with soft navs)",
+ "CLS_sn": "Good CLS (with soft navs)"
}
},
"cwv_distribution": {
@@ -755,6 +782,15 @@
"axis_label": "LCP (ms)",
"step": 100
},
+ "LCP_sn": {
+ "label": "LCP_sn",
+ "display_name": "LCP (soft navs)",
+ "bucketField": "loading_bucket",
+ "originsField": "lcp_sn_origins",
+ "unit": "ms",
+ "axis_label": "LCP with soft navs (ms)",
+ "step": 100
+ },
"FCP": {
"label": "FCP",
"bucketField": "loading_bucket",
@@ -778,6 +814,15 @@
"axis_label": "INP (ms)",
"step": 25
},
+ "INP_sn": {
+ "label": "INP_sn",
+ "display_name": "INP (soft navs)",
+ "bucketField": "inp_bucket",
+ "originsField": "inp_sn_origins",
+ "unit": "ms",
+ "axis_label": "INP with soft navs (ms)",
+ "step": 25
+ },
"CLS": {
"label": "CLS",
"bucketField": "cls_bucket",
@@ -785,6 +830,15 @@
"unit": "",
"axis_label": "CLS",
"step": 0.05
+ },
+ "CLS_sn": {
+ "label": "CLS_sn",
+ "display_name": "CLS (soft navs)",
+ "bucketField": "cls_bucket",
+ "originsField": "cls_sn_origins",
+ "unit": "",
+ "axis_label": "CLS with soft navs",
+ "step": 0.05
}
},
"zone_colors": {
@@ -938,6 +992,22 @@
{
"label": "Good TTFB",
"value": "TTFB"
+ },
+ {
+ "label": "Good Core Web Vitals (with soft navs)",
+ "value": "overall_sn"
+ },
+ {
+ "label": "Good LCP (with soft navs)",
+ "value": "LCP_sn"
+ },
+ {
+ "label": "Good INP (with soft navs)",
+ "value": "INP_sn"
+ },
+ {
+ "label": "Good CLS (with soft navs)",
+ "value": "CLS_sn"
}
]
},
@@ -1440,6 +1510,26 @@
"label": "Good TTFB",
"title": "Good Time To First Byte",
"description": "TTFB is a metric that measures the time between the request for a resource and when the first byte of a response begins to arrive. A good TTFB is less than or equal to 800ms."
+ },
+ "overall_sn": {
+ "label": "Good Core Web Vitals (with soft navs)",
+ "title": "Good Core Web Vitals (with soft navs)",
+ "description": "The percentage of origins passing all three Core Web Vitals (LCP, INP, CLS) with a good experience, accounting for soft navigations in single-page applications. Note that if an origin is missing INP data, it's assessed based on the performance of the remaining metrics."
+ },
+ "LCP_sn": {
+ "label": "Good LCP (with soft navs)",
+ "title": "Good Largest Contentful Paint (with soft navs)",
+ "description": "Largest Contentful Paint including soft navigations measures perceived load speed across both hard page loads and subsequent in-app soft navigations. Good experiences are less than or equal to 2.5 seconds."
+ },
+ "INP_sn": {
+ "label": "Good INP (with soft navs)",
+ "title": "Good Interaction to Next Paint (with soft navs)",
+ "description": "Interaction to Next Paint including soft navigations assesses responsiveness across user interactions within the lifespan of a visit, including interactions occurring during soft navigations. A good experience is less than or equal to 200ms."
+ },
+ "CLS_sn": {
+ "label": "Good CLS (with soft navs)",
+ "title": "Good Cumulative Layout Shift (with soft navs)",
+ "description": "Cumulative Layout Shift including soft navigations measures visual stability across both initial loads and subsequent soft navigation page changes. Sites should strive to have a CLS of 0.1 or less for at least 75% of page visits."
}
},
"pageWeight": {
@@ -1480,6 +1570,10 @@
"origins_eligible_for_fcp": "Eligible",
"origins_eligible_for_inp": "Eligible",
"origins_eligible_for_ttfb": "Eligible",
+ "origins_eligible_for_cwv_sn": "Eligible",
+ "origins_eligible_for_cls_sn": "Eligible",
+ "origins_eligible_for_lcp_sn": "Eligible",
+ "origins_eligible_for_inp_sn": "Eligible",
"origins_with_good_cwv": "Having good Core Web Vitals",
"origins_with_good_cls": "Having good CLS",
@@ -1487,6 +1581,10 @@
"origins_with_good_fcp": "Having good FCP",
"origins_with_good_inp": "Having good INP",
"origins_with_good_ttfb": "Having good TTFB",
+ "origins_with_good_cwv_sn": "Having good Core Web Vitals (with soft navs)",
+ "origins_with_good_cls_sn": "Having good CLS (with soft navs)",
+ "origins_with_good_lcp_sn": "Having good LCP (with soft navs)",
+ "origins_with_good_inp_sn": "Having good INP (with soft navs)",
"pct_good_cwv": "% Good Core Web Vitals",
"pct_good_cls": "% Good CLS",
@@ -1494,6 +1592,10 @@
"pct_good_fcp": "% Good FCP",
"pct_good_inp": "% Good INP",
"pct_good_ttfb": "% Good TTFB",
+ "pct_good_cwv_sn": "% Good Core Web Vitals (with soft navs)",
+ "pct_good_cls_sn": "% Good CLS (with soft navs)",
+ "pct_good_lcp_sn": "% Good LCP (with soft navs)",
+ "pct_good_inp_sn": "% Good INP (with soft navs)",
"median_lighthouse_score_accessibility": "Median accessibility",
"median_lighthouse_score_performance": "Median performance",
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
index 99cc9b07..81942499 100644
--- a/src/js/techreport/cwvDistribution.js
+++ b/src/js/techreport/cwvDistribution.js
@@ -60,13 +60,17 @@ class CwvDistribution {
// Map the shared metric value (which may be 'overall') to a metric this chart can show
resolveMetric(value) {
- if (value && this.pageConfig.cwv_distribution.metric_config[value]) return this.pageConfig.cwv_distribution.metric_config[value].label;
+ if (value && this.pageConfig?.cwv_distribution?.metric_config?.[value]) return value;
+ if (value === 'overall_sn') return 'LCP_sn';
return 'LCP';
}
updateTitle() {
const slot = this.root?.querySelector('[data-slot="cwv-distribution-title-metric"]');
- if (slot) slot.textContent = this.selectedMetric;
+ if (slot) {
+ const cfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
+ slot.textContent = cfg?.display_name || cfg?.label || this.selectedMetric;
+ }
}
bindEventListeners() {
diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js
index dea955f9..f0dd0000 100644
--- a/src/js/techreport/geoBreakdown.js
+++ b/src/js/techreport/geoBreakdown.js
@@ -56,7 +56,8 @@ class GeoBreakdown {
updateTitle() {
const slot = document.querySelector('[data-slot="geo-title-metric"]');
if (!slot) return;
- slot.textContent = this.selectedMetric === 'overall' ? 'Core Web Vitals' : this.selectedMetric;
+ const label = this.pageConfig?.geo_breakdown?.metric_labels?.[this.selectedMetric];
+ slot.textContent = label ? label.replace(/^Good\s+/, '') : (this.selectedMetric === 'overall' ? 'Core Web Vitals' : this.selectedMetric);
}
bindEventListeners() {
@@ -137,16 +138,18 @@ class GeoBreakdown {
// Determine bar label and which extra columns to show
const metric = this.selectedMetric;
- const barLabel = metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`;
+ const barLabel = this.pageConfig?.geo_breakdown?.metric_labels?.[metric] || (metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`);
const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric)
? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase())
: metric === 'overall'
? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }]
- : []; // INP/CLS: no extra columns
+ : metric === 'overall_sn'
+ ? [{ key: 'lcp_sn', label: 'LCP (SN)' }, { key: 'inp_sn', label: 'INP (SN)' }, { key: 'cls_sn', label: 'CLS (SN)' }]
+ : []; // INP/CLS/individual softnav metrics: no extra columns
// If current sort column is no longer visible, reset to total
const extraKeys = extraCols.map(c => c.key);
- if (['lcp', 'inp', 'cls', 'fcp', 'ttfb'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
+ if (['lcp', 'inp', 'cls', 'fcp', 'ttfb', 'lcp_sn', 'inp_sn', 'cls_sn'].includes(this.sortColumn) && !extraKeys.includes(this.sortColumn)) {
this.sortColumn = 'total';
this.sortDir = 'desc';
}
@@ -171,7 +174,19 @@ class GeoBreakdown {
const v = row.vitals?.find(x => x.name === name)?.[client];
return v?.tested > 0 ? Math.round(v.good_number / v.tested * 100) : null;
};
- return { geo, good: d.good_number, total: d.tested, lcp: pctFor('LCP'), inp: pctFor('INP'), cls: pctFor('CLS'), fcp: pctFor('FCP'), ttfb: pctFor('TTFB') };
+ return {
+ geo,
+ good: d.good_number,
+ total: d.tested,
+ lcp: pctFor('LCP'),
+ inp: pctFor('INP'),
+ cls: pctFor('CLS'),
+ fcp: pctFor('FCP'),
+ ttfb: pctFor('TTFB'),
+ lcp_sn: pctFor('LCP_sn'),
+ inp_sn: pctFor('INP_sn'),
+ cls_sn: pctFor('CLS_sn')
+ };
}).filter(e => e.total > 0);
// Sort by origins, limit to top 10 unless showAll
From f5f415d052908840d3b8875242c75a886b9097b4 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Thu, 15 Oct 2026 23:05:16 +0200
Subject: [PATCH 2/7] refactor: update metric labels and standardize marker
configuration for single data points in tech report charts
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
config/techreport.json | 9 +++------
src/js/techreport/cwvDistribution.js | 2 +-
src/js/techreport/geoBreakdown.js | 11 ++++++++---
src/js/techreport/timeseries.js | 8 ++++++++
4 files changed, 20 insertions(+), 10 deletions(-)
diff --git a/config/techreport.json b/config/techreport.json
index 17dd9b99..4b2456a5 100644
--- a/config/techreport.json
+++ b/config/techreport.json
@@ -783,8 +783,7 @@
"step": 100
},
"LCP_sn": {
- "label": "LCP_sn",
- "display_name": "LCP (soft navs)",
+ "label": "LCP (soft navs)",
"bucketField": "loading_bucket",
"originsField": "lcp_sn_origins",
"unit": "ms",
@@ -815,8 +814,7 @@
"step": 25
},
"INP_sn": {
- "label": "INP_sn",
- "display_name": "INP (soft navs)",
+ "label": "INP (soft navs)",
"bucketField": "inp_bucket",
"originsField": "inp_sn_origins",
"unit": "ms",
@@ -832,8 +830,7 @@
"step": 0.05
},
"CLS_sn": {
- "label": "CLS_sn",
- "display_name": "CLS (soft navs)",
+ "label": "CLS (soft navs)",
"bucketField": "cls_bucket",
"originsField": "cls_sn_origins",
"unit": "",
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
index 81942499..33f6f589 100644
--- a/src/js/techreport/cwvDistribution.js
+++ b/src/js/techreport/cwvDistribution.js
@@ -69,7 +69,7 @@ class CwvDistribution {
const slot = this.root?.querySelector('[data-slot="cwv-distribution-title-metric"]');
if (slot) {
const cfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
- slot.textContent = cfg?.display_name || cfg?.label || this.selectedMetric;
+ slot.textContent = cfg?.label || this.selectedMetric;
}
}
diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js
index f0dd0000..c23b0939 100644
--- a/src/js/techreport/geoBreakdown.js
+++ b/src/js/techreport/geoBreakdown.js
@@ -56,8 +56,13 @@ class GeoBreakdown {
updateTitle() {
const slot = document.querySelector('[data-slot="geo-title-metric"]');
if (!slot) return;
- const label = this.pageConfig?.geo_breakdown?.metric_labels?.[this.selectedMetric];
- slot.textContent = label ? label.replace(/^Good\s+/, '') : (this.selectedMetric === 'overall' ? 'Core Web Vitals' : this.selectedMetric);
+ if (this.selectedMetric === 'overall') {
+ slot.textContent = 'Core Web Vitals';
+ } else if (this.selectedMetric === 'overall_sn') {
+ slot.textContent = 'Core Web Vitals (soft navs)';
+ } else {
+ slot.textContent = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric]?.label || this.selectedMetric;
+ }
}
bindEventListeners() {
@@ -138,7 +143,7 @@ class GeoBreakdown {
// Determine bar label and which extra columns to show
const metric = this.selectedMetric;
- const barLabel = this.pageConfig?.geo_breakdown?.metric_labels?.[metric] || (metric === 'overall' ? 'Good Core Web Vitals' : `Good ${metric}`);
+ const barLabel = this.pageConfig?.geo_breakdown?.metric_labels?.[metric] || `Good ${metric}`;
const extraCols = ['LCP', 'FCP', 'TTFB'].includes(metric)
? [{ key: 'ttfb', label: 'TTFB' }, { key: 'fcp', label: 'FCP' }, { key: 'lcp', label: 'LCP' }].filter(c => c.key !== metric.toLowerCase())
: metric === 'overall'
diff --git a/src/js/techreport/timeseries.js b/src/js/techreport/timeseries.js
index 51dfdcb6..41d3a152 100644
--- a/src/js/techreport/timeseries.js
+++ b/src/js/techreport/timeseries.js
@@ -549,6 +549,10 @@ class Timeseries {
data: sortedData,
color: techColor || colors[index],
showInLegend: sortedData.length > 0,
+ marker: {
+ enabled: sortedData.length === 1 ? true : null,
+ radius: 4,
+ },
});
});
@@ -607,6 +611,10 @@ class Timeseries {
data: sortedData,
color: seriesColor || colors?.[index],
lineWidth: 2,
+ marker: {
+ enabled: sortedData.length === 1 ? true : null,
+ radius: 4,
+ },
});
});
From 37b9f0af01c73cd3a76ed088217024af61ee9e80 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Thu, 15 Oct 2026 23:20:02 +0200
Subject: [PATCH 3/7] fix: handle missing chart data and null values safely in
cwvDistribution.js
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
src/js/techreport/cwvDistribution.js | 34 +++++++++++++++++++++-------
1 file changed, 26 insertions(+), 8 deletions(-)
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
index 33f6f589..36ee8200 100644
--- a/src/js/techreport/cwvDistribution.js
+++ b/src/js/techreport/cwvDistribution.js
@@ -179,6 +179,10 @@ class CwvDistribution {
}
showError() {
+ if (this.chart) {
+ this.chart.destroy();
+ this.chart = null;
+ }
if (!this.chartContainer) return;
this.chartContainer.innerHTML = '
Distribution data is not available for this selection.
';
}
@@ -211,14 +215,14 @@ class CwvDistribution {
}
trimWithOverflow(rows, originsField, percentile) {
- const total = rows.reduce((sum, row) => sum + row[originsField], 0);
- if (total === 0) return { visible: rows, overflowCount: 0 };
+ const total = rows.reduce((sum, row) => sum + (row[originsField] || 0), 0);
+ if (!total || isNaN(total)) return { visible: rows, overflowCount: 0, total: 0 };
const cutoff = total * percentile;
let cumulative = 0;
let cutIndex = rows.length;
for (let i = 0; i < rows.length; i++) {
- cumulative += rows[i][originsField];
+ cumulative += (rows[i][originsField] || 0);
if (cumulative >= cutoff) {
cutIndex = Math.min(i + 2, rows.length);
break;
@@ -226,8 +230,8 @@ class CwvDistribution {
}
const visible = rows.slice(0, cutIndex);
- const visibleSum = visible.reduce((sum, row) => sum + row[originsField], 0);
- return { visible, overflowCount: total - visibleSum };
+ const visibleSum = visible.reduce((sum, row) => sum + (row[originsField] || 0), 0);
+ return { visible, overflowCount: total - visibleSum, total };
}
renderChart() {
@@ -235,17 +239,31 @@ class CwvDistribution {
if (!this.root) return;
const client = this.root.dataset.client || 'mobile';
- const metricCfg = this.pageConfig.cwv_distribution.metric_config[this.selectedMetric];
- const thresholds = this.config.cwv_thresholds[this.selectedMetric];
+ const metricCfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
+ if (!metricCfg) {
+ this.showError();
+ return;
+ }
+ const thresholds = this.config?.cwv_thresholds?.[this.selectedMetric] || [];
const clientRows = this.distributionData
.filter(row => row.client === client)
.sort((a, b) => a[metricCfg.bucketField] - b[metricCfg.bucketField]);
- const { visible, overflowCount } = this.trimWithOverflow(
+ if (clientRows.length === 0) {
+ this.showError();
+ return;
+ }
+
+ const { visible, overflowCount, total } = this.trimWithOverflow(
clientRows, metricCfg.originsField, 0.995
);
+ if (!total || total <= 0) {
+ this.showError();
+ return;
+ }
+
const formatBucket = (val) => {
if (metricCfg.unit === 'ms') {
return val >= 1000 ? `${(val / 1000).toFixed(1)}s` : `${val}ms`;
From 0d037ec4be5fc31d1e779d9e4487006dd9bf8ff2 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:15:09 +0200
Subject: [PATCH 4/7] fix: improve table empty state handling
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
src/js/techreport/table.js | 35 +++++++++++++++++++++++++++++++----
1 file changed, 31 insertions(+), 4 deletions(-)
diff --git a/src/js/techreport/table.js b/src/js/techreport/table.js
index 5433b5f0..bd394244 100644
--- a/src/js/techreport/table.js
+++ b/src/js/techreport/table.js
@@ -58,8 +58,15 @@ function formatData(tableConfig, data) {
}
});
- // Add row to the table
- table.push(row);
+ // Only add row if at least one metric column has available data
+ const hasMetricData = row.some(cell => {
+ if (cell.key === 'date' || cell.key === 'client') return false;
+ return cell.value !== undefined && cell.value !== null && cell.value !== '';
+ });
+
+ if (hasMetricData) {
+ table.push(row);
+ }
}
const sorted = sortTableBy(table, 'date');
@@ -72,7 +79,7 @@ function sortTableBy(dataset, key) {
const selectedA = a.find(obj => obj.key === key);
const selectedB = b.find(obj => obj.key === key);
- return selectedA.value > selectedB.value ? -1 : 1;
+ return (selectedA?.value || '') > (selectedB?.value || '') ? -1 : 1;
});
}
@@ -82,7 +89,12 @@ function getColumnCell(columnConfig, data, date) {
const _data = data[app]?.find(entry => entry.date === date);
const endpointData = _data?.[endpoint];
const subcategoryData = endpointData?.find(row => row.name === subcategory);
- const value = subcategoryData?.[client]?.[column.key];
+ let value = subcategoryData?.[client]?.[column.key];
+
+ if (column.key === 'date' && !value && subcategoryData) {
+ value = date;
+ }
+
let styling;
if(column.styleChange && column.keyNr) {
@@ -183,6 +195,21 @@ function updateTable(id, config, appConfig, apps, data) {
// Format the data per row
const rows = formatData(tableConfig, data);
+ if (!rows || rows.length === 0) {
+ const tr = document.createElement('tr');
+ const td = document.createElement('td');
+ let totalCols = 0;
+ config?.columns?.forEach(col => {
+ totalCols += col.breakdown === 'app' ? (apps?.length || 1) : 1;
+ });
+ td.colSpan = totalCols || 1;
+ td.className = 'no-data';
+ td.textContent = 'No data available for this selection.';
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ return;
+ }
+
// Add rows to the table
rows?.forEach(row => {
const tr = document.createElement('tr');
From ec482caf30620aa7cddf19bbf342a50bd3f90340 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:55:26 +0200
Subject: [PATCH 5/7] feat: disable cwv distribution toggle when metric is
unavailable
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
src/js/techreport/cwvDistribution.js | 71 +++++++++++++++++++++-------
src/styles/techreport/techreport.css | 5 ++
tools/test/test_cwv_toggle.js | 55 +++++++++++++++++++--
3 files changed, 111 insertions(+), 20 deletions(-)
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
index 36ee8200..656a12ee 100644
--- a/src/js/techreport/cwvDistribution.js
+++ b/src/js/techreport/cwvDistribution.js
@@ -16,7 +16,11 @@ class CwvDistribution {
this.chart = null;
this.root = document.querySelector(`[data-id="${this.id}"]`);
this.date = this.pageFilters.end || this.root?.dataset?.latestDate || '';
- this.selectedMetric = this.resolveMetric(UrlUtils.get('good-cwv-over-time'));
+ this.selectedMetric = this.resolveMetric(
+ UrlUtils.get('good-cwv-over-time') ||
+ document.querySelector(`[data-id="good_cwv_timeseries"] .subcategory-selector, select[data-controls="good_cwv_timeseries"]`)?.value ||
+ ''
+ );
const updateDateFromTimeseries = (newDate) => {
const dateChanged = this.date && this.date !== newDate;
@@ -26,7 +30,7 @@ class CwvDistribution {
if (dateChanged) {
this.distributionData = null;
}
- if (!this.distributionData && !this.isLoading && !this.root?.classList.contains('hidden')) {
+ if (!this.distributionData && !this.isLoading && !this.root?.classList.contains('hidden') && this.selectedMetric) {
this.fetchData();
}
};
@@ -49,25 +53,43 @@ class CwvDistribution {
if (tsSlot && this.date) tsSlot.textContent = UIUtils.printMonthYear(this.date);
}
- this.updateTitle();
+ this.updateAvailability();
this.bindEventListeners();
- // Auto-expand if URL hash targets this section
- if (window.location.hash === `#section-${this.id}`) {
+ // Auto-expand if URL hash targets this section and metric is available
+ if (this.selectedMetric && window.location.hash === `#section-${this.id}`) {
this.toggle(true);
}
}
- // Map the shared metric value (which may be 'overall') to a metric this chart can show
+ // Map the shared metric value to a metric this chart can show, or null if not available
resolveMetric(value) {
if (value && this.pageConfig?.cwv_distribution?.metric_config?.[value]) return value;
- if (value === 'overall_sn') return 'LCP_sn';
- return 'LCP';
+ return null;
+ }
+
+ updateAvailability() {
+ const btn = document.getElementById('cwv-distribution-btn');
+ if (!this.selectedMetric) {
+ if (btn) {
+ btn.disabled = true;
+ btn.setAttribute('aria-disabled', 'true');
+ btn.classList.remove('hidden');
+ }
+ this.toggle(false);
+ } else {
+ if (btn) {
+ btn.disabled = false;
+ btn.removeAttribute('aria-disabled');
+ btn.classList.remove('hidden');
+ }
+ this.updateTitle();
+ }
}
updateTitle() {
const slot = this.root?.querySelector('[data-slot="cwv-distribution-title-metric"]');
- if (slot) {
+ if (slot && this.selectedMetric) {
const cfg = this.pageConfig?.cwv_distribution?.metric_config?.[this.selectedMetric];
slot.textContent = cfg?.label || this.selectedMetric;
}
@@ -78,16 +100,23 @@ class CwvDistribution {
document.addEventListener('cwv-metric-change', (event) => {
const resolved = this.resolveMetric(event.detail?.value);
- if (resolved !== this.selectedMetric) {
- this.selectedMetric = resolved;
- this.updateTitle();
- if (this.distributionData) this.renderChart();
+ const changed = resolved !== this.selectedMetric;
+ this.selectedMetric = resolved;
+ this.updateAvailability();
+
+ if (this.selectedMetric && changed && this.root && !this.root.classList.contains('hidden')) {
+ if (!this.distributionData && !this.isLoading && this.date) {
+ this.fetchData();
+ } else if (this.distributionData) {
+ this.renderChart();
+ }
}
});
const btn = document.getElementById('cwv-distribution-btn');
if (btn) {
btn.addEventListener('click', () => {
+ if (btn.disabled) return;
const isVisible = !this.root.classList.contains('hidden');
this.toggle(!isVisible);
});
@@ -97,6 +126,7 @@ class CwvDistribution {
toggle(show) {
const btn = document.getElementById('cwv-distribution-btn');
if (show) {
+ if (!this.selectedMetric || btn?.disabled) return;
this.root.classList.remove('hidden');
if (btn) btn.textContent = 'Hide histogram';
if (!this.date) {
@@ -125,12 +155,14 @@ class CwvDistribution {
url.hash = `#section-${this.id}`;
window.history.replaceState(null, null, url);
} else {
- this.root.classList.add('hidden');
+ if (this.root) this.root.classList.add('hidden');
if (btn) btn.textContent = 'Show histogram';
// Update the URL
- const url = new URL(window.location.href);
- url.hash = '';
- window.history.replaceState(null, null, url);
+ if (window.location.hash === `#section-${this.id}`) {
+ const url = new URL(window.location.href);
+ url.hash = '';
+ window.history.replaceState(null, null, url);
+ }
}
}
@@ -139,6 +171,7 @@ class CwvDistribution {
}
getUrl() {
+ if (!this.selectedMetric) return '';
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
const geo = encodeURIComponent(this.pageFilters.geo || 'ALL');
@@ -151,6 +184,10 @@ class CwvDistribution {
updateContent() {
const isVisible = this.root && !this.root.classList.contains('hidden');
+ if (!this.selectedMetric) {
+ if (isVisible) this.toggle(false);
+ return;
+ }
const currentUrl = this.getUrl();
const urlChanged = this.fetchedUrl && this.fetchedUrl !== currentUrl;
diff --git a/src/styles/techreport/techreport.css b/src/styles/techreport/techreport.css
index 045b553d..0e39b576 100644
--- a/src/styles/techreport/techreport.css
+++ b/src/styles/techreport/techreport.css
@@ -1056,6 +1056,11 @@ select {
border-color: var(--color-text-lighter);
}
+.show-table:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
.card.table-ui-wrapper {
margin-bottom: 2rem;
}
diff --git a/tools/test/test_cwv_toggle.js b/tools/test/test_cwv_toggle.js
index 6c348962..ce18bd08 100644
--- a/tools/test/test_cwv_toggle.js
+++ b/tools/test/test_cwv_toggle.js
@@ -19,23 +19,72 @@ const test_cwv_toggle = async () => {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
await new Promise((resolve) => setTimeout(resolve, 800));
- // Verify button bar exists and table wrapper was positioned after button bar
+ // Verify button bar exists, table wrapper positioned after bar, and histogram btn disabled (not hidden) on overall
const initialCheck = await page.evaluate(() => {
const bar = document.querySelector('.cwv-button-bar');
const tw = document.getElementById('good_cwv_timeseries-table-wrapper');
- if (!bar || !tw) return null;
+ const histBtn = document.getElementById('cwv-distribution-btn');
+ if (!bar || !tw || !histBtn) return null;
return {
hasBar: !!bar,
hasTw: !!tw,
isTwAfterBar: !(bar.compareDocumentPosition(tw) & Node.DOCUMENT_POSITION_PRECEDING),
+ isHistBtnDisabledOnOverall: histBtn.disabled === true,
+ isHistBtnVisible: !histBtn.classList.contains('hidden'),
};
});
if (!initialCheck || !initialCheck.isTwAfterBar) {
console.error('❌ [FAIL] CWV button bar or table wrapper not properly positioned');
failures++;
+ } else if (!initialCheck.isHistBtnDisabledOnOverall || !initialCheck.isHistBtnVisible) {
+ console.error('❌ [FAIL] Histogram button should be visible and disabled for overall metric', initialCheck);
+ failures++;
+ } else {
+ console.log('✅ [PASS] Table wrapper positioned after .cwv-button-bar and histogram button disabled for overall');
+ passes++;
+ }
+
+ // Switch metric to overall_sn to verify histogram button is also disabled
+ const snCheck = await page.evaluate(async () => {
+ const select = document.querySelector('select[data-controls="good_cwv_timeseries"]');
+ if (select) {
+ select.value = 'overall_sn';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ await new Promise((r) => setTimeout(r, 300));
+ const histBtn = document.getElementById('cwv-distribution-btn');
+ return histBtn && histBtn.disabled === true && !histBtn.classList.contains('hidden');
+ });
+
+ if (!snCheck) {
+ console.error('❌ [FAIL] Histogram button should be visible and disabled for overall_sn metric');
+ failures++;
+ } else {
+ console.log('✅ [PASS] Histogram button disabled for overall_sn');
+ passes++;
+ }
+
+ // Switch metric to LCP so histogram button becomes enabled for toggle testing
+ await page.evaluate(() => {
+ const select = document.querySelector('select[data-controls="good_cwv_timeseries"]');
+ if (select) {
+ select.value = 'LCP';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ });
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const lcpCheck = await page.evaluate(() => {
+ const histBtn = document.getElementById('cwv-distribution-btn');
+ return histBtn && histBtn.disabled === false;
+ });
+
+ if (!lcpCheck) {
+ console.error('❌ [FAIL] Histogram button should be enabled for LCP metric');
+ failures++;
} else {
- console.log('✅ [PASS] Table wrapper positioned after .cwv-button-bar');
+ console.log('✅ [PASS] Histogram button enabled for LCP');
passes++;
}
From 65d07d69d59b6dde5a20f880d5801130742c42c0 Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Fri, 16 Oct 2026 01:01:00 +0200
Subject: [PATCH 6/7] refactor: simplify cwv distribution metric handling and
update geo breakdown metric labels
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
src/js/techreport/cwvDistribution.js | 64 +++++++++-------------------
src/js/techreport/geoBreakdown.js | 2 +-
2 files changed, 22 insertions(+), 44 deletions(-)
diff --git a/src/js/techreport/cwvDistribution.js b/src/js/techreport/cwvDistribution.js
index 656a12ee..61f72954 100644
--- a/src/js/techreport/cwvDistribution.js
+++ b/src/js/techreport/cwvDistribution.js
@@ -16,11 +16,7 @@ class CwvDistribution {
this.chart = null;
this.root = document.querySelector(`[data-id="${this.id}"]`);
this.date = this.pageFilters.end || this.root?.dataset?.latestDate || '';
- this.selectedMetric = this.resolveMetric(
- UrlUtils.get('good-cwv-over-time') ||
- document.querySelector(`[data-id="good_cwv_timeseries"] .subcategory-selector, select[data-controls="good_cwv_timeseries"]`)?.value ||
- ''
- );
+ this.selectedMetric = this.resolveMetric(UrlUtils.get('good-cwv-over-time'));
const updateDateFromTimeseries = (newDate) => {
const dateChanged = this.date && this.date !== newDate;
@@ -30,7 +26,7 @@ class CwvDistribution {
if (dateChanged) {
this.distributionData = null;
}
- if (!this.distributionData && !this.isLoading && !this.root?.classList.contains('hidden') && this.selectedMetric) {
+ if (!this.distributionData && !this.isLoading && !this.root?.classList.contains('hidden')) {
this.fetchData();
}
};
@@ -53,10 +49,11 @@ class CwvDistribution {
if (tsSlot && this.date) tsSlot.textContent = UIUtils.printMonthYear(this.date);
}
- this.updateAvailability();
+ this.updateTitle();
+ this.updateButton();
this.bindEventListeners();
- // Auto-expand if URL hash targets this section and metric is available
+ // Auto-expand if URL hash targets this section
if (this.selectedMetric && window.location.hash === `#section-${this.id}`) {
this.toggle(true);
}
@@ -68,22 +65,10 @@ class CwvDistribution {
return null;
}
- updateAvailability() {
+ updateButton() {
const btn = document.getElementById('cwv-distribution-btn');
- if (!this.selectedMetric) {
- if (btn) {
- btn.disabled = true;
- btn.setAttribute('aria-disabled', 'true');
- btn.classList.remove('hidden');
- }
- this.toggle(false);
- } else {
- if (btn) {
- btn.disabled = false;
- btn.removeAttribute('aria-disabled');
- btn.classList.remove('hidden');
- }
- this.updateTitle();
+ if (btn) {
+ btn.disabled = !this.selectedMetric;
}
}
@@ -100,13 +85,14 @@ class CwvDistribution {
document.addEventListener('cwv-metric-change', (event) => {
const resolved = this.resolveMetric(event.detail?.value);
- const changed = resolved !== this.selectedMetric;
- this.selectedMetric = resolved;
- this.updateAvailability();
-
- if (this.selectedMetric && changed && this.root && !this.root.classList.contains('hidden')) {
- if (!this.distributionData && !this.isLoading && this.date) {
- this.fetchData();
+ if (resolved !== this.selectedMetric) {
+ this.selectedMetric = resolved;
+ this.updateTitle();
+ this.updateButton();
+ if (!this.selectedMetric) {
+ if (!this.root.classList.contains('hidden')) {
+ this.toggle(false);
+ }
} else if (this.distributionData) {
this.renderChart();
}
@@ -116,7 +102,6 @@ class CwvDistribution {
const btn = document.getElementById('cwv-distribution-btn');
if (btn) {
btn.addEventListener('click', () => {
- if (btn.disabled) return;
const isVisible = !this.root.classList.contains('hidden');
this.toggle(!isVisible);
});
@@ -126,7 +111,7 @@ class CwvDistribution {
toggle(show) {
const btn = document.getElementById('cwv-distribution-btn');
if (show) {
- if (!this.selectedMetric || btn?.disabled) return;
+ if (!this.selectedMetric) return;
this.root.classList.remove('hidden');
if (btn) btn.textContent = 'Hide histogram';
if (!this.date) {
@@ -155,14 +140,12 @@ class CwvDistribution {
url.hash = `#section-${this.id}`;
window.history.replaceState(null, null, url);
} else {
- if (this.root) this.root.classList.add('hidden');
+ this.root.classList.add('hidden');
if (btn) btn.textContent = 'Show histogram';
// Update the URL
- if (window.location.hash === `#section-${this.id}`) {
- const url = new URL(window.location.href);
- url.hash = '';
- window.history.replaceState(null, null, url);
- }
+ const url = new URL(window.location.href);
+ url.hash = '';
+ window.history.replaceState(null, null, url);
}
}
@@ -171,7 +154,6 @@ class CwvDistribution {
}
getUrl() {
- if (!this.selectedMetric) return '';
const technology = this.pageFilters.app.map(encodeURIComponent).join(',');
const rank = encodeURIComponent(this.pageFilters.rank || 'ALL');
const geo = encodeURIComponent(this.pageFilters.geo || 'ALL');
@@ -184,10 +166,6 @@ class CwvDistribution {
updateContent() {
const isVisible = this.root && !this.root.classList.contains('hidden');
- if (!this.selectedMetric) {
- if (isVisible) this.toggle(false);
- return;
- }
const currentUrl = this.getUrl();
const urlChanged = this.fetchedUrl && this.fetchedUrl !== currentUrl;
diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js
index c23b0939..02b838ec 100644
--- a/src/js/techreport/geoBreakdown.js
+++ b/src/js/techreport/geoBreakdown.js
@@ -149,7 +149,7 @@ class GeoBreakdown {
: metric === 'overall'
? [{ key: 'lcp', label: 'LCP' }, { key: 'inp', label: 'INP' }, { key: 'cls', label: 'CLS' }]
: metric === 'overall_sn'
- ? [{ key: 'lcp_sn', label: 'LCP (SN)' }, { key: 'inp_sn', label: 'INP (SN)' }, { key: 'cls_sn', label: 'CLS (SN)' }]
+ ? [{ key: 'lcp_sn', label: 'LCP' }, { key: 'inp_sn', label: 'INP' }, { key: 'cls_sn', label: 'CLS' }]
: []; // INP/CLS/individual softnav metrics: no extra columns
// If current sort column is no longer visible, reset to total
From 46413b49953ef5df96c9e411d73343d699365e4d Mon Sep 17 00:00:00 2001
From: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
Date: Fri, 16 Oct 2026 21:38:44 +0200
Subject: [PATCH 7/7] refactor: extract data point extraction logic and add
hard navigation timeseries support
Signed-off-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com>
---
src/js/techreport/timeseries.js | 134 +++++++++++++++++++++-----------
1 file changed, 87 insertions(+), 47 deletions(-)
diff --git a/src/js/techreport/timeseries.js b/src/js/techreport/timeseries.js
index 41d3a152..6886228e 100644
--- a/src/js/techreport/timeseries.js
+++ b/src/js/techreport/timeseries.js
@@ -5,6 +5,13 @@ import { DataUtils } from "./utils/data";
import { UIUtils } from "./utils/ui";
import { UrlUtils } from "./utils/url";
+const HARD_NAV_MAP = {
+ overall_sn: 'overall',
+ LCP_sn: 'LCP',
+ INP_sn: 'INP',
+ CLS_sn: 'CLS',
+};
+
class Timeseries {
// Create the component
constructor(id, pageConfig, config, filters, data) {
@@ -499,6 +506,26 @@ class Timeseries {
}
}
+ // Extract and sort date/value pairs for a given subcategory and client
+ extractPoints(appData, endpoint, categoryName, clientName, metricField) {
+ const points = [];
+ appData?.forEach(row => {
+ const categoryData = row?.[endpoint]?.find(r => r.name === categoryName);
+ const clientData = categoryData?.[clientName];
+ const y = clientData?.[metricField];
+ if (y !== null && y !== undefined && y !== '') {
+ const num = Number(y);
+ if (!isNaN(num)) {
+ points.push({
+ x: new Date(row.date).getTime(),
+ y: num,
+ });
+ }
+ }
+ });
+ return points.sort((a, b) => a.x - b.x);
+ }
+
// Format the data broken down by app
formatDataByApp() {
const series = [];
@@ -516,6 +543,9 @@ class Timeseries {
const urlSubcategory = UrlUtils.get(config.param);
const subcategory = urlSubcategory || config.default;
+ const isVitals = endpoint === 'vitals' || endpoint === 'cwv';
+ const hardSubcategory = isVitals ? HARD_NAV_MAP[subcategory] : null;
+
// Get default colors
const colors = this.defaults(config)?.chart?.colors;
@@ -526,34 +556,40 @@ class Timeseries {
const tech = app[0]?.technology || techName;
const techColor = UIUtils.getAppColor(tech, this.pageFilters.app, this.pageConfig.colors);
- const validPoints = [];
- app.forEach(row => {
- const categoryData = row?.[endpoint]?.find(r => r.name === subcategory);
- const clientData = categoryData?.[client];
- const value = clientData?.[metric];
- if (value !== null && value !== undefined && value !== '') {
- const num = Number(value);
- if (!isNaN(num)) {
- validPoints.push({
- x: new Date(row.date).getTime(),
- y: num,
- });
- }
- }
- });
-
- const sortedData = validPoints.sort((a, b) => a.x - b.x);
+ const sortedData = this.extractPoints(app, endpoint, subcategory, client, metric);
+ const seriesColor = techColor || colors[index];
series.push({
name: tech,
data: sortedData,
- color: techColor || colors[index],
+ color: seriesColor,
showInLegend: sortedData.length > 0,
- marker: {
- enabled: sortedData.length === 1 ? true : null,
- radius: 4,
- },
+ ...(sortedData.length === 1 && {
+ marker: {
+ enabled: true,
+ radius: 4,
+ },
+ }),
});
+
+ if (hardSubcategory) {
+ const sortedHardData = this.extractPoints(app, endpoint, hardSubcategory, client, metric);
+ if (sortedHardData.length > 0) {
+ series.push({
+ name: `${tech} (hard navs)`,
+ data: sortedHardData,
+ color: seriesColor,
+ dashStyle: 'Dash',
+ showInLegend: true,
+ ...(sortedHardData.length === 1 && {
+ marker: {
+ enabled: true,
+ radius: 4,
+ },
+ }),
+ });
+ }
+ }
});
return series;
@@ -571,33 +607,16 @@ class Timeseries {
const metric = this.pageConfig[this.id]?.metric;
const category = this.getCategory(config);
+ const isVitals = endpoint === 'vitals' || endpoint === 'cwv';
+ const hardCategory = isVitals ? HARD_NAV_MAP[category] : null;
+
// Get color scheme
const theme = document.querySelector('html').dataset.theme;
// Breakdown data by categories defined in config
config?.series?.values?.forEach((value, index) => {
- // Filter by selected client & sort
const appData = this.data?.[app];
-
- // Add the values for the selected metric to a new array of objects
- // Formatted as coordinates for Highcharts
- const formattedData = [];
- appData?.forEach(row => {
- const categoryData = row?.[endpoint]?.find(r => r.name === category);
- const clientData = categoryData?.[value.name];
- const y = clientData?.[metric];
- if (y !== null && y !== undefined && y !== '') {
- const num = Number(y);
- if (!isNaN(num)) {
- formattedData.push({
- x: new Date(row.date).getTime(),
- y: num,
- });
- }
- }
- });
-
- const sortedData = formattedData.sort((a, b) => a.x - b.x);
+ const sortedData = this.extractPoints(appData, endpoint, category, value.name, metric);
// Pick color from settings depending on theme
const colors = this.defaults(config)?.chart?.colors;
@@ -611,11 +630,32 @@ class Timeseries {
data: sortedData,
color: seriesColor || colors?.[index],
lineWidth: 2,
- marker: {
- enabled: sortedData.length === 1 ? true : null,
- radius: 4,
- },
+ ...(sortedData.length === 1 && {
+ marker: {
+ enabled: true,
+ radius: 4,
+ },
+ }),
});
+
+ if (hardCategory) {
+ const sortedHardData = this.extractPoints(appData, endpoint, hardCategory, value.name, metric);
+ if (sortedHardData.length > 0) {
+ series.push({
+ name: `${value.name} (hard navs)`,
+ data: sortedHardData,
+ color: seriesColor || colors?.[index],
+ lineWidth: 2,
+ dashStyle: 'Dash',
+ ...(sortedHardData.length === 1 && {
+ marker: {
+ enabled: true,
+ radius: 4,
+ },
+ }),
+ });
+ }
+ }
});
return series;