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

24 KiB
Raw Permalink Blame History

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.jsjs/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 devtwig.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/panelsinitFilterBar(), 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:

/* ── 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_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:

{% 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
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:

    {% 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:

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: 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:


{% 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 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 <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_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?