Files
intotheeast-com/docs/working/plans/2026-06-27-home-trip-view-convergence.md
T

441 lines
24 KiB
Markdown
Raw Blame History

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