Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BftDn9vu9SonFAY4vxu4uk
24 KiB
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. Theuser/tree is a standalone git repo synced viamake content-push; commit there as instructed by the execution skill. - Dev mode stays dev —
twig.cache: falseis 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-colmarkup 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 existinghome-highlights-cta/home-highlights-cta-wrapfor the pre-departure button. - No new JS for filter/sort/panels —
initFilterBar(),initPanelToggles(),initSortButton()are already global and selector-guarded. OnlyinitTripStatsis 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; editjs/src/main.jsand rebuild withmake 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, ~afterinitPanelTogglesat:239; expose onwindow) - Rebuild artifact:
user/themes/intotheeast/js/main.js(viamake build-assets)
Interfaces:
-
Consumes:
window.MapUtils.parseGpxFiles(urls, cb),window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)(frommap.js, loaded in thebottomasset group). -
Produces:
window.initTripStats(config)whereconfig = { gpxUrls: string[], gpsPoints: [number,number][], hasGpx: boolean }. Selector-guarded: no-op when#stat-distanceis absent. No-GPX fallback writes'—'(not~0) and returns whengpsPoints.length < 2. This is the exact contract the partial's inline script (Task 2) and both templates (Tasks 3–4) rely on. -
Step 1: Add the
initTripStatsfunction
In user/themes/intotheeast/js/src/main.js, immediately after the initPanelToggles function (after line 239, before the /* ── Boot ── */ comment), add:
/* ── 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
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_pagePage pagetripall_itemsarray sorted by date, flag 4 sorted by date, flag 3 journal_entriesarray dailies children dailies children journal_countint count count story_countint count count has_gpxbool gpx_urls|length > 0home_gpx_urls|length > 0gpx_urlsarray gpx_urlshome_gpx_urlsgps_pointsarray gps_pointsgps_points(new on home, Task 4)show_sortbool truefalsepre_departurebool falseall_items|length == 0gpx_urlsandgps_pointsare added to the spec's interface table as the agreed implementation choice: the partial emits theinitTripStatsinline 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-colDOM thatinitFilterBar/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:
{% 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 passespre_departure: false). Home never reaches this fallback because home-empty setspre_departure: true. -
The
initTripStatscall is wrapped inDOMContentLoadedsowindow.initTripStatsandwindow.MapUtils(both in thebottomasset 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 thestats/cyclingmacros 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
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.initTripStatsfrom 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 buildsall_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-colblock 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:
{% 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 58–68) 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:
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
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: addgps_pointsbuild at:30-31; replace:60-83)
Interfaces:
-
Consumes: the partial from Task 2,
window.initTripStatsfrom 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_pointsbuild (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:
{% 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:
{% 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 55–58) and the closing </div> of .home-layout (line 84) intact. The map <script> block (lines 86–139, 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 fromgps_pointswhen 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 <date>", "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
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_itemsflag 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_pointsbuild 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?