# 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
{% 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 %}
{% 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"
```