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

29 KiB
Raw Blame History

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):

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:

{% 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):

{% 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):

{% 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):

{% 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:

<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:

<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:

<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:

.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:

@media (max-width: 600px) {
    .stats-grid { grid-template-columns: repeat(2, 1fr); }
}

Commit

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:

{% 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):

{% 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):

{% 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 detectiongpx_urls already computed in trip.html.twig; add:

{% 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:

<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:

<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:

<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">:

{% 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>):

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):

.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):

/* ── 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

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

  • Both templates show exactly 6 stats in the same order (days, entries, countries, cities, distance, temp range)
  • Distance label is server-side conditional: "🚴 km cycled" (GPX) vs "🧭 km roamed" (no GPX)
  • Stats note text is conditional matching the mode
  • GPX Mode A: fetches all GPX files, sums trackpoint haversine distances
  • GPX Mode B: sums haversine between consecutive entry lat/lng points
  • Cycling button only rendered when has_gpx is true
  • Cycling panel hidden by default; toggled by cycling button
  • Stats toggle and Cycling toggle are independent (opening one doesn't close the other)
  • parseGpxFiles called once; results used for both distance stat and cycling panel
  • Old haversine function and STATS_GPS IIFE removed and replaced in trip.html.twig
  • .stats-grid updated to 3 columns
  • .trip-stats-grid updated to 3 columns
  • Cycling panel CSS added
  • No raw hex/pixel values in CSS
  • No ES6 syntax in inline JS