docs: mark stats-redesign plan complete
This commit is contained in:
@@ -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 ~475–502
|
||||
- `.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 ~979–1008
|
||||
- `.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
|
||||
Reference in New Issue
Block a user