# Stats Redesign — Implementation Plan *Derived from spec: docs/superpowers/specs/2026-06-19-stats-redesign.md* > **For agentic workers:** Use superpowers:subagent-driven-development to execute this plan task-by-task. **Status:** ✅ Complete (2026-06-20) **Goal:** Expand trip statistics from 4 to 6 stats (add cities visited + temperature range), add smart distance labelling (Mode A: GPX-based "km cycled" vs Mode B: entry-lat/lng "km roamed"), and add a collapsible cycling panel (only when GPX files are present) with 7 cycling-specific stats derived from GPX track data. **Architecture:** Twig server-side computation for new stats (cities, temp range, GPX detection, date_end-aware days-on-road). Client-side JS for: distance computation in both modes, GPX parsing, cycling panel population. No new pages, no Grav config changes. **Tech Stack:** Twig (Grav 2.0), vanilla JS (ES5), CSS custom properties --- ## Global Constraints - **ES5 JS only** — no `const`/`let`, no arrow functions `() =>`, no template literals `` ` `` — all scripts are inline Twig and run as plain ` ``` ### CSS change in style.css Update `.stats-grid` from 2 to 3 columns: ```css .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-8); } ``` Keep the mobile breakpoint if one exists; add one if not: ```css @media (max-width: 600px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } } ``` ### Commit ```bash cd user && git add themes/intotheeast/templates/stats.html.twig themes/intotheeast/css/style.css git commit -m "feat: expand stats page to 6 stats — cities, temp range, distance mode detection" ``` --- ## Task 2: Update `trip.html.twig` — inline stats + cycling panel **Files:** - Modify: `user/themes/intotheeast/templates/trip.html.twig` - Modify: `user/themes/intotheeast/css/style.css` **What to build:** ### Twig changes in trip.html.twig Add after the existing `{% set story_count %}` line (line ~19), mirroring Task 1's logic but using `page` directly (not `page.parent()`): **1. Date-end-aware days on road** — replace the existing `days_on_road` block: ```twig {% set days_on_road = 0 %} {% if page.header.date_end is not empty %} {% set start_ts = page.header.date_start|date('U') %} {% set end_ts = page.header.date_end|date('U') %} {% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %} {% else %} {% set first_ts = null %} {% for entry in journal_entries %} {% set ts = entry.date|date('U') %} {% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %} {% endfor %} {% if first_ts is not null %} {% set diff_seconds = "now"|date('U') - first_ts %} {% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %} {% set days_on_road = days_raw < 1 ? 1 : days_raw %} {% endif %} {% endif %} ``` **2. Cities dedup** (add after country dedup block): ```twig {% set seen_city_lower = [] %} {% set city_display = [] %} {% for entry in journal_entries %} {% if entry.header.location_city is not empty %} {% set lower = entry.header.location_city|trim|lower %} {% if lower not in seen_city_lower %} {% set seen_city_lower = seen_city_lower|merge([lower]) %} {% set city_display = city_display|merge([entry.header.location_city|trim]) %} {% endif %} {% endif %} {% endfor %} ``` **3. Temperature range** (add after cities block): ```twig {% set temp_min = null %} {% set temp_max = null %} {% for entry in journal_entries %} {% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %} {% set t = entry.header.weather_temp_c %} {% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %} {% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %} {% endif %} {% endfor %} ``` **4. GPX detection** — `gpx_urls` already computed in trip.html.twig; add: ```twig {% set has_gpx = gpx_urls|length > 0 %} ``` ### HTML changes in trip.html.twig **A. Update filter bar** — add Cycling button next to Stats button (hidden if no GPX): Find the current filter bar: ```twig
``` Replace with: ```twig
{% if has_gpx %} {% endif %}
``` **B. Update inline stats block** — expand from 4 to 6 stats (same order as Task 1): Replace the current `.trip-stats-grid` content with: ```twig ``` **C. Add cycling panel** — immediately after the inline stats block, before `
`: ```twig {% if has_gpx %} {% endif %} ``` ### JS changes in trip.html.twig The existing script block has: map setup, GPX route drawing for map, filter bar JS, stats distance + toggle JS. Make the following JS changes: **1. Replace the existing `STATS_GPS` + distance IIFE** with a unified GPX/distance function (place after the existing map + filter bar IIFE, before ``): ```javascript var STATS_GPS = {{ gps_points|json_encode|raw }}; var HAS_GPX = {{ has_gpx ? 'true' : 'false' }}; function haversineKm(lat1, lng1, lat2, lng2) { var R = 6371; var dLat = (lat2 - lat1) * Math.PI / 180; var dLng = (lng2 - lng1) * Math.PI / 180; var a = Math.sin(dLat/2)*Math.sin(dLat/2) + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)* Math.sin(dLng/2)*Math.sin(dLng/2); return R * 2 * Math.asin(Math.sqrt(a)); } function parseGpxFiles(urls, callback) { var pending = urls.length; var masterPts = []; if (pending === 0) { callback({ error: 'no files' }); return; } urls.forEach(function(url) { fetch(url) .then(function(r) { return r.text(); }) .then(function(text) { var xml = new DOMParser().parseFromString(text, 'text/xml'); var trkpts = xml.querySelectorAll('trkpt'); trkpts.forEach(function(pt) { var eleEl = pt.querySelector('ele'); var timeEl = pt.querySelector('time'); masterPts.push({ lat: parseFloat(pt.getAttribute('lat')), lon: parseFloat(pt.getAttribute('lon')), ele: eleEl ? parseFloat(eleEl.textContent) : NaN, time: timeEl ? timeEl.textContent : null }); }); pending--; if (pending === 0) { computeAndCallback(); } }) .catch(function(err) { console.warn('GPX load failed:', url, err); pending--; if (pending === 0) { computeAndCallback(); } }); }); function computeAndCallback() { var n = masterPts.length; if (n < 2) { callback({ distance: 0 }); return; } var distance = 0, eleGain = 0, eleLoss = 0; var highest = NaN, lowest = NaN, movingTime = 0; for (var i = 1; i < n; i++) { var p0 = masterPts[i-1], p1 = masterPts[i]; var d = haversineKm(p0.lat, p0.lon, p1.lat, p1.lon); distance += d; if (!isNaN(p0.ele) && !isNaN(p1.ele)) { var dEle = p1.ele - p0.ele; if (dEle > 1) eleGain += dEle - 1; else if (dEle < -1) eleLoss += (-dEle) - 1; if (isNaN(highest) || p1.ele > highest) highest = p1.ele; if (isNaN(lowest) || p1.ele < lowest) lowest = p1.ele; } if (p0.time && p1.time) { var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000; if (dtHrs > 0) { var speed = d / dtHrs; if (speed >= 1) movingTime += dtHrs; } } } // include first point in elevation range if (!isNaN(masterPts[0].ele)) { if (isNaN(highest) || masterPts[0].ele > highest) highest = masterPts[0].ele; if (isNaN(lowest) || masterPts[0].ele < lowest) lowest = masterPts[0].ele; } var avgSpeed = movingTime > 0 ? distance / movingTime : 0; var movHours = Math.floor(movingTime); var movMins = Math.round((movingTime - movHours) * 60); if (movMins === 60) { movHours++; movMins = 0; } callback({ distance: distance, eleGain: eleGain, eleLoss: eleLoss, highest: highest, lowest: lowest, movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins, avgSpeed: avgSpeed }); } } (function() { var distEl = document.getElementById('stat-distance'); if (HAS_GPX) { parseGpxFiles(GPX_URLS, function(result) { // Mode A: update distance stat if (distEl) { distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—'; } // Populate cycling panel function setText(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; } setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—'); setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—'); setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—'); setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—'); setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—'); setText('cyc-moving-time', result.movingTime || '—'); setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—'); }); } else { // Mode B: haversine between entry points var total = 0; for (var i = 1; i < STATS_GPS.length; i++) { total += haversineKm( parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]), parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1]) ); } if (distEl) { distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString(); } } // Stats toggle var statsToggle = document.getElementById('trip-stats-toggle'); var statsBlock = document.getElementById('trip-stats-block'); if (statsToggle && statsBlock) { statsToggle.addEventListener('click', function() { var isOpen = statsBlock.style.display !== 'none'; statsBlock.style.display = isOpen ? 'none' : ''; statsToggle.classList.toggle('is-active', !isOpen); }); } // Cycling toggle (only present when has_gpx) var cycToggle = document.getElementById('trip-cycling-toggle'); var cycBlock = document.getElementById('trip-cycling-block'); if (cycToggle && cycBlock) { cycToggle.addEventListener('click', function() { var isOpen = cycBlock.style.display !== 'none'; cycBlock.style.display = isOpen ? 'none' : ''; cycToggle.classList.toggle('is-active', !isOpen); }); } })(); ``` **Important:** Remove the old `STATS_GPS` declaration and the old stats IIFE that's currently in the template (the one starting with `var STATS_GPS = ...`), replacing it entirely with the new unified block above. The `haversine` function used by `MapUtils.addJourneyLine` is in `maplibre-utils.js` — the new `haversineKm` function in this script is a local copy for stats; do not remove any map-related code. ### CSS changes in style.css **1. Update `.trip-stats-grid`** from 4 to 3 columns (3 columns × 2 rows = 6 stats): ```css .trip-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-4); } ``` **2. Add cycling panel styles** (after the existing `.trip-stats-note` rule): ```css /* ── Trip page cycling panel ─────────────────────────────────────────────────── */ .trip-cycling-block { background: var(--color-canvas); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-6); margin-bottom: var(--space-6); } .trip-cycling-header { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-4); } .trip-cycling-icon { font-size: var(--text-xl); } .trip-cycling-title { font-family: var(--font-display); font-size: var(--text-lg); color: var(--color-ink); } .trip-cycling-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); } @media (max-width: 600px) { .trip-cycling-grid { grid-template-columns: repeat(2, 1fr); } } ``` ### Commit ```bash cd user && git add themes/intotheeast/templates/trip.html.twig themes/intotheeast/css/style.css git commit -m "feat: expand trip inline stats to 6 stats + add cycling panel with GPX parsing" ``` --- ## Self-Review Checklist - [x] Both templates show exactly 6 stats in the same order (days, entries, countries, cities, distance, temp range) - [x] Distance label is server-side conditional: "🚴 km cycled" (GPX) vs "🧭 km roamed" (no GPX) - [x] Stats note text is conditional matching the mode - [x] GPX Mode A: fetches all GPX files, sums trackpoint haversine distances - [x] GPX Mode B: sums haversine between consecutive entry lat/lng points - [x] Cycling button only rendered when `has_gpx` is true - [x] Cycling panel hidden by default; toggled by cycling button - [x] Stats toggle and Cycling toggle are independent (opening one doesn't close the other) - [x] `parseGpxFiles` called once; results used for both distance stat and cycling panel - [x] Old haversine function and STATS_GPS IIFE removed and replaced in trip.html.twig - [x] `.stats-grid` updated to 3 columns - [x] `.trip-stats-grid` updated to 3 columns - [x] Cycling panel CSS added - [x] No raw hex/pixel values in CSS - [x] No ES6 syntax in inline JS