From b2e9dcadb9f9c11dc94958cac32339193b3dd24c Mon Sep 17 00:00:00 2001 From: Mischa Date: Tue, 23 Jun 2026 23:47:03 +0200 Subject: [PATCH] docs: add template refactor implementation plan (Milestone 2) Three tasks: stats/cycling macros, date-range macro, latent bug fixes. Full code in every step, no placeholders. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr --- .../plans/2026-06-23-template-refactor.md | 993 ++++++++++++++++++ 1 file changed, 993 insertions(+) create mode 100644 docs/working/plans/2026-06-23-template-refactor.md diff --git a/docs/working/plans/2026-06-23-template-refactor.md b/docs/working/plans/2026-06-23-template-refactor.md new file mode 100644 index 0000000..2bed4c7 --- /dev/null +++ b/docs/working/plans/2026-06-23-template-refactor.md @@ -0,0 +1,993 @@ +# 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" +```