# Template Refactor Implementation Plan > **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:** Extract stats computation and panel HTML into Twig macros, extract date range formatting into a macro, and fix two latent bugs in inactive templates — with zero visual change. **Architecture:** Three Twig macros live in `templates/macros/`. `trip.html.twig` imports and calls the stats/cycling macros, shrinking from 386 to ~260 lines. `story.html.twig` and `stories.html.twig` both use the date-range macro. `feed-map.html.twig` and `map.html.twig` get DOMContentLoaded wrappers and correct asset registration timing. **Tech Stack:** Grav 2.0, Twig 3, Playwright (test runner), `make test-ui` → `npx playwright test`. ## Global Constraints - Working directory for all file edits: `user/themes/intotheeast/templates/` - Dev server URL: `http://localhost:8081` - Demo trip used for testing: `/trips/italy-2026-demo` - Zero visual change — no HTML structure, CSS class, or JS logic changes - Twig macros are imported with `{% import 'macros/file.html.twig' as alias %}` inside `{% block content %}` - Macros live in `templates/macros/` (new directory — create it) - All macro arguments are positional (Twig macros use named params with defaults as of Twig 1.12, but positional is fine here) --- ## File Map | Action | Path | Responsibility | |---|---|---| | Create | `templates/macros/stats.html.twig` | Stats computation + stats panel HTML | | Create | `templates/macros/cycling.html.twig` | Cycling panel HTML (all JS placeholders) | | Create | `templates/macros/date-range.html.twig` | Smart condensed date range string | | Modify | `templates/trip.html.twig` | Import + call stats/cycling macros; remove 130 lines | | Modify | `templates/story.html.twig` | Replace 15-line date logic with macro call | | Modify | `templates/stories.html.twig` | Add `{% block map_assets %}`; replace date logic with macro call | | Modify | `templates/partials/feed-map.html.twig` | Remove asset calls; wrap map init in DOMContentLoaded | | Modify | `templates/map.html.twig` | Move `{% block map_assets %}` to top level; add DOMContentLoaded | | Modify | `templates/dailies.html.twig` | Add `{% block map_assets %}` override | --- ### Task 1: Stats + cycling macros; update trip.html.twig **Files:** - Create: `templates/macros/stats.html.twig` - Create: `templates/macros/cycling.html.twig` - Modify: `templates/trip.html.twig` **Interfaces:** - Produces: `stats_panel(journal_entries, page, journal_count, has_gpx)` — renders `
` - Produces: `cycling_panel()` — renders `
` - Both macros are imported at the top of `{% block content %}` in trip.html.twig - [ ] **Step 1: Create the macros directory** ```bash mkdir -p user/themes/intotheeast/templates/macros ``` - [ ] **Step 2: Create `templates/macros/stats.html.twig`** This macro receives the entry collection, computes all server-side stats internally, and renders the complete stats panel. The `id="stat-distance"` placeholder is left empty for JS to fill after page load. ```twig {% macro stats_panel(journal_entries, page, journal_count, has_gpx) %} {% set days_on_road = 0 %} {% if page.header.date_end is not empty %} {% set start_ts = page.header.date_start|date('U') %} {% set end_ts = page.header.date_end|date('U') %} {% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %} {% else %} {% set first_ts = null %} {% for entry in journal_entries %} {% set ts = entry.date|date('U') %} {% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %} {% endfor %} {% if first_ts is not null %} {% set diff_seconds = "now"|date('U') - first_ts %} {% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %} {% set days_on_road = days_raw < 1 ? 1 : days_raw %} {% endif %} {% endif %} {% set seen_lower = [] %} {% set country_display = [] %} {% for entry in journal_entries %} {% if entry.header.location_country is not empty %} {% set lower = entry.header.location_country|trim|lower %} {% if lower not in seen_lower %} {% set seen_lower = seen_lower|merge([lower]) %} {% set country_display = country_display|merge([entry.header.location_country|trim]) %} {% endif %} {% endif %} {% endfor %} {% set seen_city_lower = [] %} {% set city_display = [] %} {% for entry in journal_entries %} {% if entry.header.location_city is not empty %} {% set lower = entry.header.location_city|trim|lower %} {% if lower not in seen_city_lower %} {% set seen_city_lower = seen_city_lower|merge([lower]) %} {% set city_display = city_display|merge([entry.header.location_city|trim]) %} {% endif %} {% endif %} {% endfor %} {% set temp_min = null %} {% set temp_max = null %} {% for entry in journal_entries %} {% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %} {% set t = entry.header.weather_temp_c %} {% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %} {% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %} {% endif %} {% endfor %}
{{ days_on_road }} {{ days_on_road == 1 ? 'day' : 'days' }} on the road
{{ journal_count }} {{ journal_count == 1 ? 'entry' : 'entries' }} posted
{{ country_display|length }} {{ country_display|length == 1 ? 'country' : 'countries' }} visited
{{ city_display|length }} {{ city_display|length == 1 ? 'city' : 'cities' }} visited
{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}
{% if temp_min is not null %} {{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }} {% else %} {% endif %} °C range
{% if country_display|length > 0 %}

