Files
intotheeast-com/docs/working/plans/2026-06-19-stats-redesign.md
T

737 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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