docs: add home/trip convergence plan (complete) and update design spec

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
This commit is contained in:
2026-06-27 22:35:43 +02:00
co-authored by Claude Opus 4.8
parent dd3c89e88a
commit 567ea7bb89
2 changed files with 459 additions and 2 deletions
@@ -0,0 +1,440 @@
# Home / Trip View Convergence Implementation Plan
**Status:** ✅ Complete (2026-06-27)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the home page's active-trip view present the same feed-col chrome (date range, filter bar, stats/cycling panels) as the trip page, by extracting the chrome into one shared Twig partial and the stats computation into one shared JS function.
**Architecture:** A new partial `templates/partials/trip-feed-col.html.twig` holds the entire `.home-feed-col` markup (header, filter bar, panel toggles, stats/cycling macro calls, feed loop) and is included by both `trip.html.twig` and `home.html.twig` (active branch). The inline stats/cycling computation currently in `trip.html.twig` becomes a window-exposed `initTripStats(config)` in `js/src/main.js`; the partial emits a small `DOMContentLoaded` inline script that calls it with page-specific data. The two intended differences (home has no sort button and keeps its own feed order) are driven by partial params, not separate markup.
**Tech Stack:** Grav 2.0 / Twig templates, esbuild-bundled vanilla JS (`js/src/main.js``js/main.js`), MapLibre via `map.js` (`window.MapUtils`).
## Global Constraints
- **Only ever write changes inside `travel-blog-intotheeast/` or subfolders.** The `user/` tree is a standalone git repo synced via `make content-push`; commit there as instructed by the execution skill.
- **Dev mode stays dev** — `twig.cache: false` is already set. Do NOT toggle any dev/prod config flag to work around caching; theme edits take effect on reload.
- **No map convergence.** Both inline map `<script>` blocks and both `.home-map-col` markup blocks stay exactly as they are. Do not touch map markers, fullscreen wiring, or map data-build loops.
- **No visual restyling.** Home reuses the trip's existing CSS classes unchanged. No new CSS class names except the pre-departure divider (`home-predeparture-divider`) and reuse of existing `home-highlights-cta` / `home-highlights-cta-wrap` for the pre-departure button.
- **No new JS for filter/sort/panels** — `initFilterBar()`, `initPanelToggles()`, `initSortButton()` are already global and selector-guarded. Only `initTripStats` is new.
- **Trip page rendered output must be visually and functionally identical** to before for the populated and empty cases — exact bytes may differ (the partial re-indents the feed-col markup, and the stats logic moves into a relocated inline `<script>`). Structural refactor only on that side; verify by behavioral smoke test, not a literal diff.
- **Built JS is generated** — never hand-edit `js/main.js`; edit `js/src/main.js` and rebuild with `make build-assets`.
- Dev server: `http://localhost:8081`. All verification is manual browser smoke testing (no JS test harness exists).
## File Structure
| File | Responsibility |
|---|---|
| `user/themes/intotheeast/js/src/main.js` (edit) | Add `initTripStats(config)`; expose on `window`. Rebuild → `js/main.js`. |
| `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` (new) | The entire shared `.home-feed-col`: header, filter bar (sort button gated), panel toggles, stats/cycling macro calls, feed loop, pre-departure block, and the inline `initTripStats` call. |
| `user/themes/intotheeast/templates/trip.html.twig` (edit) | Replace inline `.home-feed-col` (`:70-120`) with the partial include; remove inline stats script (`:213-249`). Map untouched. |
| `user/themes/intotheeast/templates/home.html.twig` (edit) | Active branch: add `gps_points` build; replace bespoke feed-col (`:60-83`) with the partial include (`show_sort: false`, `pre_departure` gated). Map untouched. Between-trips branch untouched. |
---
## Task 1: Shared stats glue `initTripStats(config)` in main.js
**Files:**
- Modify: `user/themes/intotheeast/js/src/main.js` (add function near the other init functions, ~after `initPanelToggles` at `:239`; expose on `window`)
- Rebuild artifact: `user/themes/intotheeast/js/main.js` (via `make build-assets`)
**Interfaces:**
- Consumes: `window.MapUtils.parseGpxFiles(urls, cb)`, `window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)` (from `map.js`, loaded in the `bottom` asset group).
- Produces: `window.initTripStats(config)` where `config = { gpxUrls: string[], gpsPoints: [number,number][], hasGpx: boolean }`. Selector-guarded: no-op when `#stat-distance` is absent. No-GPX fallback writes `'—'` (not `~0`) and returns when `gpsPoints.length < 2`. This is the exact contract the partial's inline script (Task 2) and both templates (Tasks 34) rely on.
- [ ] **Step 1: Add the `initTripStats` function**
In `user/themes/intotheeast/js/src/main.js`, immediately after the `initPanelToggles` function (after line 239, before the `/* ── Boot ── */` comment), add:
```js
/* ── Trip stats / cycling computation (trip + home-active) ───
config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
No-op if #stat-distance is absent (page rendered no stats panel).
No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0'). */
function initTripStats(config) {
var distEl = document.getElementById('stat-distance');
if (!distEl) return;
var gpxUrls = config.gpxUrls || [];
var gpsPoints = config.gpsPoints || [];
if (config.hasGpx) {
MapUtils.parseGpxFiles(gpxUrls, function (result) {
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
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 {
if (gpsPoints.length < 2) {
distEl.textContent = '—';
return;
}
var total = 0;
for (var i = 1; i < gpsPoints.length; i++) {
total += MapUtils.haversineKm(
parseFloat(gpsPoints[i-1][0]), parseFloat(gpsPoints[i-1][1]),
parseFloat(gpsPoints[i][0]), parseFloat(gpsPoints[i][1])
);
}
distEl.textContent = '~' + Math.round(total).toLocaleString();
}
}
window.initTripStats = initTripStats;
```
Note: the function is **not** added to the `DOMContentLoaded` boot block — it is called per-page from the partial's inline script (Task 2) with page-specific config. `window.initTripStats =` is required because `main.js` is bundled as an IIFE, so the function is otherwise not reachable from inline template scripts.
- [ ] **Step 2: Rebuild the JS bundle**
Run: `make build-assets`
Expected: completes without esbuild errors; `user/themes/intotheeast/js/main.js` is regenerated.
- [ ] **Step 3: Verify the function is exposed in the built bundle**
Run: `grep -c "initTripStats" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/js/main.js`
Expected: a non-zero count (the minified bundle contains the symbol).
- [ ] **Step 4: Smoke-test that existing pages still work (no regression from the additive change)**
Load `http://localhost:8081/trips/japan-korea-2026` (or the active trip) in a browser. The trip page still uses its own inline stats script at this point, so stats should populate exactly as before. Open the console and confirm **no errors** and that `typeof window.initTripStats === 'function'`.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add js/src/main.js js/main.js
git commit -m "feat(theme): add shared initTripStats() for trip+home stats panels"
```
---
## Task 2: Shared partial `trip-feed-col.html.twig`
**Files:**
- Create: `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
**Interfaces:**
- Consumes (params, passed via `{% include 'partials/trip-feed-col.html.twig' with {…} only %}`):
| Param | Type | Trip passes | Home-active passes |
|---|---|---|---|
| `trip_page` | Page | `page` | `trip` |
| `all_items` | array | sorted by date, flag 4 | sorted by date, flag 3 |
| `journal_entries` | array | dailies children | dailies children |
| `journal_count` | int | count | count |
| `story_count` | int | count | count |
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
| `gpx_urls` | array | `gpx_urls` | `home_gpx_urls` |
| `gps_points` | array | `gps_points` | `gps_points` (new on home, Task 4) |
| `show_sort` | bool | `true` | `false` |
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
`gpx_urls` and `gps_points` are added to the spec's interface table as the agreed implementation choice: the partial emits the `initTripStats` inline call itself (single place), so it needs the page-specific data.
- Consumes globally: `window.initTripStats` (Task 1), `window.MapUtils` (map.js), CSS classes from the existing theme.
- Produces: the `.home-feed-col` DOM that `initFilterBar` / `initPanelToggles` / `initSortButton('trip-sort-toggle', …)` already key off (`.trip-filter-btn`, `[data-type]`, `.trip-panel-toggle`, `#feed-filter-empty`, `#trip-sort-toggle`).
- [ ] **Step 1: Create the partial file**
Create `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig` with exactly:
```twig
{% import 'macros/stats.html.twig' as stats_m %}
{% import 'macros/cycling.html.twig' as cycling_m %}
<div class="home-feed-col">
{% if pre_departure %}
{# ── Pre-departure landing state (home-active only) ──────────── #}
<div class="home-trip-header">
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
{% if trip_page.header.date_start %}
<p class="trip-dates">Departing {{ trip_page.header.date_start|date('d M Y') }}</p>
{% endif %}
<span class="home-trip-counts">Coming soon</span>
</div>
<div class="feed">
<hr class="home-predeparture-divider">
<p class="feed-empty">The journey hasn't begun yet — check back once we're on the road.</p>
<div class="home-highlights-cta-wrap">
<a class="home-highlights-cta" href="/trips">In the meantime, explore my other trips →</a>
</div>
</div>
{% else %}
<div class="home-trip-header">
<h1 class="home-trip-name">{{ trip_page.title }}</h1>
{% if trip_page.header.date_start %}
<p class="trip-dates">
{{ trip_page.header.date_start|date('d M Y') }}
{% if trip_page.header.date_end %}{{ trip_page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}
</p>
{% endif %}
<span class="home-trip-counts">
{{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }}
{% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
</span>
<div class="trip-filter-bar">
<div class="trip-filter-group">
<button class="trip-filter-btn is-active" data-filter="all" aria-pressed="true">All content</button>
<button class="trip-filter-btn" data-filter="journal" aria-pressed="false">Journal</button>
<button class="trip-filter-btn" data-filter="story" aria-pressed="false">Stories</button>
</div>
{% if show_sort %}
<button class="trip-stats-btn" id="trip-sort-toggle" aria-label="Sort: oldest first">↑</button>
{% endif %}
</div>
<div class="trip-panel-toggles">
<button class="trip-panel-toggle" id="trip-stats-toggle" aria-expanded="false" aria-controls="trip-stats-block">Stats <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% if has_gpx %}
<button class="trip-panel-toggle" id="trip-cycling-toggle" aria-expanded="false" aria-controls="trip-cycling-block">Cycling <span class="trip-panel-caret" aria-hidden="true">▾</span></button>
{% endif %}
</div>
</div>
{{ stats_m.stats_panel(journal_entries, trip_page, journal_count, has_gpx) }}
{% if has_gpx %}
{{ cycling_m.cycling_panel() }}
{% endif %}
<div class="feed">
{% if all_items|length > 0 %}
{% for item in all_items %}
{% set entry = item.page %}
{% if item.type == 'journal' %}
{% include 'partials/entry-journal.html.twig' %}
{% else %}
{% include 'partials/entry-story.html.twig' %}
{% endif %}
{% endfor %}
{% else %}
<p class="feed-empty">No entries yet. The journey is about to begin.</p>
{% endif %}
<p id="feed-filter-empty" class="feed-empty" style="display:none;"></p>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
initTripStats({
gpxUrls: {{ gpx_urls|json_encode|raw }},
gpsPoints: {{ gps_points|json_encode|raw }},
hasGpx: {{ has_gpx ? 'true' : 'false' }}
});
});
</script>
{% endif %}
</div>
```
Notes baked into this markup:
- The non-pre-departure feed keeps the `{% else %}` "No entries yet" fallback so the trip page's empty-case output is unchanged (trip always passes `pre_departure: false`). Home never reaches this fallback because home-empty sets `pre_departure: true`.
- The `initTripStats` call is wrapped in `DOMContentLoaded` so `window.initTripStats` and `window.MapUtils` (both in the `bottom` asset group rendered at the end of `<body>`) are defined when it runs.
- The call is **not** nested inside any map-entries condition, so a trip with GPX but zero geocoded journal entries still populates the panels.
- The partial is included with `only`, so it imports the `stats`/`cycling` macros itself.
- [ ] **Step 2: Verify Twig syntax compiles (no include yet, so render via a temporary check)**
The partial isn't referenced anywhere yet, so it can't render on its own. Verify there are no obvious Twig errors by confirming the file is well-formed:
Run: `grep -c "endif\|endfor\|endmacro" /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
Expected: non-zero (sanity check the file saved). Real verification happens in Task 3 when the trip page includes it.
- [ ] **Step 3: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/partials/trip-feed-col.html.twig
git commit -m "feat(theme): add shared trip-feed-col partial"
```
---
## Task 3: Refactor `trip.html.twig` to use the partial
**Files:**
- Modify: `user/themes/intotheeast/templates/trip.html.twig` (replace `:70-120`; remove `:213-249`)
**Interfaces:**
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
- Produces: visually and functionally identical trip-page output (regression-critical) — exact bytes may differ (re-indented markup, relocated stats `<script>`); confirm via the behavioral checks in Step 3, not a literal diff. The trip page already builds `all_items` (flag 4), `journal_entries`, `journal_count`, `story_count`, `gps_points`, `gpx_urls`, `has_gpx` — all passed straight through.
- [ ] **Step 1: Replace the inline `.home-feed-col` block with the include**
In `user/themes/intotheeast/templates/trip.html.twig`, replace the entire block from line 70 (` <div class="home-feed-col">`) through line 120 (` </div>`, the closing of `.home-feed-col`) with:
```twig
{% include 'partials/trip-feed-col.html.twig' with {
trip_page: page,
all_items: all_items,
journal_entries: journal_entries,
journal_count: journal_count,
story_count: story_count,
has_gpx: has_gpx,
gpx_urls: gpx_urls,
gps_points: gps_points,
show_sort: true,
pre_departure: false
} only %}
```
Leave the surrounding `<div class="home-layout">` and `.home-map-col` block (lines 5868) and the closing `</div>` of `.home-layout` (line 121) intact.
- [ ] **Step 2: Remove the inline stats script**
In the same file, delete the inline stats block — from line 213 (`var STATS_GPS = …`) through line 249 (the closing `})();` of the stats IIFE), inclusive. Specifically remove:
```twig
var STATS_GPS = {{ gps_points|json_encode|raw }};
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
(function() {
var distEl = document.getElementById('stat-distance');
if (HAS_GPX) {
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
...
});
} else {
var total = 0;
...
}
})();
```
The map `<script>`'s `document.addEventListener('DOMContentLoaded', function() { … });` wrapper and its closing `}); // DOMContentLoaded` (line 251) stay — only the stats portion inside it is removed. The map setup, marker loop, fitBounds, `renderGpxJourney`, and the fullscreen IIFE (`:201-211`) remain untouched.
- [ ] **Step 3: Reload and regression-test the trip page**
Load `http://localhost:8081/trips/japan-korea-2026` (active trip with content). Confirm:
- Header, date range, counts render as before.
- Filter bar **with** the sort button (`↑`) is present.
- Stats panel toggles open; distance populates (GPX → exact number; no GPX → `~`-prefixed estimate).
- If the trip has GPX: Cycling toggle present and its panel populates.
- Feed lists journal + stories, default order oldest→newest (flag 4, unchanged).
- Filter All/Journal/Stories works; sort button flips order.
- Console shows no errors.
- [ ] **Step 4: Verify the map is unaffected**
On the same page, confirm the map renders with markers, fits bounds, draws the GPX/journey route, and the mobile fullscreen button still works (resize on toggle). Marker click still scrolls to and flashes the card.
- [ ] **Step 5: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/trip.html.twig
git commit -m "refactor(theme): trip.html.twig uses shared trip-feed-col partial + initTripStats"
```
---
## Task 4: Wire `home.html.twig` active branch to the partial
**Files:**
- Modify: `user/themes/intotheeast/templates/home.html.twig` (active branch: add `gps_points` build at `:30-31`; replace `:60-83`)
**Interfaces:**
- Consumes: the partial from Task 2, `window.initTripStats` from Task 1.
- Produces: home-active now renders date range, filter bar (no sort button), and stats/cycling panels, plus the pre-departure block when no entries exist. The between-trips branch (`{% else %}`) is untouched and does not use the partial.
- [ ] **Step 1: Add the `gps_points` build (no-GPX stats fallback)**
In `user/themes/intotheeast/templates/home.html.twig`, in the active-trip branch, after the counts at line 30 (`{% set story_count = story_entries|length %}`) and before the `map_entries` build (line 32), insert:
```twig
{% set gps_points = [] %}
{% for entry in journal_entries %}
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
{% set gps_points = gps_points|merge([[entry.header.lat, entry.header.lng]]) %}
{% endif %}
{% endfor %}
```
This mirrors `trip.html.twig:27-32`.
- [ ] **Step 2: Replace the bespoke feed-col with the include**
In the same file, replace the entire `<div class="home-feed-col">` block from line 60 through its closing `</div>` at line 83 with:
```twig
{% include 'partials/trip-feed-col.html.twig' with {
trip_page: trip,
all_items: all_items,
journal_entries: journal_entries,
journal_count: journal_count,
story_count: story_count,
has_gpx: home_gpx_urls|length > 0,
gpx_urls: home_gpx_urls,
gps_points: gps_points,
show_sort: false,
pre_departure: all_items|length == 0
} only %}
```
Leave `<div class="home-layout">` and the `.home-map-col` block (lines 5558) and the closing `</div>` of `.home-layout` (line 84) intact. The map `<script>` block (lines 86139, gated by `map_entries|length > 0`) stays untouched.
- [ ] **Step 3: Reload and test home-active (with content)**
Ensure `config.site.travelling: true` and the active trip has posts. Load `http://localhost:8081/`. Confirm:
- Date range, counts, and filter bar appear — **no** sort button.
- Stats panel toggles open and distance populates (`~` estimate from `gps_points` when no GPX; exact when GPX present); Cycling panel appears and populates only if the trip has GPX.
- Filter All/Journal/Stories works; panel toggles work.
- Feed default order is home's own (flag 3, unchanged from today).
- Console shows no errors; map still renders.
- [ ] **Step 4: Test the pre-departure empty state**
With `travelling: true` and **no posts** in the active trip's `dailies`/`stories` (temporarily, or on a fresh trip), load `/`. Confirm:
- The pre-departure block shows the trip title, "Departing &lt;date&gt;", "Coming soon", a divider, and the "In the meantime, explore my other trips →" button linking to `/trips`.
- The filter bar, panel toggles, and the generic "No entries yet" fallback do **not** appear.
- After posting one entry (or restoring content), the pre-departure block disappears and the normal filter bar + feed render.
- [ ] **Step 5: Regression-test between-trips mode**
Set `config.site.travelling: false`, load `/`. Confirm the highlights grid layout is unchanged (this branch does not use the partial). Restore `travelling: true` afterward if that is the intended dev state.
- [ ] **Step 6: Commit**
```bash
cd /home/mischa/Nextcloud/Projects/travel-blog-intotheeast/user/themes/intotheeast
git add templates/home.html.twig
git commit -m "feat(theme): home-active reuses trip-feed-col partial with stats + pre-departure state"
```
---
## Self-Review
**Spec coverage:**
- Date-range header, filter bar, stats/cycling panels on home-active → Tasks 2 + 4. ✅
- Chrome in one place (partial) → Task 2; both pages include it → Tasks 3, 4. ✅
- Home keeps own order, no sort button → `show_sort: false`, `all_items` flag 3 unchanged (Task 4). ✅
- Stats/cycling from single shared JS → Task 1 (`initTripStats`), called via partial. ✅
- No visual/functional change to trip output (exact bytes may differ: re-indented markup, relocated stats `<script>`) → Task 3 passes through existing vars; partial preserves the empty-case `{% else %}` fallback. ✅
- Stats glue runs in `DOMContentLoaded`, not nested in map block, `<2`-points guard writes `—` → Task 1 + partial script. ✅
- Home `gps_points` build added → Task 4 Step 1. ✅
- Pre-departure block (title + start date + "Coming soon" + divider + button; suppresses filter bar/fallback; panels hidden) → Task 2 markup + Task 4 gating. ✅
- Map convergence out of scope; both map blocks untouched → Tasks 3, 4 leave map markup/scripts intact. ✅
- Between-trips branch untouched → Task 4 only edits the active branch. ✅
**Placeholder scan:** No TBD/TODO/"handle edge cases" — every step has concrete code or an exact command. ✅
**Type consistency:** `initTripStats` config keys (`gpxUrls`, `gpsPoints`, `hasGpx`) match between Task 1 (definition), the partial's inline call (Task 2), and the data both pages pass (Tasks 3, 4). Partial param names match the include calls in both templates. `gpx_urls`/`gps_points`/`has_gpx` consistent throughout. ✅
---
**Plan complete and saved to `docs/working/plans/2026-06-27-home-trip-view-convergence.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?
@@ -60,6 +60,7 @@ Holds the entire `.home-feed-col` content currently inline in `trip.html.twig:70
| `story_count` | int | count | count | | `story_count` | int | count | count |
| `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` | | `has_gpx` | bool | `gpx_urls\|length > 0` | `home_gpx_urls\|length > 0` |
| `show_sort` | bool | `true` | `false` | | `show_sort` | bool | `true` | `false` |
| `pre_departure` | bool | `false` | `all_items\|length == 0` |
Because the partial is called with `only`, it must `{% import 'macros/stats.html.twig' %}` and `{% import 'macros/cycling.html.twig' %}` itself. Because the partial is called with `only`, it must `{% import 'macros/stats.html.twig' %}` and `{% import 'macros/cycling.html.twig' %}` itself.
@@ -67,16 +68,17 @@ Both pages already build `all_items`, the counts, and `has_gpx` for their existi
### 2. Shared stats glue: `initTripStats(config)` in `js/src/main.js` ### 2. Shared stats glue: `initTripStats(config)` in `js/src/main.js`
Extract the inline stats/cycling computation from `trip.html.twig:213-249` into a config-driven function. Current inline logic: if GPX present, `MapUtils.parseGpxFiles(urls, …)` fills `#stat-distance` and all `#cyc-*` fields; otherwise sum `haversineKm` over `gps_points` and write the `~`-prefixed estimate to `#stat-distance`. Extract the inline stats/cycling computation from `trip.html.twig:213-249` into a config-driven function. Current inline logic: if GPX present, `MapUtils.parseGpxFiles(urls, …)` fills `#stat-distance` and all `#cyc-*` fields; otherwise sum `haversineKm` over `gps_points` and write the `~`-prefixed estimate to `#stat-distance` — but when `gps_points` has fewer than 2 points, write `—` (not `~0`) and return, preserving the existing guard at `trip.html.twig:245`. This matters on home-active in the pre-departure / zero-entry state, where dropping the guard would render "~0 km roamed" instead of the macro's `—` placeholder.
```js ```js
function initTripStats(config) { function initTripStats(config) {
// config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool } // config: { gpxUrls: [], gpsPoints: [[lat,lng],...], hasGpx: bool }
// No-op if #stat-distance is absent (page has no stats panel). // No-op if #stat-distance is absent (page has no stats panel).
// No-GPX fallback: if gpsPoints.length < 2, write '—' and return (no '~0').
} }
``` ```
Called from the boot block alongside the other inits. Each page provides the config via a small inline `<script>` that defines the data (the `*_GPX_URLS` / `gps_points` arrays are page-specific Twig output), then calls `initTripStats(...)` — or the boot reads globals the page sets. Implementation detail for the plan; the contract is: the function is selector-guarded and runs on any page that rendered a stats panel. Called from the boot block alongside the other inits. Each page provides the config via a small inline `<script>` that defines the data (the `*_GPX_URLS` / `gps_points` arrays are page-specific Twig output), then calls `initTripStats(...)` — or the boot reads globals the page sets. Implementation detail for the plan; the contract is: the function is selector-guarded and runs on any page that rendered a stats panel. Two placement invariants the current working code relies on must carry forward: the inline call **must run inside a `DOMContentLoaded` handler** (mirroring the current `trip.html.twig` stats IIFE) so that `initTripStats` and `MapUtils` — loaded via the `bottom` asset group rendered at the end of `<body>`, after content-block inline scripts — are defined when it executes; and it **must not be nested inside the `{% if map_entries|length > 0 %}` map block**, or a trip with GPX but zero geocoded journal entries would render the panels yet never populate them.
`js/main.js` is the built artifact; the asset pipeline rebuilds it from `js/src/main.js` (see `2026-06-22-asset-pipeline-design.md`). `js/main.js` is the built artifact; the asset pipeline rebuilds it from `js/src/main.js` (see `2026-06-22-asset-pipeline-design.md`).
@@ -90,6 +92,20 @@ Home-active currently builds `map_entries` (journal-only) and `home_gpx_urls`. F
**`home.html.twig`** (active branch): replace the bespoke feed-col (`:60-83`) with the same partial include (`show_sort: false`); add `gps_points` build; wire `initTripStats(...)`. Map script and map-col stay untouched. **`home.html.twig`** (active branch): replace the bespoke feed-col (`:60-83`) with the same partial include (`show_sort: false`); add `gps_points` build; wire `initTripStats(...)`. Map script and map-col stay untouched.
### 5. Home-active pre-departure empty state
Resolves the review finding that home-active (the landing page) would otherwise show two competing empty states before the first post — the static `{% else %}` "No entries yet" feed fallback *and* the JS `#feed-filter-empty` sentinel — once a filter tab is clicked.
When `all_items` is empty (gated by the new `pre_departure` param), the partial renders a single **pre-departure block** instead of the filter bar, panel toggles, and the generic feed fallback:
- the active trip's title and `trip_page.header.date_start` (e.g. "Departing 17 Jun 2026"), with a "Coming soon" note
- a clear divider
- a short line + button — "In the meantime, explore my other trips →" — linking to the Past Trips page
This block renders **only while `all_items|length == 0`** and disappears entirely once the first entry is posted, at which point the normal filter bar + feed render. It is home-active-only: the trip page passes `pre_departure: false` and is unaffected (it is not reachable before content exists). The block is new home-only markup but reuses existing typography/button classes — no new visual language.
Open sub-decision for implementation: whether the Stats/Cycling panels are also hidden in this state (they would otherwise show "0 days / 0 entries"). Defaulting to hidden, for consistency with the suppressed filter bar.
## Data / behavior flow after change ## Data / behavior flow after change
``` ```
@@ -117,6 +133,7 @@ No JS test harness exists in this project; verification is manual browser smoke
4. With `config.site.travelling: true`, load `/`. Confirm date range, counts, filter bar (no sort button), and Stats panel (+ Cycling if the trip has GPX) now appear. 4. With `config.site.travelling: true`, load `/`. Confirm date range, counts, filter bar (no sort button), and Stats panel (+ Cycling if the trip has GPX) now appear.
5. Filter bar filters the feed; panel toggles work; stats figures populate. 5. Filter bar filters the feed; panel toggles work; stats figures populate.
6. Confirm the feed default order is home's own order (unchanged from today) and that no sort button is present. 6. Confirm the feed default order is home's own order (unchanged from today) and that no sort button is present.
6b. **Pre-departure state:** with `travelling: true` and no posts yet, confirm home shows the trip title + start date + "Coming soon" and the "explore my other trips" divider/button — and that the filter bar and the "No entries yet" fallback do *not* both appear. Post one entry and confirm the pre-departure block disappears and the normal filter bar + feed render.
**Home page, between-trips mode (regression):** **Home page, between-trips mode (regression):**
7. With `config.site.travelling: false`, load `/`. Confirm the highlights layout is unaffected (this branch does not use the partial). 7. With `config.site.travelling: false`, load `/`. Confirm the highlights layout is unaffected (this branch does not use the partial).