{{ country_display|join(' · ') }}

{% endif %}

{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}

{% endmacro %} ``` - [ ] **Step 3: Create `templates/macros/cycling.html.twig`** All stat values are JS placeholders — JS fills them via `MapUtils.parseGpxFiles()` after page load. No computation needed. ```twig {% macro cycling_panel() %}
🚴 Cycling Stats
km distance
m ↑ gain
m ↓ loss
m highest
m lowest
moving time
km/h avg speed
{% endmacro %} ``` - [ ] **Step 4: Update `templates/trip.html.twig`** Replace lines 1–9 (extends + opening of block content) with macro imports added at the top of `{% block content %}`. Then: - Remove lines 25–76 (stats computation: days, countries, cities, temp range) — the macro handles this now - Remove lines 150–230 (both panel HTML divs) — replaced by macro calls The full replacement for `trip.html.twig`: ```twig {% extends 'partials/base.html.twig' %} {% block content %} {% import 'macros/stats.html.twig' as stats_m %} {% import 'macros/cycling.html.twig' as cycling_m %} {% block map_assets %} {% do assets.addCss('theme://css-compiled/map.css') %} {% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %} {% endblock %} {% set dailies_page = grav.pages.find(page.route ~ '/dailies') %} {% set stories_page = grav.pages.find(page.route ~ '/stories') %} {% set journal_entries = dailies_page ? dailies_page.children.published() : [] %} {% set story_entries = stories_page ? stories_page.children.published() : [] %} {% set all_items = [] %} {% for e in journal_entries %} {% set all_items = all_items|merge([{'type': 'journal', 'page': e, 'date': e.header.date}]) %} {% endfor %} {% for s in story_entries %} {% set all_items = all_items|merge([{'type': 'story', 'page': s, 'date': s.header.date}]) %} {% endfor %} {% set all_items = all_items|sort_by_key('date', 4) %} {% set journal_count = journal_entries|length %} {% set story_count = story_entries|length %} {% 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 %} {% set gpx_urls = [] %} {% for name, media in page.media.all %} {% if name|split('.')|last == 'gpx' %} {% set gpx_urls = gpx_urls|merge([page.url ~ '/' ~ name]) %} {% endif %} {% endfor %} {% set has_gpx = gpx_urls|length > 0 %} {% set map_entries = [] %} {% for item in all_items %} {% if item.page.header.lat is not empty and item.page.header.lng is not empty %} {% set map_entries = map_entries|merge([{ 'type': item.type, 'lat': item.page.header.lat|number_format(6, '.', ''), 'lng': item.page.header.lng|number_format(6, '.', ''), 'slug': item.page.slug, 'title': item.page.title, 'url': item.page.url, 'force_connect': item.page.header.force_connect ? true : false, 'transport_mode': item.page.header.transport_mode ? item.page.header.transport_mode : null }]) %} {% endif %} {% endfor %}

