diff --git a/docs/superpowers/plans/2026-06-19-stats-redesign.md b/docs/superpowers/plans/2026-06-19-stats-redesign.md new file mode 100644 index 0000000..d2b2480 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-stats-redesign.md @@ -0,0 +1,736 @@ +# 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