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;