{{ page.title }}

{% if page.header.date_start %}

{{ page.header.date_start|date('d M Y') }} {% if page.header.date_end %} — {{ page.header.date_end|date('d M Y') }}{% else %} — Ongoing{% endif %}

{% endif %} {{ journal_count }} journal {{ journal_count == 1 ? 'entry' : 'entries' }} {% if story_count > 0 %} · {{ story_count }} {{ story_count == 1 ? 'story' : 'stories' }}{% endif %}
{% if has_gpx %} {% endif %}
{{ stats_m.stats_panel(journal_entries, page, journal_count, has_gpx) }} {% if has_gpx %} {{ cycling_m.cycling_panel() }} {% endif %}
{% 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 %}

No entries yet. The journey is about to begin.

{% endif %}
{% endblock %} ``` - [ ] **Step 5: Clear Grav cache** ```bash make stop && make start ``` Or if cache clearing is available without restart: ```bash curl -s http://localhost:8081/admin/cache/clear 2>/dev/null || make stop && make start ``` - [ ] **Step 6: Verify trip page renders correctly** Open `http://localhost:8081/trips/italy-2026-demo` in a browser. Check: - Page loads without Twig errors (no white page, no "Twig error" text) - Trip header shows title, dates, entry count - Click "Stats ▾" button — stats panel expands showing days, entries, countries, cities, temp range as numbers (not empty/zero) - `stat-distance` shows "—" then fills after a moment (JS loading GPX) - Click "Cycling ▾" button — cycling panel expands with 7 stat placeholders (all "—" initially, then fill) - Map shows markers - Browser console has no JS errors - [ ] **Step 7: Run existing Playwright tests** ```bash make test-ui ``` Expected: all tests pass (F1–F7 filter tests, M1–M5 map tests). If any fail, investigate before committing. - [ ] **Step 8: Commit** ```bash git add user/themes/intotheeast/templates/macros/stats.html.twig \ user/themes/intotheeast/templates/macros/cycling.html.twig \ user/themes/intotheeast/templates/trip.html.twig git commit -m "refactor: extract stats and cycling panels to Twig macros" ``` --- ### Task 2: Date range macro; update story.html.twig and stories.html.twig **Files:** - Create: `templates/macros/date-range.html.twig` - Modify: `templates/story.html.twig` (lines 19–34) - Modify: `templates/stories.html.twig` (lines 49–52 + add `{% block map_assets %}`) **Interfaces:** - Consumes: nothing from Task 1 - Produces: `format_date_range(start_date, end_date)` — outputs a text string: - Single day (no end_date or end == start): `23 Jun 2026` - Same month: `12 – 15 Jun 2026` - Same year, different month: `12 Jun – 3 Jul 2026` - Different years: `28 Dec 2025 – 3 Jan 2026` - [ ] **Step 1: Create `templates/macros/date-range.html.twig`** Logic extracted verbatim from `story.html.twig` lines 19–34, generalised to accept arguments instead of reading `page.date` / `page.header.end_date` directly. ```twig {% macro format_date_range(start_date, end_date) %} {%- if end_date is not empty and end_date|date('Y-m-d') != start_date|date('Y-m-d') -%} {%- set sd = start_date|date('d') -%} {%- set sm = start_date|date('M') -%} {%- set sy = start_date|date('Y') -%} {%- set ed = end_date|date('d') -%} {%- set em = end_date|date('M') -%} {%- set ey = end_date|date('Y') -%} {%- if sy == ey and sm == em -%} {{- sd ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}} {%- elseif sy == ey -%} {{- sd ~ ' ' ~ sm ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}} {%- else -%} {{- sd ~ ' ' ~ sm ~ ' ' ~ sy ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey -}} {%- endif -%} {%- else -%} {{- start_date|date('d M Y') -}} {%- endif %} {% endmacro %} ``` Note: the `{%- -%}` whitespace-control tags prevent the macro from outputting leading/trailing newlines, so it can be used inline in HTML without extra whitespace. - [ ] **Step 2: Update `templates/story.html.twig`** Replace lines 19–34 (date computation) with a macro call. The `{% import %}` goes at the top of `{% block content %}`, just after the `{% block content %}` opening tag. Find the existing `{% block content %}` line and the lines immediately after it: ```twig {% block content %} {% set hero_url = null %} ``` Replace with: ```twig {% block content %} {% import 'macros/date-range.html.twig' as dr_m %} {% set hero_url = null %} ``` Then find and replace the entire date computation block (lines 19–34): ```twig {% set date_str = page.date|date('d M Y') %} {% if page.header.end_date and page.header.end_date|date('Y-m-d') != page.date|date('Y-m-d') %} {% set sd = page.date|date('d') %} {% set sm = page.date|date('M') %} {% set sy = page.date|date('Y') %} {% set ed = page.header.end_date|date('d') %} {% set em = page.header.end_date|date('M') %} {% set ey = page.header.end_date|date('Y') %} {% if sy == ey and sm == em %} {% set date_str = sd ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %} {% elseif sy == ey %} {% set date_str = sd ~ ' ' ~ sm ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %} {% else %} {% set date_str = sd ~ ' ' ~ sm ~ ' ' ~ sy ~ ' – ' ~ ed ~ ' ' ~ em ~ ' ' ~ ey %} {% endif %} {% endif %} ``` Replace with: ```twig {% set date_str = dr_m.format_date_range(page.date, page.header.end_date ?? null) %} ``` - [ ] **Step 3: Update `templates/stories.html.twig`** This file needs two changes: adding `{% block map_assets %}` (CSS timing fix, from Task 4's bug) and replacing the date string logic. Replace the current opening of the file: ```twig {% extends 'partials/base.html.twig' %} {% block content %} {% set stories = page.children.published().order('date', 'asc') %} ``` With: ```twig {% extends 'partials/base.html.twig' %} {% block map_assets %} {% do assets.addCss('theme://css-compiled/map.css') %} {% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %} {% endblock %} {% block content %} {% import 'macros/date-range.html.twig' as dr_m %} {% set stories = page.children.published().order('date', 'asc') %} ``` Then find and replace the date_str logic inside the stories loop (lines 49–52 of the original): ```twig {% set date_str = story.date|date('d M Y') %} {% if story.header.end_date %} {% set date_str = story.date|date('d M') ~ '–' ~ story.header.end_date|date('d M Y') %} {% endif %} ``` Replace with: ```twig {% set date_str = dr_m.format_date_range(story.date, story.header.end_date ?? null) %} ``` - [ ] **Step 4: Clear cache and verify story page** ```bash make stop && make start ``` Open `http://localhost:8081/trips/italy-2026-demo/stories/val-dorcia-at-dawn` (single-day story): - Date renders as e.g. `3 Jun 2026` (no range) - No Twig errors Open `http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time` (multi-day story if it has end_date): - Date renders condensed if same month (e.g. `5 – 7 Jun 2026`) - No Twig errors Open `http://localhost:8081/trips/italy-2026-demo/stories`: - Story cards show dates in the same smart format - Map renders without console errors - [ ] **Step 5: Run Playwright tests** ```bash make test-ui ``` Expected: all tests pass. Story tests in `tests/ui/stories/stories.spec.js` should pass. - [ ] **Step 6: Commit** ```bash git add user/themes/intotheeast/templates/macros/date-range.html.twig \ user/themes/intotheeast/templates/story.html.twig \ user/themes/intotheeast/templates/stories.html.twig git commit -m "refactor: extract date range macro; fix stories.html.twig asset registration" ``` --- ### Task 3: Fix latent bugs in feed-map.html.twig, map.html.twig, dailies.html.twig **Files:** - Modify: `templates/partials/feed-map.html.twig` - Modify: `templates/map.html.twig` - Modify: `templates/dailies.html.twig` **The two bugs:** 1. `feed-map.html.twig` calls `assets.addCss/addJs` inside `{% block content %}`, after `base.html.twig` has already rendered `{{ assets.css() }}` in ``. Map.css never reaches ``. Fix: remove asset calls from the partial; add `{% block map_assets %}` in callers. 2. `feed-map.html.twig` and `map.html.twig` call `new maplibregl.Map()` in inline ` {% endif %} ``` - [ ] **Step 2: Update `templates/map.html.twig`** Move `{% block map_assets %}` outside `{% block content %}` (so it runs at line 11 of base.html.twig, before assets.css), and wrap the map init in DOMContentLoaded. Full replacement for `map.html.twig`: ```twig {% extends 'partials/base.html.twig' %} {% block map_assets %} {% do assets.addCss('theme://css-compiled/map.css') %} {% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %} {% endblock %} {% block content %} {% set trip_page = page.parent() %} {% set tracker_page = grav.pages.find(page.parent().route ~ '/dailies') %} {% set all_entries = tracker_page ? tracker_page.children.published() : [] %} {% set gpx_urls = [] %} {% for name, media in trip_page.media.all %} {% if name|split('.')|last == 'gpx' %} {% set gpx_urls = gpx_urls|merge([trip_page.url ~ '/' ~ name]) %} {% endif %} {% endfor %} {% set map_entries = [] %} {% for entry in all_entries %} {% if entry.header.lat is not empty and entry.header.lng is not empty %} {% set hero_url = null %} {% if entry.header.hero_image and entry.media[entry.header.hero_image] is defined %} {% set hero_url = entry.media[entry.header.hero_image].cropResize(240, 135).url %} {% elseif entry.media.images|length > 0 %} {% set hero_url = entry.media.images|first.cropResize(240, 135).url %} {% endif %} {% set map_entries = map_entries|merge([{ 'lat': entry.header.lat|number_format(6, '.', ''), 'lng': entry.header.lng|number_format(6, '.', ''), 'title': entry.title, 'date': entry.date|date('d M Y'), 'url': entry.url, 'hero': hero_url, 'force_connect': entry.header.force_connect ? true : false, 'transport_mode': entry.header.transport_mode ? entry.header.transport_mode : null }]) %} {% endif %} {% endfor %}
{% endblock %} ``` - [ ] **Step 3: Update `templates/dailies.html.twig`** Add `{% block map_assets %}` override so map.css reaches ``. Place it between `{% extends %}` and `{% block content %}`. Find: ```twig {% extends 'default.html.twig' %} {% block content %} ``` Replace with: ```twig {% extends 'default.html.twig' %} {% block map_assets %} {% do assets.addCss('theme://css-compiled/map.css') %} {% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %} {% endblock %} {% block content %} ``` - [ ] **Step 4: Clear cache and verify map page** ```bash make stop && make start ``` Open `http://localhost:8081/trips/italy-2026-demo/map`: - MapLibre canvas renders - Markers appear on the map - Browser console has no `maplibregl is not defined` error (the M1 test catches this) Open `http://localhost:8081/trips/italy-2026-demo/stories`: - Stories mini-map renders (MapLibre canvas visible) - No JS errors in console - [ ] **Step 5: Run full test suite** ```bash make test-ui ``` Expected: all tests pass, including M1 (map page), M3 (dailies mini-map), M9–M11 (stories map). - [ ] **Step 6: Commit** ```bash git add user/themes/intotheeast/templates/partials/feed-map.html.twig \ user/themes/intotheeast/templates/map.html.twig \ user/themes/intotheeast/templates/dailies.html.twig git commit -m "fix: DOMContentLoaded wrapper + correct asset registration in map templates" ```