From 0846d516f819a0c332eb8967e6a387ba2cf717a1 Mon Sep 17 00:00:00 2001 From: Alon Kochba Date: Tue, 24 Mar 2026 16:12:02 +0200 Subject: [PATCH 1/2] feat: add geographic CWV breakdown table to tech report drilldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Geographic Breakdown section inside the Core Web Vitals card on the tech report drilldown page. Shows top geographies (plus ALL) by origin count with a Good CWV progress bar and sortable columns. - Bar label and extra columns adapt to selected metric: Overall → Good Core Web Vitals + LCP/INP/CLS columns LCP/FCP/TTFB → Good bar + the other two load metrics INP/CLS → Good bar only - Metric selector styled to match existing subcategory-selector pattern - Show all / Show fewer toggle below the table - Table wrapped in table-ui-wrapper for mobile horizontal scroll - Dark mode hover contrast fixed using --table-row-hover variable - Guards against non-array API responses (e.g. 404 error objects) - Respects end date filter; latest data timestamp reflects selected date --- config/techreport.json | 13 ++ src/js/techreport/geoBreakdown.js | 207 ++++++++++++++++++ src/js/techreport/section.js | 16 +- static/css/techreport/techreport.css | 83 +++++++ .../techreport/components/geo_breakdown.html | 49 +++++ templates/techreport/drilldown.html | 6 + templates/techreport/techreport.html | 1 + webpack.config.js | 1 + 8 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 src/js/techreport/geoBreakdown.js create mode 100644 templates/techreport/components/geo_breakdown.html diff --git a/config/techreport.json b/config/techreport.json index 4dfd6dce..c2dafd33 100644 --- a/config/techreport.json +++ b/config/techreport.json @@ -721,6 +721,19 @@ "title": "Weight in bytes" } } + }, + "geo_breakdown": { + "id": "geo_breakdown", + "title": "Geographic Breakdown", + "description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual LCP, INP, and CLS scores.", + "metric_options": [ + { "label": "Overall CWVs", "value": "overall" }, + { "label": "LCP", "value": "LCP" }, + { "label": "INP", "value": "INP" }, + { "label": "CLS", "value": "CLS" }, + { "label": "FCP", "value": "FCP" }, + { "label": "TTFB", "value": "TTFB" } + ] } } }, diff --git a/src/js/techreport/geoBreakdown.js b/src/js/techreport/geoBreakdown.js new file mode 100644 index 00000000..a995c721 --- /dev/null +++ b/src/js/techreport/geoBreakdown.js @@ -0,0 +1,207 @@ +import { Constants } from './utils/constants'; +import { UIUtils } from './utils/ui'; + +class GeoBreakdown { + constructor(id, pageConfig, config, filters, data) { + this.id = id; + this.pageConfig = pageConfig; + this.config = config; + this.pageFilters = filters; + this.data = data; + this.geoData = null; + this.selectedMetric = 'overall'; + this.sortColumn = 'total'; + this.sortDir = 'desc'; + this.showAll = false; + + this.bindEventListeners(); + this.fetchData(); + } + + bindEventListeners() { + const root = `[data-id="${this.id}"]`; + document.querySelectorAll(`${root} .geo-metric-selector`).forEach(dropdown => { + dropdown.addEventListener('change', event => { + this.selectedMetric = event.target.value; + if (this.geoData) this.renderTable(); + }); + }); + } + + fetchData() { + const technology = this.pageFilters.app.map(encodeURIComponent).join(','); + const rank = encodeURIComponent(this.pageFilters.rank || 'ALL'); + const end = this.pageFilters.end ? `&end=${encodeURIComponent(this.pageFilters.end)}` : ''; + const url = `${Constants.apiBase}/geo-breakdown?technology=${technology}&rank=${rank}${end}`; + + fetch(url) + .then(r => r.json()) + .then(rows => { + this.geoData = rows; + this.renderTable(); + }) + .catch(err => console.error('GeoBreakdown fetch error:', err)); + } + + updateContent() { + if (this.geoData) this.renderTable(); + } + + sortEntries(entries) { + const col = this.sortColumn; + const dir = this.sortDir === 'desc' ? -1 : 1; + return entries.slice().sort((a, b) => { + let av, bv; + if (col === 'total') { av = a.total; bv = b.total; } + else if (col === 'good_pct') { av = a.good / a.total; bv = b.good / b.total; } + else { av = a[col] != null ? a[col] : -1; bv = b[col] != null ? b[col] : -1; } + return (av - bv) * dir; + }); + } + + renderTable() { + if (!this.geoData || !this.geoData.length || this.geoData.length === 0) return; + + const component = document.querySelector(`[data-id="${this.id}"]`); + const client = component?.dataset?.client || 'mobile'; + + // Determine bar label and which extra columns to show + const metric = this.selectedMetric; + const barLabel = 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 + + // 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)) { + this.sortColumn = 'total'; + this.sortDir = 'desc'; + } + + // Pick latest date per geo, excluding "ALL" + const geoMap = {}; + let latestDate = null; + this.geoData.forEach(row => { + if (!geoMap[row.geo] || row.date > geoMap[row.geo].date) geoMap[row.geo] = row; + if (!latestDate || row.date > latestDate) latestDate = row.date; + }); + + // Fill timestamp slot + const tsSlot = component?.querySelector('[data-slot="geo-timestamp"]'); + if (tsSlot && latestDate) tsSlot.textContent = UIUtils.printMonthYear(latestDate); + + // Extract data per geo + const rawEntries = Object.entries(geoMap).map(([geo, row]) => { + const vital = row.vitals?.find(v => v.name === metric); + const d = vital?.[client] || { good_number: 0, tested: 0 }; + const pctFor = name => { + 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') }; + }).filter(e => e.total > 0); + + // Sort by origins, limit to top 10 unless showAll + const sorted = rawEntries.slice().sort((a, b) => b.total - a.total); + const limited = this.showAll ? sorted : sorted.slice(0, 10); + const entries = this.sortEntries(limited); + + const container = document.getElementById(`${this.id}-table`); + if (!container) return; + + const table = document.createElement('table'); + table.className = 'table-ui geo-breakdown-table'; + + // Header with sortable columns + const cols = [ + { key: 'geo', label: 'Geography', cls: '' }, + { key: 'total', label: 'Origins', cls: 'geo-origins-col' }, + { key: 'good_pct', label: barLabel, cls: 'geo-cwv-col' }, + ...extraCols.map(c => ({ ...c, cls: 'geo-vital-col' })), + ]; + + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + cols.forEach(col => { + const th = document.createElement('th'); + th.className = [col.cls, 'geo-sortable-col'].filter(Boolean).join(' '); + const isActive = this.sortColumn === col.key; + th.innerHTML = col.label + (isActive ? `${this.sortDir === 'desc' ? ' ▼' : ' ▲'}` : ''); + if (col.key !== 'geo') { + th.style.cursor = 'pointer'; + th.addEventListener('click', () => { + this.sortColumn = col.key; + this.sortDir = this.sortColumn === col.key && this.sortDir === 'desc' ? 'asc' : 'desc'; + this.renderTable(); + }); + } + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Body + const tbody = document.createElement('tbody'); + entries.forEach(e => { + const goodPct = Math.round(e.good / e.total * 100); + const tr = document.createElement('tr'); + + const tdGeo = document.createElement('td'); + tdGeo.className = 'geo-name-cell'; + tdGeo.textContent = e.geo; + tdGeo.title = e.geo; + tr.appendChild(tdGeo); + + const tdOrigins = document.createElement('td'); + tdOrigins.className = 'geo-origins-col numeric'; + tdOrigins.textContent = e.total.toLocaleString(); + tr.appendChild(tdOrigins); + + const tdBar = document.createElement('td'); + tdBar.className = 'cwv-cell geo-pct-cell geo-cwv-col'; + tdBar.style.setProperty('--good-stop', goodPct + '%'); + tdBar.style.setProperty('--bar-total', '1'); + tdBar.dataset.value = goodPct; + const label = document.createElement('span'); + label.className = 'geo-pct-label'; + label.textContent = goodPct + '%'; + tdBar.appendChild(label); + const bar = document.createElement('span'); + bar.className = 'geo-bar'; + bar.setAttribute('aria-hidden', 'true'); + tdBar.appendChild(bar); + tr.appendChild(tdBar); + + extraCols.forEach(({ key }) => { + const td = document.createElement('td'); + td.className = 'geo-vital-col numeric'; + td.textContent = e[key] != null ? e[key] + '%' : '—'; + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + const wrapper = document.createElement('div'); + wrapper.className = 'table-ui-wrapper'; + wrapper.appendChild(table); + const btn = document.createElement('button'); + btn.className = 'btn show-table'; + btn.type = 'button'; + btn.textContent = this.showAll ? 'Show fewer' : `Show all ${sorted.length} geographies`; + btn.addEventListener('click', () => { + this.showAll = !this.showAll; + this.renderTable(); + }); + + container.innerHTML = ''; + container.appendChild(wrapper); + container.appendChild(btn); + } +} + +window.GeoBreakdown = GeoBreakdown; diff --git a/src/js/techreport/section.js b/src/js/techreport/section.js index fa03011f..b6103657 100644 --- a/src/js/techreport/section.js +++ b/src/js/techreport/section.js @@ -1,4 +1,4 @@ -/* global Timeseries */ +/* global Timeseries, GeoBreakdown */ import SummaryCard from "./summaryCards"; import TableLinked from "./tableLinked"; @@ -33,6 +33,10 @@ class Section { this.initializeTable(component); break; + case "geoBreakdown": + this.initializeGeoBreakdown(component); + break; + default: break; } @@ -69,6 +73,16 @@ class Section { ); } + initializeGeoBreakdown(component) { + this.components[component.dataset.id] = new GeoBreakdown( + component.dataset.id, + this.pageConfig, + this.config, + this.pageFilters, + this.data + ); + } + updateSection(content) { Object.values(this.components).forEach(component => { if(component.data !== this.data) { diff --git a/static/css/techreport/techreport.css b/static/css/techreport/techreport.css index 2c75c977..f10207b8 100644 --- a/static/css/techreport/techreport.css +++ b/static/css/techreport/techreport.css @@ -1499,6 +1499,85 @@ select { line-height: 1.25em; } +/* Geographic Breakdown */ + +.geo-breakdown-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +.geo-breakdown-meta .heading { + margin: 0 0 0.25rem; +} + + +.geo-sort-arrow { + font-size: 0.7em; + opacity: 0.7; +} + +.geo-sortable-col:hover { + background: var(--table-row-hover); + cursor: pointer; +} + + +/* Geo breakdown table bar cell */ +.table-ui.geo-breakdown-table td.geo-name-cell { + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.table-ui.geo-breakdown-table th.geo-vital-col, +.table-ui.geo-breakdown-table td.geo-vital-col { + text-align: right; + padding-right: 1rem; + white-space: nowrap; + color: var(--color-text-lighter); + font-size: 0.875rem; +} + +.table-ui.geo-breakdown-table th.geo-origins-col, +.table-ui.geo-breakdown-table td.geo-origins-col { + text-align: right; + padding-right: 1.5rem; + white-space: nowrap; +} + +.table-ui.geo-breakdown-table td.geo-pct-cell { + min-width: 16rem; + white-space: nowrap; +} + +.table-ui.geo-breakdown-table td.geo-pct-cell .geo-pct-label { + display: inline-block; + width: 3rem; + font-weight: 600; + font-size: 0.875rem; + vertical-align: middle; +} + +.table-ui.geo-breakdown-table td.geo-pct-cell .geo-bar { + display: inline-block; + height: 0.5rem; + vertical-align: middle; + width: calc(var(--bar-total, 1) * (100% - 3.5rem)); + border: 1px solid var(--color-progress-basic-border); + border-radius: 3px; + background-image: linear-gradient( + 90deg, + var(--color-progress-basic-fill) 0% var(--good-stop, 0%), + var(--color-progress-basic-bg) var(--good-stop, 0%) 100% + ); +} + /* -------------------- */ /* ----- Sections ----- */ /* -------------------- */ @@ -2209,6 +2288,10 @@ path.highcharts-tick { display: none; } + .table-ui.geo-breakdown-table td.geo-pct-cell { + min-width: 8rem; + } + .card { padding: 0.75rem; } diff --git a/templates/techreport/components/geo_breakdown.html b/templates/techreport/components/geo_breakdown.html new file mode 100644 index 00000000..1f8c5f8b --- /dev/null +++ b/templates/techreport/components/geo_breakdown.html @@ -0,0 +1,49 @@ +{% set geo_breakdown_config = tech_report_page.config.geo_breakdown %} + +
+
+
+

{{ geo_breakdown_config.title }}

+

{{ geo_breakdown_config.description }}

+
+ +
+
+
+ + +
+
+
+
+ +
+
+

Latest data:

+
    +
  • Client: {{ (request.args.get('client', '') or 'Mobile') | capitalize }}
  • +
  • Rank: {{ request.args.get('rank', '') or 'ALL' }}
  • + {% set tech = request.args.get('tech', '') or 'ALL' %} +
  • Technology: {{ tech }}
  • +
+
+
+ +
+
diff --git a/templates/techreport/drilldown.html b/templates/techreport/drilldown.html index b2dc2063..32e9550e 100644 --- a/templates/techreport/drilldown.html +++ b/templates/techreport/drilldown.html @@ -68,6 +68,12 @@

{{ tech_report_page.config.good_cwv_summary.title }}

{% set selected_subcategory = request.args.get('good-cwv-over-time', '') or tech_report_page.config.good_cwv_timeseries.viz.default or 'overall' %} {% include "techreport/components/timeseries.html" %} + + {% if tech_report_page.config.geo_breakdown %} +
+ {% include "techreport/components/geo_breakdown.html" %} +
+ {% endif %} diff --git a/templates/techreport/techreport.html b/templates/techreport/techreport.html index f707cb0d..0f8cdd1e 100644 --- a/templates/techreport/techreport.html +++ b/templates/techreport/techreport.html @@ -111,6 +111,7 @@

Accessibility + diff --git a/webpack.config.js b/webpack.config.js index 74d23eb8..d1fcc493 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,6 +15,7 @@ module.exports = { 'techreport': './src/js/techreport/index.js', 'techreport/timeseries': './src/js/techreport/timeseries.js', 'techreport/section': './src/js/techreport/section.js', + 'techreport/geoBreakdown': './src/js/techreport/geoBreakdown.js', }, output: { path: path.resolve(__dirname, 'static/js'), From b539f62c1c1d866a77919deeddc9e185b9a5b304 Mon Sep 17 00:00:00 2001 From: Barry Pollard Date: Tue, 24 Mar 2026 15:18:51 +0000 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Barry Pollard --- config/techreport.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/config/techreport.json b/config/techreport.json index c2dafd33..083925b4 100644 --- a/config/techreport.json +++ b/config/techreport.json @@ -724,15 +724,15 @@ }, "geo_breakdown": { "id": "geo_breakdown", - "title": "Geographic Breakdown", - "description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual LCP, INP, and CLS scores.", + "title": "Core Web Vitals geographic breakdown", + "description": "Top geographies by number of origins, showing the percentage with good Core Web Vitals and individual metrics.", "metric_options": [ - { "label": "Overall CWVs", "value": "overall" }, - { "label": "LCP", "value": "LCP" }, - { "label": "INP", "value": "INP" }, - { "label": "CLS", "value": "CLS" }, - { "label": "FCP", "value": "FCP" }, - { "label": "TTFB", "value": "TTFB" } + { "label": "Good Core Web Vitals", "value": "overall" }, + { "label": "Good LCP", "value": "LCP" }, + { "label": "Good INP", "value": "INP" }, + { "label": "Good CLS", "value": "CLS" }, + { "label": "Good FCP", "value": "FCP" }, + { "label": "Good TTFB", "value": "TTFB" } ] } }