docs: mark stats-redesign plan complete

This commit is contained in:
2026-06-20 20:00:54 +02:00
parent 8f5ad0dae9
commit d6a7a8c3df
@@ -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 `<script>` blocks
- **CSS custom properties only** — no raw hex or pixel values; use tokens from `tokens.css`
- **6 stats must be identical** between `stats.html.twig` and the inline stats block in `trip.html.twig` — same order, same labels, same Twig logic
- `parseGpxFiles` function defined **once** in `trip.html.twig`; shared between distance Mode A update and cycling panel population
- `stats.html.twig` does **not** have a cycling panel — GPX parsing there is simpler (only for distance)
- Do **not** touch `dailies.html.twig`, `map.html.twig`, `stories.html.twig`, `entry.html.twig`, or any other template
- Commit after each task in the `user/` sub-repo (cd to `user/` before `git add` / `git commit`)
---
## Reference: Existing Files
- `user/themes/intotheeast/templates/stats.html.twig` — standalone stats page
- `user/themes/intotheeast/templates/trip.html.twig` — trip page (has inline stats block + filter bar)
- `user/themes/intotheeast/css/style.css`
- `.stats-grid` at line ~468: `grid-template-columns: repeat(2, 1fr)` — used by stats.html.twig
- `.stat-block`, `.stat-value`, `.stat-label` at lines ~475502
- `.trip-stats-grid` at line ~987: `grid-template-columns: repeat(4, 1fr)` — used by trip inline block
- `.trip-stats-block`, `.trip-stats-note`, `.trip-stats-countries` at lines ~9791008
- `.trip-stats-btn` at line ~789 — both Stats and Cycling buttons share this class
---
## The Six Stats (order matters — apply identically in both templates)
| # | Stat | Label | Source | Notes |
|---|---|---|---|---|
| 1 | Days on the road | `day/days on the road` | `date_end - date_start` if `date_end` set; else `now - first entry date` | date_end-aware |
| 2 | Entries posted | `entry/entries posted` | `all_entries\|length` | Unchanged |
| 3 | Countries visited | `country/countries visited` | Dedup `location_country` | Unchanged |
| 4 | Cities visited | `city/cities visited` | Dedup `location_city` | New |
| 5 | Distance | `km cycled` (Mode A) or `km roamed` (Mode B) | GPX trackpoints (A) or entry lat/lng (B) | Label + JS value |
| 6 | Temperature range | `°C range` | min/max `weather_temp_c` | New; value: `2 → 28` or `18` if single; `—` if no data |
**Distance stat stat-note text:**
- Mode A (GPX): `"Distance based on GPS track data."`
- Mode B (no GPX): `"Distance is approximate — straight lines between entry locations."`
**Distance stat icon (in label, as emoji prefix):**
- Mode A: `🚴 km cycled`
- Mode B: `🧭 km roamed`
---
## GPX Parsing Algorithm (for both templates)
```
Master trackpoints = []
for each GPX URL:
fetch URL → parse as XML via DOMParser
get all <trkpt> elements
for each <trkpt>:
lat = parseFloat(trkpt.getAttribute('lat'))
lon = parseFloat(trkpt.getAttribute('lon'))
ele = parseFloat(trkpt.querySelector('ele').textContent) [or NaN if missing]
time = trkpt.querySelector('time').textContent [ISO 8601 string]
push {lat, lon, ele, time} to Master
Compute over Master (length n):
distance = sum haversine(p[i-1], p[i]) for i=1..n-1 [km]
ele_gain = sum max(0, ele[i]-ele[i-1]-1) for i=1..n-1 [m, 1m threshold]
ele_loss = sum max(0, ele[i-1]-ele[i]-1) for i=1..n-1 [m, 1m threshold]
highest = max(ele) across all trackpoints [m]
lowest = min(ele) across all trackpoints [m]
dt_hrs[i] = (Date.parse(time[i]) - Date.parse(time[i-1])) / 3600000 [hours]
speed[i] = haversine(p[i-1], p[i]) / dt_hrs[i] [km/h]
moving_time = sum dt_hrs[i] where speed[i] >= 1 [hours]
avg_speed = distance / moving_time [km/h]
moving_time_fmt = floor(moving_time) + ':' + padded_minutes [h:mm]
```
Skip segments where dt_hrs[i] is 0 or NaN (avoids divide-by-zero). Skip `ele` computation for trackpoints where ele is NaN.
**Haversine function** (same as already used in trip.html.twig):
```javascript
function haversine(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));
}
```
---
## Task 1: Update `stats.html.twig` — 6-stat grid + distance mode detection
**Files:**
- Modify: `user/themes/intotheeast/templates/stats.html.twig`
- Modify: `user/themes/intotheeast/css/style.css` (`.stats-grid` only)
**What to build:**
### Twig changes in stats.html.twig
The trip page is `page.parent()`. Add after the existing Twig computation block (after the `gps_points` collection loop):
**1. Date-end-aware days on road:**
Replace the existing `first_ts`/`days_on_road` block with:
```twig
{% set trip_page = page.parent() %}
{% set days_on_road = 0 %}
{% if trip_page.header.date_end is not empty %}
{# Past trip: use declared end date #}
{% set start_ts = trip_page.header.date_start|date('U') %}
{% set end_ts = trip_page.header.date_end|date('U') %}
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
{% else %}
{# Active trip: first entry to now #}
{% set first_ts = null %}
{% for entry in all_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, same pattern):
```twig
{% set seen_city_lower = [] %}
{% set city_display = [] %}
{% for entry in all_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 all_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** (add after gps_points collection):
```twig
{% set gpx_urls = [] %}
{% for name, media in trip_page.media.all %}
{% if name|split('.')|last == 'gpx' %}
{% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %}
{% endif %}
{% endfor %}
{% set has_gpx = gpx_urls|length > 0 %}
```
### HTML changes in stats.html.twig
Replace the current 4-stat grid with a 6-stat grid in this order:
```twig
<div class="stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ entry_count }}</span>
<span class="stat-label">{{ entry_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ city_display|length }}</span>
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
</div>
<div class="stat-block">
{% if temp_min is not null %}
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
{% else %}
<span class="stat-value">—</span>
{% endif %}
<span class="stat-label">°C range</span>
</div>
</div>
```
Update the stats note (below the countries list) to be mode-sensitive:
```twig
<p class="stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
```
### JS changes in stats.html.twig
Replace the existing haversine/distance script entirely with mode-aware logic:
```javascript
<script>
var GPS_POINTS = {{ gps_points|json_encode|raw }};
var GPX_URLS = {{ gpx_urls|json_encode|raw }};
function haversine(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));
}
var distEl = document.getElementById('stat-distance');
if (GPX_URLS.length > 0) {
// Mode A: sum haversine between all GPX trackpoints
var pending = GPX_URLS.length;
var masterPts = [];
GPX_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) {
masterPts.push({
lat: parseFloat(pt.getAttribute('lat')),
lon: parseFloat(pt.getAttribute('lon'))
});
});
pending--;
if (pending === 0) {
var total = 0;
for (var i = 1; i < masterPts.length; i++) {
total += haversine(masterPts[i-1].lat, masterPts[i-1].lon,
masterPts[i].lat, masterPts[i].lon);
}
distEl.textContent = masterPts.length < 2 ? '—' : Math.round(total).toLocaleString();
}
})
.catch(function(err) { console.warn('GPX load failed:', url, err); pending--; });
});
} else {
// Mode B: sum haversine between consecutive entry lat/lng points
var total = 0;
for (var i = 1; i < GPS_POINTS.length; i++) {
total += haversine(
parseFloat(GPS_POINTS[i-1][0]), parseFloat(GPS_POINTS[i-1][1]),
parseFloat(GPS_POINTS[i][0]), parseFloat(GPS_POINTS[i][1])
);
}
distEl.textContent = GPS_POINTS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
}
</script>
```
### 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
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
</div>
```
Replace with:
```twig
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all">All content</button>
<button class="trip-filter-btn" data-filter="journal">Journal</button>
<button class="trip-filter-btn" data-filter="story">Stories</button>
</div>
<div class="trip-filter-group">
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
{% if has_gpx %}
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
{% endif %}
</div>
</div>
```
**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
<div id="trip-stats-block" class="trip-stats-block" style="display:none">
<div class="trip-stats-grid">
<div class="stat-block">
<span class="stat-value">{{ days_on_road }}</span>
<span class="stat-label">{{ days_on_road == 1 ? 'day' : 'days' }} on the road</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ journal_count }}</span>
<span class="stat-label">{{ journal_count == 1 ? 'entry' : 'entries' }} posted</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ country_display|length }}</span>
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value">{{ city_display|length }}</span>
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
</div>
<div class="stat-block">
<span class="stat-value" id="stat-distance">—</span>
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
</div>
<div class="stat-block">
{% if temp_min is not null %}
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
{% else %}
<span class="stat-value">—</span>
{% endif %}
<span class="stat-label">°C range</span>
</div>
</div>
{% if country_display|length > 0 %}
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
{% endif %}
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
</div>
```
**C. Add cycling panel** — immediately after the inline stats block, before `<div class="feed">`:
```twig
{% if has_gpx %}
<div id="trip-cycling-block" class="trip-cycling-block" style="display:none">
<div class="trip-cycling-header">
<span class="trip-cycling-icon">🚴</span>
<span class="trip-cycling-title">Cycling Stats</span>
</div>
<div class="trip-cycling-grid">
<div class="stat-block">
<span class="stat-value" id="cyc-distance">—</span>
<span class="stat-label">km distance</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-ele-gain">—</span>
<span class="stat-label">m ↑ gain</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-ele-loss">—</span>
<span class="stat-label">m ↓ loss</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-highest">—</span>
<span class="stat-label">m highest</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-lowest">—</span>
<span class="stat-label">m lowest</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-moving-time">—</span>
<span class="stat-label">moving time</span>
</div>
<div class="stat-block">
<span class="stat-value" id="cyc-avg-speed">—</span>
<span class="stat-label">km/h avg speed</span>
</div>
</div>
</div>
{% 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 `</script>`):
```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