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?